pax_global_header00006660000000000000000000000064136122600560014513gustar00rootroot0000000000000052 comment=a695bda1edd9a574532bd099fe0c19968a5c5be4 exchangelib-3.1.1/000077500000000000000000000000001361226005600137665ustar00rootroot00000000000000exchangelib-3.1.1/.codacy.yaml000066400000000000000000000000571361226005600161740ustar00rootroot00000000000000--- exclude_paths: - tests/** - scripts/** exchangelib-3.1.1/.flake8000066400000000000000000000001011361226005600151310ustar00rootroot00000000000000[flake8] max-line-length = 120 exclude = .git,__pycache__,vendor exchangelib-3.1.1/.github/000077500000000000000000000000001361226005600153265ustar00rootroot00000000000000exchangelib-3.1.1/.github/FUNDING.yml000066400000000000000000000000271361226005600171420ustar00rootroot00000000000000github: [ecederstrand] exchangelib-3.1.1/.gitignore000066400000000000000000000001471361226005600157600ustar00rootroot00000000000000.eggs .idea .coverage *.html *.pyc *.swp *.egg-info build dist __pycache__ settings.yml scratch*.py exchangelib-3.1.1/.travis.yml000066400000000000000000000024661361226005600161070ustar00rootroot00000000000000language: python os: linux dist: bionic sudo: true python: - "3.5" - "3.6" - "3.7" - "3.8" - "nightly" # - "pypy3" before_install: - openssl aes-256-cbc -K $encrypted_ae8487d57299_key -iv $encrypted_ae8487d57299_iv -in settings.yml.enc -out settings.yml -d install: # Install master branches of Cython and Cython-built packages if we are testing on nightly since the C API of # CPython changes often and fixes for Python nightly are slow to reach released versions. - if [[ "$( python --version | grep '[a|b]' )" ]] ; then pip install git+https://github.com/cython/cython.git ; fi - if [[ "$( python --version | grep '[a|b]' )" ]] ; then pip install git+https://github.com/lxml/lxml.git ; fi - if [[ "$( python --version | grep '[a|b]' )" ]] ; then pip install git+https://github.com/yaml/pyyaml.git ; fi - pip install . # Install test dependencies manually since we're calling tests/__init__.py directly in the 'script' section - pip install PyYAML requests_mock psutil coverage coveralls flake8 script: - coverage run --source=exchangelib setup.py test after_success: coveralls jobs: include: - stage: wipe_test_account # Wipe contents of the test account after a complete build, to avoid account going over quota script: PYTHONPATH=./ python scripts/wipe_test_account.py python: "3.8" os: linux exchangelib-3.1.1/CHANGELOG.md000066400000000000000000000653101361226005600156040ustar00rootroot00000000000000Change Log ========== HEAD ---- 3.1.1 ----- - The `max_wait` argument to `FaultTolerance` changed semantics. Previously, it triggered when the delay until the next attempt would exceed this value. It now triggers after the given timespan since the *first* request attempt. - Fixed a bug when pagination is combined with `max_items` (#710) - Other minor bug fixes 3.1.0 ----- - Removed the legacy autodiscover implementation. - Added `QuerySet.depth()` to configure item traversal of querysets. Default is `Shallow` except for the `CommonViews` folder where default is `Associated`. - Updating credentials on `Account.protocol` after getting an `UnauthorizedError` now works. 3.0.0 ----- - The new Autodiscover implementation added in 2.2.0 is now default. To switch back to the old implementation, set the environment variable `EXCHANGELIB_AUTODISCOVER_VERSION=legacy`. - Removed support for Python 2 2.2.0 ----- - Added support for specifying a separate retry policy for the autodiscover service endpoint selection. Set via the `exchangelib.autodiscover.legacy.INITIAL_RETRY_POLICY` module variable for the the old autodiscover implementation, and via the `exchangelib.autodiscover.Autodiscovery.INITIAL_RETRY_POLICY` class variable for the new one. - Support the authorization code OAuth 2.0 grant type (see issue #698) - Removed the `RootOfHierarchy.permission_set` field. It was causing too many failures in the wild. - The full autodiscover response containing all contents of the reponse is now available as `Account.ad_response`. - Added a new Autodiscover implementation that is closer to the specification and easier to debug. To switch to the new implementation, set the environment variable `EXCHANGELIB_AUTODISCOVER_VERSION=new`. The old one is still the default if the variable is not set, or set to `EXCHANGELIB_AUTODISCOVER_VERSION=legacy`. - The `Item.mime_content` field was switched back from a string type to a `bytes` type. It turns out trying to decode the data was an error (see issue #709). 2.1.1 ----- - Bugfix release. 2.1.0 ----- - Added support for OAuth 2.0 authentication - Fixed a bug in `RelativeMonthlyPattern` and `RelativeYearlyPattern` where the `weekdays` field was thought to be a list, but is in fact a single value. Renamed the field to `weekday` to reflect the change. - Added support for archiving items to the archive mailbox, if the account has one. - Added support for getting delegate information on an Account, as `Account.delegates`. - Added support for the `ConvertId` service. Available as `Protocol.convert_ids()`. 2.0.1 ----- - Fixed a bug where version 2.x could not open autodiscover cache files generated by version 1.x packages. 2.0.0 ----- - `Item.mime_content` is now a text field instead of a binary field. Encoding and decoding is done automatically. - The `Item.item_id`, `Folder.folder_id` and `Occurrence.item_id` fields that were renamed to just `id` in 1.12.0, have now been removed. - The `Persona.persona_id` field was replaced with `Persona.id` and `Persona.changekey`, to align with the `Item` and `Folder` classes. - In addition to bulk deleting via a QuerySet (`qs.delete()`), it is now possible to also bulk send, move and copy items in a QuerySet (via `qs.send()`, `qs.move()` and `qs.copy()`, respectively). - SSPI support was added but dependencies are not installed by default since it only works in Win32 environments. Install as `pip install exchangelib[sspi]` to get SSPI support. Install with `pip install exchangelib[complete]` to get both Kerberos and SSPI auth. - The custom `extern_id` field is no longer registered by default. If you require this field, register it manually as part of your setup code on the item types you need: ```python from exchangelib import CalendarItem, Message, Contact, Task from exchangelib.extended_properties import ExternId CalendarItem.register('extern_id', ExternId) Message.register('extern_id', ExternId) Contact.register('extern_id', ExternId) Task.register('extern_id', ExternId) ``` - The `ServiceAccount` class has been removed. If you want fault tolerance, set it in a `Configuration` object: ```python from exchangelib import Configuration, Credentials, FaultTolerance c = Credentials('foo', 'bar') config = Configuration(credentials=c, retry_policy=FaultTolerance()) ``` - It is now possible to use Kerberos and SSPI auth without providing a dummy `Credentials('', '')` object. - The `has_ssl` argument of `Configuration` was removed. If you want to connect to a plain HTTP endpoint, pass the full URL in the `service_endpoint` argument. - We no longer look in `types.xsd` for a hint of which API version the server is running. Instead, we query the service directly, starting with the latest version first. 1.12.5 ------ - Bugfix release. 1.12.4 ------ - Fix bug that left out parts of the folder hierarchy when traversing `account.root`. - Fix bug that did not properly find all attachments if an item has a mix of item and file attachments. 1.12.3 ------ - Add support for reading and writing `PermissionSet` field on folders. - Add support for Exchange 2019 build IDs. 1.12.2 ------ - Add `Protocol.expand_dl()` to get members of a distribution list. 1.12.1 ------ - Lower the session pool size automatically in response to ErrorServerBusy and ErrorTooManyObjectsOpened errors from the server. - Unusual slicing and indexing (e.g. `inbox.all()[9000]` and `inbox.all()[9000:9001]`) is now efficient. - Downloading large attachments is now more memory-efficient. We can now stream the file content without ever storing the full file content in memory, using the new `Attachment.fp` context manager. 1.12.0 ------ - Add a MAINFEST.in to ensure the LICENSE file gets included + CHANGELOG.md and README.md to sdist tarball - Renamed `Item.item_id`, `Folder.folder_id` and `Occurrence.item_id` to just `Item.id`, `Folder.id` and `Occurrence.id`, respectively. This removes redundancy in the naming and provides consistency. For all classes that have an ID, the ID can now be accessed using the `id` attribute. Backwards compatibility and deprecation warnings were added. - Support folder traversal without creating a full cache of the folder hierarchy first, using the `some_folder // 'sub_folder' // 'leaf'` (double-slash) syntax. - Fix a bug in traversal of public and archive folders. These folder hierarchies are now fully supported. - Fix a bug where the timezone of a calendar item changed when the item was fetched and then saved. - Kerberos support is now optional and Kerberos dependencies are not installed by default. Install as `pip install exchangelib[kerberos]` to get Kerberos support. 1.11.4 ------ - Improve back off handling when receiving `ErrorServerBusy` error messages from the server - Fixed bug where `Account.root` and its children would point to the root folder of the connecting account instead of the target account when connecting to other accounts. 1.11.3 ------ - Add experimental Kerberos support. This adds the `pykerberos` package, which needs the following system packages to be installed on Ubuntu/Debian systems: `apt-get install build-essential libssl-dev libffi-dev python-dev libkrb5-dev`. 1.11.2 ------ - Bugfix release 1.11.1 ------ - Bugfix release 1.11.0 ------ - Added `cancel` to `CalendarItem` and `CancelCalendarItem` class to allow cancelling meetings that were set up - Added `accept`, `decline` and `tentatively_accept` to `CalendarItem` as wrapper methods - Added `accept`, `decline` and `tentatively_accept` to `MeetingRequest` to respond to incoming invitations - Added `BaseMeetingItem` (inheriting from `Item`) being used as base for MeetingCancellation, MeetingMessage, MeetingRequest and MeetingResponse - Added `AssociatedCalendarItemId` (property), `AssociatedCalendarItemIdField` and `ReferenceItemIdField` - Added `PostReplyItem` - Removed `Folder.get_folder_by_name()` which has been deprecated since version `1.10.2`. - Added `Item.copy(to_folder=some_folder)` method which copies an item to the given folder and returns the ID of the new item. - We now respect the back off value of an `ErrorServerBusy` server error. - Added support for fetching free/busy availability information ofr a list of accounts. - Added `Message.reply()`, `Message.reply_all()`, and `Message.forward()` methods. - The full search API now works on single folders *and* collections of folders, e.g. `some_folder.glob('foo*').filter()`, `some_folder.children.filter()` and `some_folder.walk().filter()`. - Deprecated `EWSService.CHUNKSIZE` in favor of a per-request chunk\_size available on `Account.bulk_foo()` methods. - Support searching the GAL and other contact folders using `some_contact_folder.people()`. - Deprecated the `page_size` argument for `QuerySet.iterator()` because it was inconsistent with other API methods. You can still set the page size of a queryset like this: ```python qs = a.inbox.filter(...).iterator() qs.page_size = 123 for item in items: print(item) ``` 1.10.7 ------ - Added support for registering extended properties on folders. - Added support for creating, updating, deleting and emptying folders. 1.10.6 ------ - Added support for getting and setting `Account.oof_settings` using the new `OofSettings` class. - Added snake\_case named shortcuts to all distinguished folders on the `Account` model. E.g. `Account.search_folders`. 1.10.5 ------ - Bugfix release 1.10.4 ------ - Added support for most item fields. The remaining ones are mentioned in issue \#203. 1.10.3 ------ - Added an `exchangelib.util.PrettyXmlHandler` log handler which will pretty-print and highlight XML requests and responses. 1.10.2 ------ - Greatly improved folder navigation. See the 'Folders' section in the README - Added deprecation warnings for `Account.folders` and `Folder.get_folder_by_name()` 1.10.1 ------ - Bugfix release 1.10.0 ------ - Removed the `verify_ssl` argument to `Account`, `discover` and `Configuration`. If you need to disable TLS verification, register a custom `HTTPAdapter` class. A sample adapter class is provided for convenience: ```python from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter ``` 1.9.6 ----- - Support new Office365 build numbers 1.9.5 ----- - Added support for the `effective_rights`field on items and folders. - Added support for custom `requests` transport adapters, to allow proxy support, custom TLS validation etc. - Default value for the `affected_task_occurrences` argument to `Item.move_to_trash()`, `Item.soft_delete()` and `Item.delete()` was changed to `'AllOccurrences'` as a less surprising default when working with simple tasks. - Added `Task.complete()` helper method to mark tasks as complete. 1.9.4 ----- - Added minimal support for the `PostItem` item type - Added support for the `DistributionList` item type - Added support for receiving naive datetimes from the server. They will be localized using the new `default_timezone` attribute on `Account` - Added experimental support for recurring calendar items. See examples in issue \#37. 1.9.3 ----- - Improved support for `filter()`, `.only()`, `.order_by()` etc. on indexed properties. It is now possible to specify labels and subfields, e.g. `.filter(phone_numbers=PhoneNumber(label='CarPhone', phone_number='123'))` `.filter(phone_numbers__CarPhone='123')`, `.filter(physical_addresses__Home__street='Elm St. 123')`, .only('physical\_addresses\_\_Home\_\_street')\` etc. - Improved performance of `.order_by()` when sorting on multiple fields. - Implemented QueryString search. You can now filter using an EWS QueryString, e.g. `filter('subject:XXX')` 1.9.2 ----- - Added `EWSTimeZone.localzone()` to get the local timezone - Support `some_folder.get(item_id=..., changekey=...)` as a shortcut to get a single item when you know the ID and changekey. - Support attachments on Exchange 2007 1.9.1 ----- - Fixed XML generation for Exchange 2010 and other picky server versions - Fixed timezone localization for `EWSTimeZone` created from a static timezone 1.9.0 ----- - Expand support for `ExtendedProperty` to include all possible attributes. This required renaming the `property_id` attribute to `property_set_id`. - When using the `Credentials` class, `UnauthorizedError` is now raised if the credentials are wrong. - Add a new `version` attribute to `Configuration`, to force the server version if version guessing does not work. Accepts a `exchangelib.version.Version` object. - Rework bulk operations `Account.bulk_foo()` and `Account.fetch()` to return some exceptions unraised, if it is deemed the exception does not apply to all items. This means that e.g. `fetch()` can return a mix of `` `Item `` and `ErrorItemNotFound` instances, if only some of the requested `ItemId` were valid. Other exceptions will be raised immediately, e.g. `ErrorNonExistentMailbox` because the exception applies to all items. It is the responsibility of the caller to check the type of the returned values. - The `Folder` class has new attributes `total_count`, `unread_count` and `child_folder_count`, and a `refresh()` method to update these values. - The argument to `Account.upload()` was renamed from `upload_data` to just `data` - Support for using a string search expression for `Folder.filter()` was removed. It was a cool idea but using QuerySet chaining and `Q` objects is even cooler and provides the same functionality, and more. - Add support for `reminder_due_by` and `reminder_minutes_before_start` fields on `Item` objects. Submitted by `@vikipha`. - Added a new `ServiceAccount` class which is like `Credentials` but does what `is_service_account` did before. If you need fault-tolerane and used `Credentials(..., is_service_account=True)` before, use `ServiceAccount` now. This also disables fault-tolerance for the `Credentials` class, which is in line with what most users expected. - Added an optional `update_fields` attribute to `save()` to specify only some fields to be updated. - Code in in `folders.py` has been split into multiple files, and some classes will have new import locaions. The most commonly used classes have a shortcut in \_\_init\_\_.py - Added support for the `exists` lookup in filters, e.g. `my_folder.filter(categories__exists=True|False)` to filter on the existence of that field on items in the folder. - When filtering, `foo__in=value` now requires the value to be a list, and `foo__contains` requires the value to be a list if the field itself is a list, e.g. `categories__contains=['a', 'b']`. - Added support for fields and enum entries that are only supported in some EWS versions - Added a new field `Item.text_body` which is a read-only version of HTML body content, where HTML tags are stripped by the server. Only supported from Exchange 2013 and up. - Added a new choice `WorkingElsewhere` to the `CalendarItem.legacy_free_busy_status` enum. Only supported from Exchange 2013 and up. 1.8.1 ----- - Fix completely botched `Message.from` field renaming in 1.8.0 - Improve performance of QuerySet slicing and indexing. For example, `account.inbox.all()[10]` and `account.inbox.all()[:10]` now only fetch 10 items from the server even though `account.inbox.all()` could contain thousands of messages. 1.8.0 ----- - Renamed `Message.from` field to `Message.author`. `from` is a Python keyword so `from` could only be accessed as `Getattr(my_essage, 'from')` which is just stupid. - Make `EWSTimeZone` Windows timezone name translation more robust - Add read-only `Message.message_id` which holds the Internet Message Id - Memory and speed improvements when sorting querysets using `order_by()` on a single field. - Allow setting `Mailbox` and `Attendee`-type attributes as plain strings, e.g.: ```python calendar_item.organizer = 'anne@example.com' calendar_item.required_attendees = ['john@example.com', 'bill@example.com'] message.to_recipients = ['john@example.com', 'anne@example.com'] ``` 1.7.6 ----- - Bugfix release 1.7.5 ----- - `Account.fetch()` and `Folder.fetch()` are now generators. They will do nothing before being evaluated. - Added optional `page_size` attribute to `QuerySet.iterator()` to specify the number of items to return per HTTP request for large query results. Default `page_size` is 100. - Many minor changes to make queries less greedy and return earlier 1.7.4 ----- - Add Python2 support 1.7.3 ----- - Implement attachments support. It's now possible to create, delete and get attachments connected to any item type: ```python from exchangelib.folders import FileAttachment, ItemAttachment # Process attachments on existing items for item in my_folder.all(): for attachment in item.attachments: local_path = os.path.join('/tmp', attachment.name) with open(local_path, 'wb') as f: f.write(attachment.content) print('Saved attachment to', local_path) # Create a new item with an attachment item = Message(...) binary_file_content = 'Hello from unicode æøå'.encode('utf-8') # Or read from file, BytesIO etc. my_file = FileAttachment(name='my_file.txt', content=binary_file_content) item.attach(my_file) my_calendar_item = CalendarItem(...) my_appointment = ItemAttachment(name='my_appointment', item=my_calendar_item) item.attach(my_appointment) item.save() # Add an attachment on an existing item my_other_file = FileAttachment(name='my_other_file.txt', content=binary_file_content) item.attach(my_other_file) # Remove the attachment again item.detach(my_file) ``` Be aware that adding and deleting attachments from items that are already created in Exchange (items that have an `item_id`) will update the `changekey` of the item. - Implement `Item.headers` which contains custom Internet message headers. Primarily useful for `Message` objects. Read-only for now. 1.7.2 ----- - Implement the `Contact.physical_addresses` attribute. This is a list of `exchangelib.folders.PhysicalAddress` items. - Implement the `CalendarItem.is_all_day` boolean to create all-day appointments. - Implement `my_folder.export()` and `my_folder.upload()`. Thanks to @SamCB! - Fixed `Account.folders` for non-distinguished folders - Added `Folder.get_folder_by_name()` to make it easier to get sub-folders by name. - Implement `CalendarView` searches as `my_calendar.view(start=..., end=...)`. A view differs from a normal `filter()` in that a view expands recurring items and returns recurring item occurrences that are valid in the time span of the view. - Persistent storage location for autodiscover cache is now platform independent - Implemented custom extended properties. To add support for your own custom property, subclass `exchangelib.folders.ExtendedProperty` and call `register()` on the item class you want to use the extended property with. When you have registered your extended property, you can use it exactly like you would use any other attribute on this item type. If you change your mind, you can remove the extended property again with `deregister()`: ```python class LunchMenu(ExtendedProperty): property_id = '12345678-1234-1234-1234-123456781234' property_name = 'Catering from the cafeteria' property_type = 'String' CalendarItem.register('lunch_menu', LunchMenu) item = CalendarItem(..., lunch_menu='Foie gras et consommé de légumes') item.save() CalendarItem.deregister('lunch_menu') ``` - Fixed a bug on folder items where an existing HTML body would be converted to text when calling `save()`. When creating or updating an item body, you can use the two new helper classes `exchangelib.Body` and `exchangelib.HTMLBody` to specify if your body should be saved as HTML or text. E.g.: ```python item = CalendarItem(...) # Plain-text body item.body = Body('Hello UNIX-beard pine user!') # Also plain-text body, works as before item.body = 'Hello UNIX-beard pine user!' # Exchange will see this as an HTML body and display nicely in clients item.body = HTMLBody('Hello happy OWA user!') item.save() ``` 1.7.1 ----- - Fix bug where fetching items from a folder that can contain multiple item types (e.g. the Deleted Items folder) would only return one item type. - Added `Item.move(to_folder=...)` that moves an item to another folder, and `Item.refresh()` that updates the Item with data from EWS. - Support reverse sort on individual fields in `order_by()`, e.g. `my_folder.all().order_by('subject', '-start')` - `Account.bulk_create()` was added to create items that don't need a folder, e.g. `Message.send()` - `Account.fetch()` was added to fetch items without knowing the containing folder. - Implemented `SendItem` service to send existing messages. - `Folder.bulk_delete()` was moved to `Account.bulk_delete()` - `Folder.bulk_update()` was moved to `Account.bulk_update()` and changed to expect a list of `(Item, fieldnames)` tuples where Item is e.g. a `Message` instance and `fieldnames` is a list of attributes names that need updating. E.g.: ```python items = [] for i in range(4): item = Message(subject='Test %s' % i) items.append(item) account.sent.bulk_create(items=items) item_changes = [] for i, item in enumerate(items): item.subject = 'Changed subject' % i item_changes.append(item, ['subject']) account.bulk_update(items=item_changes) ``` 1.7.0 ----- - Added the `is_service_account` flag to `Credentials`. `is_service_account=False` disables the fault-tolerant error handling policy and enables immediate failures. - `Configuration` now expects a single `credentials` attribute instead of separate `username` and `password` attributes. - Added support for distinguished folders `Account.trash`, `Account.drafts`, `Account.outbox`, `Account.sent` and `Account.junk`. - Renamed `Folder.find_items()` to `Folder.filter()` - Renamed `Folder.add_items()` to `Folder.bulk_create()` - Renamed `Folder.update_items()` to `Folder.bulk_update()` - Renamed `Folder.delete_items()` to `Folder.bulk_delete()` - Renamed `Folder.get_items()` to `Folder.fetch()` - Made various policies for message saving, meeting invitation sending, conflict resolution, task occurrences and deletion available on `bulk_create()`, `bulk_update()` and `bulk_delete()`. - Added convenience methods `Item.save()`, `Item.delete()`, `Item.soft_delete()`, `Item.move_to_trash()`, and methods `Message.send()` and `Message.send_and_save()` that are specific to `Message` objects. These methods make it easier to create, update and delete single items. - Removed `fetch(.., with_extra=True)` in favor of the more fine-grained `fetch(.., only_fields=[...])` - Added a `QuerySet` class that supports QuerySet-returning methods `filter()`, `exclude()`, `only()`, `order_by()`, `reverse()``values()` and `values_list()` that all allow for chaining. `QuerySet` also has methods `iterator()`, `get()`, `count()`, `exists()` and `delete()`. All these methods behave like their counterparts in Django. 1.6.2 ----- - Use of `my_folder.with_extra_fields = True` to get the extra fields in `Item.EXTRA_ITEM_FIELDS` is deprecated (it was a kludge anyway). Instead, use `my_folder.get_items(ids, with_extra=[True, False])`. The default was also changed to `True`, to avoid head-scratching with newcomers. 1.6.1 ----- - Simplify `Q` objects and `Restriction.from_source()` by using Item attribute names in expressions and kwargs instead of EWS FieldURI values. Change `Folder.find_items()` to accept either a search expression, or a list of `Q` objects just like Django `filter()` does. E.g.: ```python ids = account.calendar.find_items( "start < '2016-01-02T03:04:05T' and end > '2016-01-01T03:04:05T' and categories in ('foo', 'bar')", shape=IdOnly ) q1, q2 = (Q(subject__iexact='foo') | Q(subject__contains='bar')), ~Q(subject__startswith='baz') ids = account.calendar.find_items(q1, q2, shape=IdOnly) ``` 1.6.0 ----- - Complete rewrite of `Folder.find_items()`. The old `start`, `end`, `subject` and `categories` args are deprecated in favor of a Django QuerySet filter() syntax. The supported lookup types are `__gt`, `__lt`, `__gte`, `__lte`, `__range`, `__in`, `__exact`, `__iexact`, `__contains`, `__icontains`, `__contains`, `__icontains`, `__startswith`, `__istartswith`, plus an additional `__not` which translates to `!=`. Additionally, *all* fields on the item are now supported in `Folder.find_items()`. **WARNING**: This change is backwards-incompatible! Old uses of `Folder.find_items()` like this: ```python ids = account.calendar.find_items( start=tz.localize(EWSDateTime(year, month, day)), end=tz.localize(EWSDateTime(year, month, day + 1)), categories=['foo', 'bar'], ) ``` must be rewritten like this: ```python ids = account.calendar.find_items( start__lt=tz.localize(EWSDateTime(year, month, day + 1)), end__gt=tz.localize(EWSDateTime(year, month, day)), categories__contains=['foo', 'bar'], ) ``` failing to do so will most likely result in empty or wrong results. - Added a `exchangelib.restrictions.Q` class much like Django Q objects that can be used to create even more complex filtering. Q objects must be passed directly to `exchangelib.services.FindItem`. 1.3.6 ----- - Don't require sequence arguments to `Folder.*_items()` methods to support `len()` (e.g. generators and `map` instances are now supported) - Allow empty sequences as argument to `Folder.*_items()` methods 1.3.4 ----- - Add support for `required_attendees`, `optional_attendees` and `resources` attribute on `folders.CalendarItem`. These are implemented with a new `folders.Attendee` class. 1.3.3 ----- - Add support for `organizer` attribute on `CalendarItem`. Implemented with a new `folders.Mailbox` class. 1.2 --- - Initial import exchangelib-3.1.1/CODE_OF_CONDUCT.md000066400000000000000000000064271361226005600165760ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at erik@cederstrand.dk. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq exchangelib-3.1.1/LICENSE000066400000000000000000000024461361226005600150010ustar00rootroot00000000000000Copyright (c) 2009-2018 Erik Cederstrand Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. exchangelib-3.1.1/MANIFEST.in000066400000000000000000000001131361226005600155170ustar00rootroot00000000000000include MANIFEST.in include LICENSE include CHANGELOG.md include README.md exchangelib-3.1.1/README.md000066400000000000000000001516401361226005600152540ustar00rootroot00000000000000Exchange Web Services client library ==================================== This module provides an well-performing, well-behaving, platform-independent and simple interface for communicating with a Microsoft Exchange 2007-2016 Server or Office365 using Exchange Web Services (EWS). It currently implements autodiscover, and functions for searching, creating, updating, deleting, exporting and uploading calendar, mailbox, task, contact and distribution list items. [![image](https://img.shields.io/pypi/v/exchangelib.svg)](https://pypi.org/project/exchangelib/) [![image](https://img.shields.io/pypi/pyversions/exchangelib.svg)](https://pypi.org/project/exchangelib/) [![image](https://api.codacy.com/project/badge/Grade/5f805ad901054a889f4b99a82d6c1cb7)](https://www.codacy.com/app/ecederstrand/exchangelib?utm_source=github.com&utm_medium=referral&utm_content=ecederstrand/exchangelib&utm_campaign=Badge_Grade) [![image](https://api.travis-ci.com/ecederstrand/exchangelib.png)](http://travis-ci.com/ecederstrand/exchangelib) [![image](https://coveralls.io/repos/github/ecederstrand/exchangelib/badge.svg?branch=master)](https://coveralls.io/github/ecederstrand/exchangelib?branch=master) ## Teaser Here's a short example of how `exchangelib` works. Let's print the first 100 inbox messages in reverse order: ```python from exchangelib import Credentials, Account credentials = Credentials('john@example.com', 'topsecret') account = Account('john@example.com', credentials=credentials, autodiscover=True) for item in account.inbox.all().order_by('-datetime_received')[:100]: print(item.subject, item.sender, item.datetime_received) ``` ## Installation You can install this package from PyPI: ```bash pip install exchangelib ``` The default installation does not support Kerberos or SSPI. For additional Kerberos or SSPI support, install with the extra `kerberos` or `sspi` dependencies (please note that SSPI is only supported on Windows): ```bash pip install exchangelib[kerberos] pip install exchangelib[sspi] ``` To get both, install as: ```bash pip install exchangelib[complete] ``` To install the very latest code, install directly from GitHub instead: ```bash pip install git+https://github.com/ecederstrand/exchangelib.git ``` `exchangelib` uses the `lxml` package, and `pykerberos` to support Kerberos authentication. 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 # For Kerberos support, also install these: 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 # For Kerberos support, also install these: pkg install krb5 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 ```python from exchangelib import DELEGATE, IMPERSONATION, Account, Credentials, OAuth2Credentials, \ OAuth2AuthorizationCodeCredentials, FaultTolerance, Configuration, NTLM, GSSAPI, SSPI, \ OAUTH2, Build, Version from exchangelib.autodiscover import AutodiscoverProtocol # 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 supported, if your server expects that. credentials = Credentials(username='MYWINDOMAIN\\myusername', 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 unavailable or responding with error messages. # This prevents automated scripts from overwhelming a failing or overloaded server, and hides # intermittent service outages that often happen in large Exchange installations. # An Account is the account on the Exchange server that you want to connect to. This can be # the account associated with the credentials you connect with, or any other account on the # server that you have been granted access to. If, for example, you want to access a shared # folder, create an Account instance using the email address of the account that the shared # folder belongs to, and access the shared folder through this account. # 'primary_smtp_address' is the primary SMTP address assigned the account. If you enable # autodiscover, an alias address will work, too. In this case, 'Account.primary_smtp_address' # will be set to the primary SMTP address. my_account = Account(primary_smtp_address='myusername@example.com', credentials=credentials, autodiscover=True, access_type=DELEGATE) johns_account = Account(primary_smtp_address='john@example.com', credentials=credentials, autodiscover=True, access_type=DELEGATE) marys_account = Account(primary_smtp_address='mary@example.com', credentials=credentials, autodiscover=True, access_type=DELEGATE) still_marys_account = Account(primary_smtp_address='alias_for_mary@example.com', credentials=credentials, autodiscover=True, access_type=DELEGATE) # Full autodiscover data is availale on the Account object: my_account.ad_response # Set up a target account and do an autodiscover lookup to find the target EWS endpoint. account = Account(primary_smtp_address='john@example.com', credentials=credentials, autodiscover=True, access_type=DELEGATE) # If your credentials have been given impersonation access to the target account, set a # different 'access_type': account = Account(primary_smtp_address='john@example.com', credentials=credentials, autodiscover=True, access_type=IMPERSONATION) # If the server doesn't support autodiscover, or you want to avoid the overhead of autodiscover, # use a Configuration object to set the server location instead: config = Configuration(server='mail.example.com', credentials=credentials) account = Account(primary_smtp_address='john@example.com', config=config, autodiscover=False, access_type=DELEGATE) # 'exchangelib' will attempt to guess the server version and authentication method. If you # have a really bizarre or locked-down installation and the guessing fails, or you want to avoid # the extra network traffic, you can set the auth method and version explicitly instead: version = Version(build=Build(15, 0, 12, 34)) config = Configuration( server='example.com', credentials=credentials, version=version, auth_type=NTLM ) # 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 for up to an hour. # This is configurable: config = Configuration(retry_policy=FaultTolerance(max_wait=3600), credentials=credentials) account = Account(primary_smtp_address='john@example.com', config=config) # Autodiscovery will also use this policy, but only for the final autodiscover endpoint. # Here's how to change the policy for connecting to autodiscover candidate servers. # Old autodiscover implementation import exchangelib.autodiscover.legacy exchangelib.autodiscover.legacy.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=30) # New autodiscover implementation from exchangelib.autodiscover import Autodiscovery Autodiscovery.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=30) # Kerberos and SSPI authentication are supported via the GSSAPI and SSPI auth types. config = Configuration(server='example.com', auth_type=GSSAPI) config = Configuration(server='example.com', auth_type=SSPI) # OAuth is supported via the OAUTH2 auth type and the OAuth2Credentials class. # Use OAuth2AuthorizationCodeCredentials for the authorization code flow (useful # for applications that access multiple accounts). credentials = OAuth2Credentials(client_id='MY_ID', client_secret='MY_SECRET', tenant_id='TENANT_ID') credentials = OAuth2AuthorizationCodeCredentials(client_id='MY_ID', client_secret='MY_SECRET', authorization_code='AUTH_CODE') credentials = OAuth2AuthorizationCodeCredentials(client_id='MY_ID', client_secret='MY_SECRET', access_token='EXISTING_TOKEN') config = Configuration(credentials=credentials, auth_type=OAUTH2) # Applications using the authorization code flow that let exchangelib refresh # access tokens for them probably want to store the refreshed tokens so users # don't have to re-authorize. Subclass OAuth2AuthorizationCodeCredentials and # override on_token_auto_refreshed(): class MyCredentials(OAuth2AuthorizationCodeCredentials): def on_token_auto_refreshed(self, access_token): store_it_somewhere(access_token) # Let the object update its internal state! super().on_token_auto_refreshed(access_token) # For applications that use the authorization code flow and rely on an external # provider to refresh access tokens (and thus are unable to provide a client ID # and secret to exchangelib), subclass OAuth2AuthorizationCodeCredentials and # override refresh(). class MyCredentials(OAuth2AuthorizationCodeCredentials): def refresh(self): self.access_token = ... # 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: ews_url = account.protocol.service_endpoint ews_auth_type = account.protocol.auth_type primary_smtp_address = account.primary_smtp_address # You can now create the Account without autodiscovering, using the cached values: config = Configuration(service_endpoint=ews_url, credentials=credentials, auth_type=ews_auth_type) account = Account( primary_smtp_address=primary_smtp_address, config=config, autodiscover=False, access_type=DELEGATE, ) # Autodiscover can take a lot of time, specially the part that figures out the autodiscover # server to contact for a specific email domain. For this reason, we will create a persistent, # per-user, on-disk cache containing a map of previous, successful domain -> autodiscover server # lookups. This cache is 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: from exchangelib.autodiscover import clear_cache clear_cache() ``` ## Proxies and custom TLS validation If you need proxy support or custom TLS validation, you can supply a custom 'requests' transport adapter class, as described in . Here's an example using different custom root certificates depending on the server to connect to: ```python from urllib.parse import urlparse import requests.adapters from exchangelib.protocol import BaseProtocol class RootCAAdapter(requests.adapters.HTTPAdapter): """An HTTP adapter that uses a custom root CA certificate at a hard coded location""" def cert_verify(self, conn, url, verify, cert): cert_file = { 'example.com': '/path/to/example.com.crt', 'mail.internal': '/path/to/mail.internal.crt', }[urlparse(url).hostname] super().cert_verify(conn=conn, url=url, verify=cert_file, cert=cert) # Tell exchangelib to use this adapter class instead of the default BaseProtocol.HTTP_ADAPTER_CLS = RootCAAdapter ``` Here's an example of adding proxy support: ```python import requests.adapters from exchangelib.protocol import BaseProtocol class ProxyAdapter(requests.adapters.HTTPAdapter): def send(self, *args, **kwargs): kwargs['proxies'] = { 'http': 'http://10.0.0.1:1243', 'https': 'http://10.0.0.1:4321', } return super().send(*args, **kwargs) # Tell exchangelib to use this adapter class instead of the default BaseProtocol.HTTP_ADAPTER_CLS = ProxyAdapter ``` `exchangelib` provides a sample adapter which ignores TLS validation errors. Use at own risk. ```python from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter # Tell exchangelib to use this adapter class instead of the default BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter ``` ## User-Agent You can supply a custom 'User-Agent' for your application. By default, `exchangelib` will use: `exchangelib/ (python-requests/)` Here's an example using different User-Agent: ```python from exchangelib.protocol import BaseProtocol # Tell exchangelib to use this user-agent instead of the default 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`. ```python # There are multiple ways of navigating the folder tree and searching for folders. Globbing and # absolute path may create unexpected results if your folder names contain slashes. # The folder structure is cached after first access to a folder hierarchy. This means that external # changes to the folder structure will not show up until you clear the cache. Here's how to clear # the cache of each of the currently supported folder hierarchies: from exchangelib import Account, Folder a = Account(...) a.root.refresh() a.public_folders_root.refresh() a.archive_root.refresh() some_folder = a.root / 'Some Folder' some_folder.parent some_folder.parent.parent.parent some_folder.root # Returns the root of the folder structure, at any level. Same as Account.root some_folder.children # A generator of child folders some_folder.absolute # Returns the absolute path, as a string some_folder.walk() # A generator returning all subfolders at arbitrary depth this level # Globbing uses the normal UNIX globbing syntax some_folder.glob('foo*') # Return child folders matching the pattern some_folder.glob('*/foo') # Return subfolders named 'foo' in any child folder some_folder.glob('**/foo') # Return subfolders named 'foo' at any depth some_folder / 'sub_folder' / 'even_deeper' / 'leaf' # Works like pathlib.Path # You can also drill down into the folder structure without using the cache. This works like # the single slash syntax, but does not start by creating a cache the folder hierarchy. This is # useful if your account contains a huge number of folders, and you already know where to go. some_folder // 'sub_folder' // 'even_deeper' // 'leaf' some_folder.parts # returns some_folder and all its parents, as Folder instances # tree() returns a string representation of the tree structure at the given level print(a.root.tree()) ''' root ├── inbox │ └── todos └── archive ├── Last Job ├── exchangelib issues └── Mom ''' # Folders have some useful counters: a.inbox.total_count a.inbox.child_folder_count a.inbox.unread_count # Update the counters a.inbox.refresh() # Folders can be created, updated and deleted: f = Folder(parent=a.inbox, name='My New Folder') f.save() f.name = 'My New Subfolder' f.save() f.delete() # Delete all items in a folder f.empty() # Also delete all subfolders in the folder f.empty(delete_sub_folders=True) # Recursively delete all items in a folder, and all subfolders and their content. This is # like `empty(delete_sub_folders=True)` but attempts to protect distinguished folders from # being deleted. Use with caution! f.wipe() ``` ## Dates, datetimes and timezones EWS has some special requirements on datetimes and timezones. You need to use the special `EWSDate`, `EWSDateTime` and `EWSTimeZone` classes when working with dates. ```python from datetime import datetime, timedelta import pytz from exchangelib import EWSTimeZone, EWSDateTime, EWSDate # EWSTimeZone works just like pytz.timezone() tz = EWSTimeZone.timezone('Europe/Copenhagen') # You can also get the local timezone defined in your operating system tz = EWSTimeZone.localzone() # EWSDate and EWSDateTime work just like datetime.datetime and datetime.date. Always create # timezone-aware datetimes with EWSTimeZone.localize(): localized_dt = tz.localize(EWSDateTime(2017, 9, 5, 8, 30)) right_now = tz.localize(EWSDateTime.now()) # Datetime math works transparently two_hours_later = localized_dt + timedelta(hours=2) two_hours = two_hours_later - localized_dt two_hours_later += timedelta(hours=2) # Dates my_date = EWSDate(2017, 9, 5) today = EWSDate.today() also_today = right_now.date() also_today += timedelta(days=10) # UTC helpers. 'UTC' is the UTC timezone as an EWSTimeZone instance. # 'UTC_NOW' returns a timezone-aware UTC timestamp of current time. from exchangelib import UTC, UTC_NOW right_now_in_utc = UTC.localize(EWSDateTime.now()) right_now_in_utc = UTC_NOW() # Already have a Python datetime object you want to use? Make sure it's localized. Then pass # it to from_datetime(). pytz_tz = pytz.timezone('Europe/Copenhagen') py_dt = pytz_tz.localize(datetime(2017, 12, 11, 10, 9, 8)) ews_now = EWSDateTime.from_datetime(py_dt) ``` ## Creating, updating, deleting, sending, moving, archiving ```python # Here's an example of creating a calendar item in the user's standard calendar. If you want to # access a non-standard calendar, choose a different one from account.folders[Calendar]. # # You can create, update and delete single items: from exchangelib import Account, CalendarItem, Message, Mailbox, FileAttachment, HTMLBody from exchangelib.items import SEND_ONLY_TO_ALL, SEND_ONLY_TO_CHANGED from exchangelib.properties import DistinguishedFolderId a = Account(...) item = CalendarItem(folder=a.calendar, subject='foo') item.save() # This gives the item an 'id' and a 'changekey' value item.save(send_meeting_invitations=SEND_ONLY_TO_ALL) # Send a meeting invitation to attendees # Update a field. All fields have a corresponding Python type that must be used. item.subject = 'bar' # Print all available fields on the 'CalendarItem' class. Beware that some fields are read-only, or # read-only after the item has been saved or sent, and some fields are not supported on old # versions of Exchange. print(CalendarItem.FIELDS) item.save() # When the items has an item_id, this will update the item item.save(update_fields=['subject']) # Only updates certain fields. Accepts a list of field names. item.save(send_meeting_invitations=SEND_ONLY_TO_CHANGED) # Send invites only to attendee changes item.delete() # Hard deletinon item.delete(send_meeting_cancellations=SEND_ONLY_TO_ALL) # Send cancellations to all attendees item.soft_delete() # Delete, but keep a copy in the recoverable items folder item.move_to_trash() # Move to the trash folder item.move(a.trash) # Also moves the item to the trash folder item.copy(a.trash) # Creates a copy of the item to the trash folder item.archive(DistinguishedFolderId('inbox')) # Archives the item to inbox of the the archive mailbox # You can also send emails. If you don't want a local copy: m = Message( account=a, subject='Daily motivation', body='All bodies are beautiful', to_recipients=[ Mailbox(email_address='anne@example.com'), Mailbox(email_address='bob@example.com'), ], cc_recipients=['carl@example.com', 'denice@example.com'], # Simple strings work, too bcc_recipients=[ Mailbox(email_address='erik@example.com'), 'felicity@example.com', ], # Or a mix of both ) m.send() # Or, if you want a copy in e.g. the 'Sent' folder m = Message( account=a, folder=a.sent, subject='Daily motivation', body='All bodies are beautiful', to_recipients=[Mailbox(email_address='anne@example.com')] ) m.send_and_save() # Likewise, you can reply to and forward messages that are stored in your mailbox (i.e. they # have an item ID). m = a.sent.get(subject='Daily motivation') m.reply( subject='Re: Daily motivation', body='I agree', to_recipients=['carl@example.com', 'denice@example.com'] ) m.reply_all(subject='Re: Daily motivation', body='I agree') m.forward( subject='Fwd: Daily motivation', body='Hey, look at this!', to_recipients=['carl@example.com', 'denice@example.com'] ) # You can also edit a draft of a reply or forward forward_draft = m.create_forward( subject='Fwd: Daily motivation', body='Hey, look at this!', to_recipients=['carl@example.com', 'denice@example.com'] ).save(a.drafts) # gives you back the item forward_draft.reply_to = ['erik@example.com'] forward_draft.attach(FileAttachment(name='my_file.txt', content='hello world'.encode('utf-8'))) forward_draft.send() # now our forward has an extra reply_to field and an extra attachment. # 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: item.body = HTMLBody('Hello happy OWA user!') ``` ## Bulk operations ```python # Build a list of calendar items from exchangelib import Account, CalendarItem, EWSDateTime, EWSTimeZone, Attendee, Mailbox from exchangelib.properties import DistinguishedFolderId a = Account(...) tz = EWSTimeZone.timezone('Europe/Copenhagen') year, month, day = 2016, 3, 20 calendar_items = [] for hour in range(7, 17): calendar_items.append(CalendarItem( start=tz.localize(EWSDateTime(year, month, day, hour, 30)), end=tz.localize(EWSDateTime(year, month, day, hour + 1, 15)), subject='Test item', body='Hello from Python', location='devnull', categories=['foo', 'bar'], required_attendees = [Attendee( mailbox=Mailbox(email_address='user1@example.com'), response_type='Accept' )] )) # Create all items at once return_ids = a.bulk_create(folder=a.calendar, items=calendar_items) # Bulk fetch, when you have a list of item IDs and want the full objects. Returns a generator. calendar_ids = [(i.id, i.changekey) for i in calendar_items] items_iter = a.fetch(ids=calendar_ids) # If you only want some fields, use the 'only_fields' attribute items_iter = a.fetch(ids=calendar_ids, only_fields=['start', 'subject']) # Bulk update items. Each item must be accompanied by a list of attributes to update updated_ids = a.bulk_update(items=[(i, ('start', 'subject')) for i in calendar_items]) # Move many items to a new folder new_ids = a.bulk_move(ids=calendar_ids, to_folder=a.other_calendar) # Send draft messages in bulk message_ids = a.drafts.all().only('id', 'changekey') new_ids = a.bulk_send(ids=message_ids, save_copy=False) # Delete in bulk delete_results = a.bulk_delete(ids=calendar_ids) # Archive in bulk delete_results = a.bulk_archive(ids=calendar_ids, to_folder=DistinguishedFolderId('inbox')) # Bulk delete items found as a queryset a.inbox.filter(subject__startswith='Invoice').delete() # Likewise, you can bulk send, copy, move or archive items found in a QuerySet a.drafts.filter(subject__startswith='Invoice').send() # All kwargs are passed on to the equivalent bulk methods on the Account a.drafts.filter(subject__startswith='Invoice').send(save_copy=False) a.inbox.filter(subject__startswith='Invoice').copy(to_folder=a.inbox / 'Archive') a.inbox.filter(subject__startswith='Invoice').move(to_folder=a.inbox / 'Archive') a.inbox.filter(subject__startswith='Invoice').archive(to_folder=DistinguishedFolderId('inbox')) # You can change the default page size of bulk operations if you have a slow or busy server a.inbox.filter(subject__startswith='Invoice').delete(page_size=25) ``` ## Searching Searching is modeled after the Django QuerySet API, and a large part of the API is supported. Like in Django, the QuerySet is lazy and doesn't fetch anything before the QuerySet is iterated. QuerySets support chaining, so you can build the final query in multiple steps, and you can re-use a base QuerySet for multiple sub-searches. The QuerySet returns an iterator, and results are cached when the QuerySet is fully iterated the first time. Here are some examples of using the API: ```python from datetime import timedelta from exchangelib import Account, EWSDateTime, FolderCollection, Q, Message a = Account(...) # Not all fields on an item support searching. Here's the list of options for Message items print([f.name for f in Message.FIELDS if f.is_searchable]) all_items = a.inbox.all() # Get everything all_items_without_caching = a.inbox.all().iterator() # Get everything, but don't cache # Chain multiple modifiers to refine the query filtered_items = a.inbox.filter(subject__contains='foo').exclude(categories__icontains='bar') status_report = a.inbox.all().delete() # Delete the items returned by the QuerySet start = a.default_timezone.localize(EWSDateTime(2017, 1, 1)) end = a.default_timezone.localize(EWSDateTime(2018, 1, 1)) items_for_2017 = a.calendar.filter(start__range=(start, end)) # Filter by a date range # Same as filter() but throws an error if exactly one item isn't returned item = a.inbox.get(subject='unique_string') # If you only have the ID and possibly the changekey of an item, you can get the full item: a.inbox.get(id='AAMkADQy=') a.inbox.get(id='AAMkADQy=', changekey='FwAAABYA') # You can sort by a single or multiple fields. Prefix a field with '-' to reverse the sorting. # Sorting is efficient since it is done server-side, except when a calendar view sorting on # multiple fields. ordered_items = a.inbox.all().order_by('subject') reverse_ordered_items = a.inbox.all().order_by('-subject') # Indexed properties can be ordered on their individual components sorted_by_home_street = a.contacts.all().order_by('physical_addresses__Home__street') # Beware that sorting is done client-side here a.calendar.view(start=start, end=end).order_by('subject', 'categories') # Counting and exists n = a.inbox.all().count() # Efficient counting folder_is_empty = not a.inbox.all().exists() # Efficient tasting # Restricting returned attributes sparse_items = a.inbox.all().only('subject', 'start') # Dig deeper on indexed properties sparse_items = a.contacts.all().only('phone_numbers') sparse_items = a.contacts.all().only('phone_numbers__CarPhone') sparse_items = a.contacts.all().only('physical_addresses__Home__street') # Return values as dicts, not objects ids_as_dict = a.inbox.all().values('id', 'changekey') # Return values as nested lists values_as_list = a.inbox.all().values_list('subject', 'body') # Return values as a flat list all_subjects = a.inbox.all().values_list('physical_addresses__Home__street', flat=True) # A QuerySet can be indexed and sliced like a normal Python list. Slicing and indexing of the # QuerySet is efficient because it only fetches the necessary items to perform the slicing. # Slicing from the end is also efficient, but then you might as well reverse the sorting. first_ten = a.inbox.all().order_by('-subject')[:10] # Efficient. We only fetch 10 items last_ten = a.inbox.all().order_by('-subject')[:-10] # Efficient, but convoluted next_ten = a.inbox.all().order_by('-subject')[10:20] # Efficient. We only fetch 10 items single_item = a.inbox.all().order_by('-subject')[34298] # Efficient. We only fetch 1 item ten_items = a.inbox.all().order_by('-subject')[3420:3430] # Efficient. We only fetch 10 items random_emails = a.inbox.all().order_by('-subject')[::3] # This is just stupid, but works # The syntax for filter() is modeled after Django QuerySet filters. The following filter lookup # types are supported. Some lookups only work with string attributes. Range and less/greater # operators only work for date or numerical attributes. Some attributes are not searchable at all # via EWS: qs = a.calendar.all() qs.filter(subject='foo') # Returns items where subject is exactly 'foo'. Case-sensitive qs.filter(start__range=(start, end)) # Returns items within range qs.filter(subject__in=('foo', 'bar')) # Return items where subject is either 'foo' or 'bar' qs.filter(subject__not='foo') # Returns items where subject is not 'foo' qs.filter(start__gt=start) # Returns items starting after 'dt' qs.filter(start__gte=start) # Returns items starting on or after 'dt' qs.filter(start__lt=start) # Returns items starting before 'dt' qs.filter(start__lte=start) # Returns items starting on or before 'dt' qs.filter(subject__exact='foo') # Same as filter(subject='foo') qs.filter(subject__iexact='foo') # Returns items where subject is 'foo', 'FOO' or 'Foo' qs.filter(subject__contains='foo') # Returns items where subject contains 'foo' qs.filter(subject__icontains='foo') # Returns items where subject contains 'foo', 'FOO' or 'Foo' qs.filter(subject__startswith='foo') # Returns items where subject starts with 'foo' # Returns items where subject starts with 'foo', 'FOO' or 'Foo' qs.filter(subject__istartswith='foo') # Returns items that have at least one category assigned, i.e. the field exists on the item on the # server. qs.filter(categories__exists=True) # Returns items that have no categories set, i.e. the field does not exist on the item on the # server. qs.filter(categories__exists=False) # WARNING: Filtering on the 'body' field is not fully supported by EWS. There seems to be a window # before some internal search index is populated where case-sensitive or case-insensitive filtering # for substrings in the body element incorrectly returns an empty result, and sometimes the result # stays empty. # filter() also supports EWS QueryStrings. Just pass the string to filter(). QueryStrings cannot # be combined with other filters. We make no attempt at 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 a.inbox.filter('subject:XXX') # filter() also supports Q objects that are modeled after Django Q objects, for building complex # boolean logic search expressions. q = (Q(subject__iexact='foo') | Q(subject__contains='bar')) & ~Q(subject__startswith='baz') a.inbox.filter(q) # In this example, we filter by categories so we only get the items created by us. a.calendar.filter( start__lt=a.default_timezone.localize(EWSDateTime(2019, 1, 1)), end__gt=a.default_timezone.localize(EWSDateTime(2019, 1, 31)), categories__contains=['foo', 'bar'], ) # By default, EWS returns only the master recurring item. If you want recurring calendar # items to be expanded, use calendar.view(start=..., end=...) instead. items = a.calendar.view( start=a.default_timezone.localize(EWSDateTime(2019, 1, 31)), end=a.default_timezone.localize(EWSDateTime(2019, 1, 31)) + timedelta(days=1), ) for item in items: print(item.start, item.end, item.subject, item.body, item.location) # You can combine view() with other modifiers. For example, to check for conflicts before # adding a meeting from 8:00 to 10:00: has_conflicts = a.calendar.view( start=a.default_timezone.localize(EWSDateTime(2019, 1, 31, 8)), end=a.default_timezone.localize(EWSDateTime(2019, 1, 31, 10)), max_items=1 ).exists() # The filtering syntax also works on collections of folders, so you can search multiple folders in # a single request. a.inbox.children.filter(subject='foo') a.inbox.walk().filter(subject='foo') a.inbox.glob('foo*').filter(subject='foo') # Or select the folders individually FolderCollection(account=a, folders=[a.inbox, a.calendar]).filter(subject='foo') ``` ## Paging Paging EWS services, e.g. FindItem and, have a default page size of 100. You can change this value globally if you want: ```python import exchangelib.services exchangelib.services.CHUNK_SIZE = 25 ``` If you are working with very small or very large items, this may not be a reasonable value. For example, if you want to retrieve and save emails with large attachments, you can change this value on a per-queryset basis: ```python from exchangelib import Account a = Account(...) qs = a.inbox.all().only('mime_content') qs.page_size = 5 for msg in qs.iterator(): with open('%s.eml' % msg.item_id, 'w') as f: f.write(msg.mime_content) ``` Finally, the bulk methods defined on the `Account` class have an optional `chunk_size` argument that you can use to set a non-default page size when fetching, creating, updating or deleting items. ```python from exchangelib import Account, Message a = Account(...) huge_list_of_items = [Message(...) for i in range(10000)] return_ids = a.bulk_create(folder=a.inbox, items=huge_list_of_items, chunk_size=5) ``` ## Meetings The `CalendarItem` class allows you send out requests for meetings that you initiate or to cancel meetings that you already set out before. It is also possible to process `MeetingRequest` messages that are received. You can reply to these messages using the `AcceptItem`, `TentativelyAcceptItem` and `DeclineItem` classes. If you receive a cancellation for a meeting (class `MeetingCancellation`) that you already accepted then you can also process these by removing the entry from the calendar. ```python from exchangelib import Account, CalendarItem, EWSDateTime from exchangelib.items import MeetingRequest, MeetingCancellation, SEND_TO_ALL_AND_SAVE_COPY a = Account(...) # create a meeting request and send it out item = CalendarItem( account=a, folder=a.calendar, start=a.default_timezone.localize(EWSDateTime(2019, 1, 31, 8, 15)), end=a.default_timezone.localize(EWSDateTime(2019, 1, 31, 8, 45)), subject="Subject of Meeting", body="Please come to my meeting", required_attendees=['anne@example.com', 'bob@example.com'] ) item.save(send_meeting_invitations=SEND_TO_ALL_AND_SAVE_COPY) # cancel a meeting that was sent out using the CalendarItem class for calendar_item in a.calendar.all().order_by('-datetime_received')[:5]: # only the organizer of a meeting can cancel it if calendar_item.organizer.email_address == a.primary_smtp_address: calendar_item.cancel() # processing an incoming MeetingRequest for item in a.inbox.all().order_by('-datetime_received')[:5]: if isinstance(item, MeetingRequest): item.accept(body="Sure, I'll come") # Or: item.decline(body="No way!") # Or: item.tentatively_accept(body="Maybe...") # meeting requests can also be handled from the calendar - e.g. decline the meeting that was # received last. for calendar_item in a.calendar.all().order_by('-datetime_received')[:1]: calendar_item.decline() # processing an incoming MeetingCancellation (also delete from calendar) for item in a.inbox.all().order_by('-datetime_received')[:5]: if isinstance(item, MeetingCancellation): if item.associated_calendar_item_id: calendar_item = a.inbox.get( id=item.associated_calendar_item_id.id, changekey=item.associated_calendar_item_id.changekey ) calendar_item.delete() item.move_to_trash() ``` ## Contacts Fetching personas from a contact folder is supported using the same syntax as folders. Just start your query with `.people()`: ```python # Navigate to a contact folder and start the search from exchangelib import Account, DistributionList from exchangelib.indexed_properties import EmailAddress a = Account(...) folder = a.root / 'AllContacts' for p in folder.people(): print(p) for p in folder.people().only('display_name').filter(display_name='john').order_by('display_name'): print(p) # Getting a single contact in the GAL contact list gal = a.contacts / 'GAL Contacts' contact = gal.get(email_addresses=EmailAddress(email='lucas@example.com')) # All contacts with a gmail address gmail_contacts = list(gal.filter(email_addresses__contains=EmailAddress(email='gmail.com'))) # All Gmail email addresses gmail_addresses = [e.email for c in gal.filter(email_addresses__contains=EmailAddress(email='gmail.com')) for e in c.email_addresses] # All email addresses all_addresses = [e.email for c in gal.all() for e in c.email_addresses if not isinstance(c, DistributionList)] ``` Contact items have `photo` and `notes` fields, but they are apparently unused. Instead, you can add a contact photo and notes like this: ```python from exchangelib import Account, FileAttachment a = Account(...) contact = a.contacts.get(given_name='John') contact.body = 'This is a note' contact.save(update_fields=['body']) att = FileAttachment( name='ContactPicture.jpg', content_type='image/png', is_inline=False, is_contact_photo=True, content=open('john_profile_picture.png', 'rb').read(), ) contact.attach(att) ``` ## Extended properties Extended properties makes it possible to attach custom key-value pairs to items and folders on the Exchange server. There are multiple online resources that describe working with extended properties, and list many of the magic values that are used by existing Exchange clients to store common and custom properties. The following is not a comprehensive description of the possibilities, but we do intend to support all the possibilities provided by EWS. ```python # If folder items have extended properties, you need to register them before you can access them. # Create a subclass of ExtendedProperty and define a set of matching setup values: from exchangelib import Account, ExtendedProperty, CalendarItem, Folder, Message a = Account(...) class LunchMenu(ExtendedProperty): property_set_id = '12345678-1234-1234-1234-123456781234' property_name = 'Catering from the cafeteria' property_type = 'String' # Register the property on the item type of your choice CalendarItem.register('lunch_menu', LunchMenu) # Now your property is available as the attribute 'lunch_menu', just like any other attribute item = CalendarItem(..., lunch_menu='Foie gras et consommé de légumes') item.save() for i in a.calendar.all(): print(i.lunch_menu) # If you change your mind, jsut remove the property again CalendarItem.deregister('lunch_menu') # You can also create named properties (e.g. created from User Defined Fields in Outlook, see # issue #137): class LunchMenu(ExtendedProperty): distinguished_property_set_id = 'PublicStrings' property_name = 'Catering from the cafeteria' property_type = 'String' # We support extended properties with tags. This is the definition for the 'completed' and # 'followup' flag you can add to items in Outlook (see also issue #85): class Flag(ExtendedProperty): property_tag = 0x1090 property_type = 'Integer' # Or with property ID: class MyMeetingArray(ExtendedProperty): property_set_id = '00062004-0000-0000-C000-000000000046' property_type = 'BinaryArray' property_id = 32852 # Or using distinguished property sets combined with property ID (here as a hex value to align # with the format usually mentioned in Microsoft docs). This is the definition for a response to # an Outlook Vote request (see issue #198): class VoteResponse(ExtendedProperty): distinguished_property_set_id = 'Common' property_id = 0x00008524 property_type = 'String' # Extended properties also work with folders. For folders, it's only possible to register custom # fields on all folder types at once. This is because it's difficult to provide a consistent API # when some folders have custom fields and 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: class FolderSize(ExtendedProperty): property_tag = 0x0e08 property_type = 'Integer' Folder.register('size', FolderSize) 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: class FlagDue(ExtendedProperty): property_set_id = '00062003-0000-0000-C000-000000000046' property_id = 0x8105 property_type = 'SystemTime' Message.register('flag_due', FlagDue) ``` ## Attachments ```python # It's possible to create, delete and get attachments connected to any item type: # Process attachments on existing items. 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. import os.path from exchangelib import Account, FileAttachment, ItemAttachment, Message, CalendarItem, HTMLBody a = Account for item in a.inbox.all(): for attachment in item.attachments: if isinstance(attachment, FileAttachment): local_path = os.path.join('/tmp', attachment.name) with open(local_path, 'wb') as f: f.write(attachment.content) print('Saved attachment to', local_path) elif isinstance(attachment, ItemAttachment): if isinstance(attachment.item, Message): print(attachment.item.subject, attachment.item.body) # Streaming downloads of file attachment is supported. This reduces memory consumption since we # never store the full content of the file in-memory: for item in a.inbox.all(): for attachment in item.attachments: if isinstance(attachment, FileAttachment): local_path = os.path.join('/tmp', attachment.name) with open(local_path, 'wb') as f, attachment.fp as fp: buffer = fp.read(1024) while buffer: f.write(buffer) buffer = fp.read(1024) print('Saved attachment to', local_path) # Create a new item with an attachment item = Message(...) binary_file_content = 'Hello from unicode æøå'.encode('utf-8') # Or read from file, BytesIO etc. my_file = FileAttachment(name='my_file.txt', content=binary_file_content) item.attach(my_file) my_calendar_item = CalendarItem(...) my_appointment = ItemAttachment(name='my_appointment', item=my_calendar_item) item.attach(my_appointment) item.save() # Add an attachment on an existing item my_other_file = FileAttachment(name='my_other_file.txt', content=binary_file_content) item.attach(my_other_file) # Remove the attachment again item.detach(my_file) # If you want to embed an image in the item body, you can link to the file in the HTML message = Message(...) logo_filename = 'logo.png' with open(logo_filename, 'rb') as f: my_logo = FileAttachment(name=logo_filename, content=f.read(), is_inline=True, content_id=logo_filename) message.attach(my_logo) message.body = HTMLBody('Hello logo: ' % logo_filename) # Attachments cannot be updated via EWS. In this case, you must to detach the attachment, update # the relevant fields, and attach the updated attachment. # Be aware that adding and deleting attachments from items that are already created in Exchange # (items that have an item_id) will update the changekey of the item. ``` ## Recurring calendar items There is full read-write support for creating recurring calendar items. You can create daily, weekly, monthly and yearly recurrences (the latter two in relative and absolute versions). Here's an example of creating 7 occurrences on Mondays and Wednesdays of every third week, starting September 1, 2017: ```python from datetime import timedelta from exchangelib import Account, CalendarItem, EWSDateTime from exchangelib.fields import MONDAY, WEDNESDAY from exchangelib.recurrence import Recurrence, WeeklyPattern a = Account(...) start = a.default_timezone.localize(EWSDateTime(2017, 9, 1, 11)) end = start + timedelta(hours=2) item = CalendarItem( folder=a.calendar, start=start, end=end, subject='Hello Recurrence', recurrence=Recurrence( pattern=WeeklyPattern(interval=3, weekdays=[MONDAY, WEDNESDAY]), start=start.date(), number=7 ), ) # Occurrence data for the master item for i in a.calendar.filter(start__lt=end, end__gt=start): print(i.subject, i.start, i.end) print(i.recurrence) print(i.first_occurrence) print(i.last_occurrence) for o in i.modified_occurrences: print(o) for o in i.deleted_occurrences: print(o) # All occurrences expanded. The recurrence will span over 4 iterations of a 3-week period for i in a.calendar.view(start=start, end=start + timedelta(days=4*3*7)): print(i.subject, i.start, i.end) # 'modified_occurrences' and 'deleted_occurrences' of master items are read-only fields. To # delete or modify an occurrence, you must use 'view()' to fetch the occurrence and modify or # delete it: for occurrence in a.calendar.view(start=start, end=start + timedelta(days=4*3*7)): # Delete or update random occurrences. This will affect 'modified_occurrences' and # 'deleted_occurrences' of the master item. if occurrence.start.milliseconds % 2: # We receive timestamps as UTC but want to write them back as local timezone occurrence.start = occurrence.start.astimezone(a.default_timezone) occurrence.start += timedelta(minutes=30) occurrence.end = occurrence.end.astimezone(a.default_timezone) occurrence.end += timedelta(minutes=30) occurrence.subject = 'My new subject' occurrence.save() else: occurrence.delete() ``` ## Message timestamp fields Each `Message` item has four timestamp fields: - `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` instances. The `datetime_sent` value may be earlier than `datetime_created`. ## Out of Facility You can get and set OOF messages using the `Account.oof_settings` property: ```python from exchangelib import Account, OofSettings, EWSDateTime a = Account(...) # Get the current OOF settings a.oof_settings # Change the OOF settings to something else a.oof_settings = OofSettings( state=OofSettings.SCHEDULED, external_audience='Known', internal_reply="I'm in the pub. See ya guys!", external_reply="I'm having a business dinner in town", start=a.default_timezone.localize(EWSDateTime(2017, 11, 1, 11)), end=a.default_timezone.localize(EWSDateTime(2017, 12, 1, 11)), ) # Disable OOF messages a.oof_settings = OofSettings( state=OofSettings.DISABLED, internal_reply='', external_reply='', ) ``` ## 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: ```python from exchangelib import Account 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 a = Account(...) print(a.delegates) ``` ## Export and upload Exchange supports backup and restore of folder contents using special export and upload services. They are available on the `Account` model: ```python from exchangelib import Account a = Account(...) items = a.inbox.all().only('id', 'changekey') data = a.export(items) # Pass a list of Item instances or (item_id, changekey) tuples a.upload((a.inbox, d) for d in data) # Restore the items. Expects a list of (folder, data) tuples ``` ## Non-account methods ```python from exchangelib import Account, DLMailbox from exchangelib.properties import AlternateId, EWS_ID, OWA_ID a = Account(...) # Get timezone information from the server a.protocol.get_timezones() # Get room lists defined on the server a.protocol.get_roomlists() # Get rooms belonging to a specific room list for rl in a.protocol.get_roomlists(): a.protocol.get_rooms(rl) # Get account information for a list of names or email addresses for mailbox in a.protocol.resolve_names(['ann@example.com', 'bart@example.com']): print(mailbox.email_address) for mailbox, contact in a.protocol.resolve_names(['anne', 'bart'], return_full_contact_data=True): print(mailbox.email_address, contact.display_name) # Get all mailboxes on a distribution list for mailbox in a.protocol.expand_dl(DLMailbox(email_address='distro@example.com', mailbox_type='PublicDL')): print(mailbox.email_address) # Or just pass a string containing the SMTP address for mailbox in a.protocol.expand_dl('distro@example.com'): print(mailbox.email_address) # Convert item IDs from one format to another for converted_id in a.protocol.convert_ids([ AlternateId(id='AAA=', format=EWS_ID, mailbox=a.primary_smtp_address), ], destination_format=OWA_ID): print(converted_id) # Get searchable mailboxes. This method is only available to users who have been assigned # the Discovery Management RBAC role. (This feature works on Exchange 2013 onwards) for mailbox in a.protocol.get_searchable_mailboxes(): print(mailbox) ``` EWS supports getting availability information for a set of users in a certain timeframe. The server returns an object for each account containing free/busy information, including a list of calendar events in the user's calendar, and the working hours and timezone of the user. ```python from datetime import timedelta from exchangelib import Account, EWSDateTime a = Account(...) start = a.default_timezone.localize(EWSDateTime.now()) end = start + timedelta(hours=6) accounts = [(a, 'Organizer', False)] for busy_info in a.protocol.get_free_busy_info(accounts=accounts, start=start, end=end): print(busy_info) ``` The calendar events and working hours are returned as naive datetimes. To convert to timezone-aware datetimes, a bit of extra work is needed if the users are not known to be in the same timezone. ```python # Get all server timezones. We need that to convert 'working_hours_timezone' from datetime import timedelta from exchangelib import Account, EWSDateTime, EWSTimeZone a = Account(...) timezones = list(a.protocol.get_timezones(return_full_timezone_data=True)) # Get availability information for a list of accounts start = a.default_timezone.localize(EWSDateTime.now()) end = start + timedelta(hours=6) # get_free_busy_info() expects a list of (account, attendee_type, exclude_conflicts) tuples accounts = [(a, 'Organizer', False)] for busy_info in a.protocol.get_free_busy_info(accounts=accounts, start=start, end=end): # Convert the TimeZone object to a Microsoft timezone ID ms_id = busy_info.working_hours_timezone.to_server_timezone(timezones, start.year) account_tz = EWSTimeZone.from_ms_id(ms_id) print(account_tz, busy_info.working_hours) for event in busy_info.calendar_events: 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 to enable debug logging. This will output a huge amount of information about what is going on, most notable the actual XML documents that are going over the wire. This can be really handy to see which fields are being sent and received. ```python import logging # This handler will pretty-print and syntax highlight the request and response XML documents from exchangelib.util import PrettyXmlHandler logging.basicConfig(level=logging.DEBUG, handlers=[PrettyXmlHandler()]) # Your code using exchangelib goes here ``` Most class definitions have a docstring containing at least a URL to the MSDN page for the corresponding XML element. ```python from exchangelib import CalendarItem print(CalendarItem.__doc__) ``` # Tests The test suite is split into unit tests, and integration tests that require a real Exchange server. If you want to run the full test suite, you must provide setup parameters for a test account. Copy `settings.yml.sample` to `settings.yml` and change the default parameters. If a `settings.yml` is available, we will run the entire test suite. Otherwise, just the unit tests are run. *WARNING*: The test account should not contain valuable data. The tests try hard to no touch existing data in the account, but accidents happen. You can run either the entire test suite or individual tests. ```bash # Full test suite python setup.py test # Single test class or test case python -m unittest -k FolderTest.test_refresh # Or, if you want extreme levels of debug output: python -m unittest -k FolderTest.test_refresh -v ``` # Notes Almost all item fields are supported. The remaining ones are tracked in . exchangelib-3.1.1/docs/000077500000000000000000000000001361226005600147165ustar00rootroot00000000000000exchangelib-3.1.1/docs/_config.yml000066400000000000000000000003401361226005600170420ustar00rootroot00000000000000--- theme: jekyll-theme-minimal title : exchangelib author : name : Erik Cederstrand email : erik@cederstrand.dk github : ecederstrand markdown: kramdown github: username : ecederstrand project : exchangelib exchangelib-3.1.1/docs/foo.md000066400000000000000000000001641361226005600160240ustar00rootroot00000000000000--- layout: default title: otherpage test --- ## This is another page We'd like this to show up in the navigation exchangelib-3.1.1/docs/index.md000066400000000000000000000007121361226005600163470ustar00rootroot00000000000000--- layout: default title: exchangelib --- ## Exchange Web Services client library This module provides an well-performing, well-behaving, platform-independent and simple interface for communicating with a Microsoft Exchange 2007-2016 Server or Office365 using Exchange Web Services (EWS). It currently implements autodiscover, and functions for searching, creating, updating, deleting, exporting and uploading calendar, mailbox, task and contact items exchangelib-3.1.1/exchangelib/000077500000000000000000000000001361226005600162375ustar00rootroot00000000000000exchangelib-3.1.1/exchangelib/__init__.py000066400000000000000000000037461361226005600203620ustar00rootroot00000000000000from .account import Account from .attachments import FileAttachment, ItemAttachment from .autodiscover import discover from .configuration import Configuration from .credentials import DELEGATE, IMPERSONATION, Credentials, OAuth2Credentials, \ OAuth2AuthorizationCodeCredentials from .ewsdatetime import EWSDate, EWSDateTime, EWSTimeZone, UTC, UTC_NOW from .extended_properties import ExtendedProperty from .folders import Folder, RootOfHierarchy, FolderCollection, SHALLOW, DEEP from .items import AcceptItem, TentativelyAcceptItem, DeclineItem, CalendarItem, CancelCalendarItem, Contact, \ DistributionList, Message, PostItem, Task from .properties import Body, HTMLBody, ItemId, Mailbox, Attendee, Room, RoomList, UID, DLMailbox from .protocol import FaultTolerance, FailFast from .settings import OofSettings from .restriction import Q from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2 from .version import Build, Version __version__ = '3.1.1' __all__ = [ '__version__', 'Account', 'FileAttachment', 'ItemAttachment', 'discover', 'Configuration', 'DELEGATE', 'IMPERSONATION', 'Credentials', 'OAuth2AuthorizationCodeCredentials', 'OAuth2Credentials', 'EWSDate', 'EWSDateTime', 'EWSTimeZone', 'UTC', 'UTC_NOW', 'ExtendedProperty', 'Folder', 'RootOfHierarchy', 'FolderCollection', 'SHALLOW', 'DEEP', 'AcceptItem', 'TentativelyAcceptItem', 'DeclineItem', 'CalendarItem', 'CancelCalendarItem', 'Contact', 'DistributionList', 'Message', 'PostItem', 'Task', 'ItemId', 'Mailbox', 'DLMailbox', 'Attendee', 'Room', 'RoomList', 'Body', 'HTMLBody', 'UID', 'FailFast', 'FaultTolerance', 'OofSettings', 'Q', 'BASIC', 'DIGEST', 'NTLM', 'GSSAPI', 'SSPI', 'OAUTH2', 'Build', 'Version', ] def close_connections(): from .autodiscover import close_connections as close_autodiscover_connections from .protocol import close_connections as close_protocol_connections close_autodiscover_connections() close_protocol_connections() exchangelib-3.1.1/exchangelib/account.py000066400000000000000000000752001361226005600202510ustar00rootroot00000000000000from locale import getlocale from logging import getLogger from cached_property import threaded_cached_property from .autodiscover import discover from .configuration import Configuration from .credentials import DELEGATE, IMPERSONATION, ACCESS_TYPES from .errors import UnknownTimeZone from .ewsdatetime import EWSTimeZone, UTC from .fields import FieldPath from .folders import Folder, AdminAuditLogs, ArchiveDeletedItems, ArchiveInbox, ArchiveMsgFolderRoot, \ ArchiveRecoverableItemsDeletions, ArchiveRecoverableItemsPurges, ArchiveRecoverableItemsRoot, \ ArchiveRecoverableItemsVersions, ArchiveRoot, Calendar, Conflicts, Contacts, ConversationHistory, DeletedItems, \ Directory, Drafts, Favorites, IMContactList, Inbox, Journal, JunkEmail, LocalFailures, MsgFolderRoot, MyContacts, \ Notes, Outbox, PeopleConnect, PublicFoldersRoot, QuickContacts, RecipientCache, RecoverableItemsDeletions, \ RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsVersions, Root, SearchFolders, SentItems, \ ServerFailures, SyncIssues, Tasks, ToDoSearch, VoiceMail, BaseFolder from .items import Item, BulkCreateResult, HARD_DELETE, \ AUTO_RESOLVE, SEND_TO_NONE, SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, ALL_OCCURRENCIES, \ DELETE_TYPE_CHOICES, MESSAGE_DISPOSITION_CHOICES, CONFLICT_RESOLUTION_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES, \ SEND_MEETING_INVITATIONS_CHOICES, SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, \ SEND_MEETING_CANCELLATIONS_CHOICES, ID_ONLY from .properties import Mailbox, SendingAs, FolderId, DistinguishedFolderId from .protocol import Protocol from .queryset import QuerySet from .services import ExportItems, UploadItems, GetItem, CreateItem, UpdateItem, DeleteItem, MoveItem, SendItem, \ CopyItem, GetUserOofSettings, SetUserOofSettings, GetMailTips, ArchiveItem, GetDelegate from .settings import OofSettings from .util import get_domain, peek log = getLogger(__name__) class Account: """Models an Exchange server user account. The primary key for an account is its PrimarySMTPAddress """ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodiscover=False, credentials=None, config=None, locale=None, default_timezone=None): """ :param primary_smtp_address: The primary email address associated with the account on the Exchange server :param fullname: The full name of the account. Optional. :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' (default) and 'impersonation'. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. :param credentials: A Credentials object containing valid credentials for this account. :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled :param locale: The locale of the user, e.g. 'en_US'. Defaults to the locale of the host, if available. :param default_timezone: EWS may return some datetime values without timezone information. In this case, we will assume values to be in the provided timezone. Defaults to the timezone of the host. """ if '@' not in primary_smtp_address: raise ValueError("primary_smtp_address %r is not an email address" % primary_smtp_address) self.fullname = fullname # Assume delegate access if individual credentials are provided. Else, assume service user with impersonation self.access_type = access_type or (DELEGATE if credentials else IMPERSONATION) if self.access_type not in ACCESS_TYPES: raise ValueError("'access_type' %r must be one of %s" % (self.access_type, ACCESS_TYPES)) try: self.locale = locale or getlocale()[0] or None # get_locale() might not be able to determine the locale except ValueError as e: # getlocale() may throw ValueError if it fails to parse the system locale log.warning('Failed to get locale (%s)' % e) self.locale = None if not isinstance(self.locale, (type(None), str)): raise ValueError("Expected 'locale' to be a string, got %r" % self.locale) try: self.default_timezone = default_timezone or EWSTimeZone.localzone() except (ValueError, UnknownTimeZone) as e: # There is no translation from local timezone name to Windows timezone name, or e failed to find the # local timezone. log.warning('%s. Fallback to UTC', e.args[0]) self.default_timezone = UTC if not isinstance(self.default_timezone, EWSTimeZone): raise ValueError("Expected 'default_timezone' to be an EWSTimeZone, got %r" % self.default_timezone) if not isinstance(config, (Configuration, type(None))): raise ValueError("Expected 'config' to be a Configuration, got %r" % config) if autodiscover: if config: retry_policy, auth_type = config.retry_policy, config.auth_type if not credentials: credentials = config.credentials else: retry_policy, auth_type = None, None self.ad_response, self.protocol = discover( email=primary_smtp_address, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy ) self.primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: raise AttributeError('non-autodiscover requires a config') self.primary_smtp_address = primary_smtp_address self.ad_response = None self.protocol = Protocol(config=config) # We may need to override the default server version on a per-account basis because Microsoft may report one # server version up-front but delegate account requests to an older backend server. self.version = self.protocol.version log.debug('Added account: %s', self) @threaded_cached_property def admin_audit_logs(self): return self.root.get_default_folder(AdminAuditLogs) @threaded_cached_property def archive_deleted_items(self): return self.archive_root.get_default_folder(ArchiveDeletedItems) @threaded_cached_property def archive_inbox(self): return self.archive_root.get_default_folder(ArchiveInbox) @threaded_cached_property def archive_msg_folder_root(self): return self.archive_root.get_default_folder(ArchiveMsgFolderRoot) @threaded_cached_property def archive_recoverable_items_deletions(self): return self.archive_root.get_default_folder(ArchiveRecoverableItemsDeletions) @threaded_cached_property def archive_recoverable_items_purges(self): return self.archive_root.get_default_folder(ArchiveRecoverableItemsPurges) @threaded_cached_property def archive_recoverable_items_root(self): return self.archive_root.get_default_folder(ArchiveRecoverableItemsRoot) @threaded_cached_property def archive_recoverable_items_versions(self): return self.archive_root.get_default_folder(ArchiveRecoverableItemsVersions) @threaded_cached_property def archive_root(self): return ArchiveRoot.get_distinguished(account=self) @threaded_cached_property def calendar(self): # If the account contains a shared calendar from a different user, that calendar will be in the folder list. # Attempt not to return one of those. An account may not always have a calendar called "Calendar", but a # Calendar folder with a localized name instead. Return that, if it's available, but always prefer any # distinguished folder returned by the server. return self.root.get_default_folder(Calendar) @threaded_cached_property def conflicts(self): return self.root.get_default_folder(Conflicts) @threaded_cached_property def contacts(self): return self.root.get_default_folder(Contacts) @threaded_cached_property def conversation_history(self): return self.root.get_default_folder(ConversationHistory) @threaded_cached_property def directory(self): return self.root.get_default_folder(Directory) @threaded_cached_property def drafts(self): return self.root.get_default_folder(Drafts) @threaded_cached_property def favorites(self): return self.root.get_default_folder(Favorites) @threaded_cached_property def im_contact_list(self): return self.root.get_default_folder(IMContactList) @threaded_cached_property def inbox(self): return self.root.get_default_folder(Inbox) @threaded_cached_property def journal(self): return self.root.get_default_folder(Journal) @threaded_cached_property def junk(self): return self.root.get_default_folder(JunkEmail) @threaded_cached_property def local_failures(self): return self.root.get_default_folder(LocalFailures) @threaded_cached_property def msg_folder_root(self): return self.root.get_default_folder(MsgFolderRoot) @threaded_cached_property def my_contacts(self): return self.root.get_default_folder(MyContacts) @threaded_cached_property def notes(self): return self.root.get_default_folder(Notes) @threaded_cached_property def outbox(self): return self.root.get_default_folder(Outbox) @threaded_cached_property def people_connect(self): return self.root.get_default_folder(PeopleConnect) @threaded_cached_property def public_folders_root(self): return PublicFoldersRoot.get_distinguished(account=self) @threaded_cached_property def quick_contacts(self): return self.root.get_default_folder(QuickContacts) @threaded_cached_property def recipient_cache(self): return self.root.get_default_folder(RecipientCache) @threaded_cached_property def recoverable_items_deletions(self): return self.root.get_default_folder(RecoverableItemsDeletions) @threaded_cached_property def recoverable_items_purges(self): return self.root.get_default_folder(RecoverableItemsPurges) @threaded_cached_property def recoverable_items_root(self): return self.root.get_default_folder(RecoverableItemsRoot) @threaded_cached_property def recoverable_items_versions(self): return self.root.get_default_folder(RecoverableItemsVersions) @threaded_cached_property def root(self): return Root.get_distinguished(account=self) @threaded_cached_property def search_folders(self): return self.root.get_default_folder(SearchFolders) @threaded_cached_property def sent(self): return self.root.get_default_folder(SentItems) @threaded_cached_property def server_failures(self): return self.root.get_default_folder(ServerFailures) @threaded_cached_property def sync_issues(self): return self.root.get_default_folder(SyncIssues) @threaded_cached_property def tasks(self): return self.root.get_default_folder(Tasks) @threaded_cached_property def todo_search(self): return self.root.get_default_folder(ToDoSearch) @threaded_cached_property def trash(self): return self.root.get_default_folder(DeletedItems) @threaded_cached_property def voice_mail(self): return self.root.get_default_folder(VoiceMail) @property def domain(self): return get_domain(self.primary_smtp_address) @property def oof_settings(self): # We don't want to cache this property because then we can't easily get updates. 'threaded_cached_property' # supports the 'del self.oof_settings' syntax to invalidate the cache, but does not support custom setter # methods. Having a non-cached service call here goes against the assumption that properties are cheap, but the # alternative is to create get_oof_settings() and set_oof_settings(), and that's just too Java-ish for my taste. return GetUserOofSettings(account=self).call( mailbox=Mailbox(email_address=self.primary_smtp_address), ) @oof_settings.setter def oof_settings(self, value): if not isinstance(value, OofSettings): raise ValueError("'value' %r must be an OofSettings instance" % value) SetUserOofSettings(account=self).call( mailbox=Mailbox(email_address=self.primary_smtp_address), oof_settings=value, ) def _consume_item_service(self, service_cls, items, chunk_size, kwargs): # 'items' could be an unevaluated QuerySet, e.g. if we ended up here via `some_folder.filter(...).delete()`. In # that case, we want to use its iterator. Otherwise, peek() will start a count() which is wasteful because we # need the item IDs immediately afterwards. iterator() will only do the bare minimum. if isinstance(items, QuerySet): items = items.iterator() is_empty, items = peek(items) if is_empty: # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow # empty 'ids' and return early. return kwargs['items'] = items for i in service_cls(account=self, chunk_size=chunk_size).call(**kwargs): yield i def export(self, items, chunk_size=None): """Return export strings of the given items :param items: An iterable containing the Items we want to export :param chunk_size: The number of items to send to the server in a single request :return A list of strings, the exported representation of the object """ return list( self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs=dict()) ) def upload(self, data, chunk_size=None): """Adds objects retrieved from export into the given folders :param data: An iterable of tuples containing the folder we want to upload the data to and the string outputs of exports. :param chunk_size: The number of items to send to the server in a single request :return A list of tuples with the new ids and changekeys Example: account.upload([(account.inbox, "AABBCC..."), (account.inbox, "XXYYZZ..."), (account.calendar, "ABCXYZ...")]) -> [("idA", "changekey"), ("idB", "changekey"), ("idC", "changekey")] """ is_empty, data = peek(data) if is_empty: # We accept generators, so it's not always convenient for caller to know up-front if 'upload_data' is empty. # Allow empty 'upload_data' and return early. return [] return list(UploadItems(account=self, chunk_size=chunk_size).call(data=data)) def bulk_create(self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, chunk_size=None): """Creates new items in 'folder' :param folder: the folder to create the items in :param items: an iterable of Item objects :param message_disposition: only applicable to Message items. Possible values are specified in MESSAGE_DISPOSITION_CHOICES :param send_meeting_invitations: only applicable to CalendarItem items. Possible values are specified in SEND_MEETING_INVITATIONS_CHOICES :param chunk_size: The number of items to send to the server in a single request :return: a list of either BulkCreateResult or exception instances in the same order as the input. The returned BulkCreateResult objects are normal Item objects except they only contain the 'id' and 'changekey' of the created item, and the 'id' of any attachments that were also created. """ if message_disposition not in MESSAGE_DISPOSITION_CHOICES: raise ValueError("'message_disposition' %s must be one of %s" % ( message_disposition, MESSAGE_DISPOSITION_CHOICES )) if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES: raise ValueError("'send_meeting_invitations' %s must be one of %s" % ( send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES )) if folder is not None: if not isinstance(folder, BaseFolder): raise ValueError("'folder' %r must be a Folder instance" % folder) if folder.account != self: raise ValueError('"Folder must belong to this account') if message_disposition == SAVE_ONLY and folder is None: raise AttributeError("Folder must be supplied when in save-only mode") if message_disposition == SEND_AND_SAVE_COPY and folder is None: folder = self.sent # 'Sent' is default EWS behaviour if message_disposition == SEND_ONLY and folder is not None: raise AttributeError("Folder must be None in send-ony mode") if isinstance(items, QuerySet): # bulk_create() on a queryset does not make sense because it returns items that have already been created raise ValueError('Cannot bulk create items from a QuerySet') log.debug( 'Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)', self, folder, message_disposition, send_meeting_invitations, ) return list( i if isinstance(i, Exception) else BulkCreateResult.from_xml(elem=i, account=self) for i in self._consume_item_service(service_cls=CreateItem, items=items, chunk_size=chunk_size, kwargs=dict( folder=folder, message_disposition=message_disposition, send_meeting_invitations=send_meeting_invitations, )) ) def bulk_update(self, items, conflict_resolution=AUTO_RESOLVE, message_disposition=SAVE_ONLY, send_meeting_invitations_or_cancellations=SEND_TO_NONE, suppress_read_receipts=True, chunk_size=None): """ Bulk updates existing items :param items: a list of (Item, fieldnames) tuples, where 'Item' is an Item object, and 'fieldnames' is a list containing the attributes on this Item object that we want to be updated. :param conflict_resolution: Possible values are specified in CONFLICT_RESOLUTION_CHOICES :param message_disposition: only applicable to Message items. Possible values are specified in MESSAGE_DISPOSITION_CHOICES :param send_meeting_invitations_or_cancellations: only applicable to CalendarItem items. Possible values are specified in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES :param suppress_read_receipts: nly supported from Exchange 2013. True or False :param chunk_size: The number of items to send to the server in a single request :return: a list of either (id, changekey) tuples or exception instances, in the same order as the input """ if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES: raise ValueError("'conflict_resolution' %s must be one of %s" % ( conflict_resolution, CONFLICT_RESOLUTION_CHOICES )) if message_disposition not in MESSAGE_DISPOSITION_CHOICES: raise ValueError("'message_disposition' %s must be one of %s" % ( message_disposition, MESSAGE_DISPOSITION_CHOICES )) if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES: raise ValueError("'send_meeting_invitations_or_cancellations' %s must be one of %s" % ( send_meeting_invitations_or_cancellations, SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES )) if suppress_read_receipts not in (True, False): raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts) if message_disposition == SEND_ONLY: raise ValueError('Cannot send-only existing objects. Use SendItem service instead') # bulk_update() on a queryset does not make sense because there would be no opportunity to alter the items. In # fact, it could be dangerous if the queryset contains an '.only()'. This would wipe out certain fields # entirely. if isinstance(items, QuerySet): raise ValueError('Cannot bulk update on a queryset') log.debug( 'Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)', self, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, ) return list( i if isinstance(i, Exception) else Item.id_from_xml(i) for i in self._consume_item_service(service_cls=UpdateItem, items=items, chunk_size=chunk_size, kwargs=dict( conflict_resolution=conflict_resolution, message_disposition=message_disposition, send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, suppress_read_receipts=suppress_read_receipts, )) ) def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, suppress_read_receipts=True, chunk_size=None): """ Bulk deletes items. :param ids: an iterable of either (id, changekey) tuples or Item objects. :param delete_type: the type of delete to perform. Possible values are specified in DELETE_TYPE_CHOICES :param send_meeting_cancellations: only applicable to CalendarItem. Possible values are specified in SEND_MEETING_CANCELLATIONS_CHOICES. :param affected_task_occurrences: only applicable for recurring Task items. Possible values are specified in AFFECTED_TASK_OCCURRENCES_CHOICES. :param suppress_read_receipts: only supported from Exchange 2013. True or False. :param chunk_size: The number of items to send to the server in a single request :return: a list of either True or exception instances, in the same order as the input """ if delete_type not in DELETE_TYPE_CHOICES: raise ValueError("'delete_type' %s must be one of %s" % ( delete_type, DELETE_TYPE_CHOICES )) if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: raise ValueError("'send_meeting_cancellations' %s must be one of %s" % ( send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES )) if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: raise ValueError("'affected_task_occurrences' %s must be one of %s" % ( affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES )) if suppress_read_receipts not in (True, False): raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts) log.debug( 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurences: %s)', self, delete_type, send_meeting_cancellations, affected_task_occurrences, ) return list( self._consume_item_service(service_cls=DeleteItem, items=ids, chunk_size=chunk_size, kwargs=dict( delete_type=delete_type, send_meeting_cancellations=send_meeting_cancellations, affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts, )) ) def bulk_send(self, ids, save_copy=True, copy_to_folder=None, chunk_size=None): """ Send existing draft messages. If requested, save a copy in 'copy_to_folder' :param ids: an iterable of either (id, changekey) tuples or Item objects. :param save_copy: If true, saves a copy of the message :param copy_to_folder: If requested, save a copy of the message in this folder. Default is the Sent folder :param chunk_size: The number of items to send to the server in a single request :return: Status for each send operation, in the same order as the input """ if copy_to_folder and not save_copy: raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set") if save_copy and not copy_to_folder: copy_to_folder = self.sent # 'Sent' is default EWS behaviour if copy_to_folder and not isinstance(copy_to_folder, BaseFolder): raise ValueError("'copy_to_folder' %r must be a Folder instance" % copy_to_folder) return list( self._consume_item_service(service_cls=SendItem, items=ids, chunk_size=chunk_size, kwargs=dict( saved_item_folder=copy_to_folder, )) ) def bulk_copy(self, ids, to_folder, chunk_size=None): """ Copy items to another folder :param ids: an iterable of either (id, changekey) tuples or Item objects. :param to_folder: The destination folder of the copy operation :param chunk_size: The number of items to send to the server in a single request :return: Status for each send operation, in the same order as the input """ if not isinstance(to_folder, BaseFolder): raise ValueError("'to_folder' %r must be a Folder instance" % to_folder) return list( i if isinstance(i, Exception) else Item.id_from_xml(i) for i in self._consume_item_service(service_cls=CopyItem, items=ids, chunk_size=chunk_size, kwargs=dict( to_folder=to_folder, )) ) def bulk_move(self, ids, to_folder, chunk_size=None): """Move items to another folder :param ids: an iterable of either (id, changekey) tuples or Item objects. :param to_folder: The destination folder of the copy operation :param chunk_size: The number of items to send to the server in a single request :return: The new IDs of the moved items, in the same order as the input. If 'to_folder' is a public folder or a folder in a different mailbox, an empty list is returned. """ if not isinstance(to_folder, BaseFolder): raise ValueError("'to_folder' %r must be a Folder instance" % to_folder) return list( i if isinstance(i, Exception) else Item.id_from_xml(i) for i in self._consume_item_service(service_cls=MoveItem, items=ids, chunk_size=chunk_size, kwargs=dict( to_folder=to_folder, )) ) def bulk_archive(self, ids, to_folder, chunk_size=None): """Archive items to a folder in the archive mailbox. An archive mailbox must be enabled in order for this to work. :param ids: an iterable of either (id, changekey) tuples or Item objects. :param to_folder: The destination folder of the archive operation :param chunk_size: The number of items to send to the server in a single request :return: A list containing True or an exception instance in stable order of the requested items """ if not isinstance(to_folder, (BaseFolder, FolderId, DistinguishedFolderId)): raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder) return list(self._consume_item_service(service_cls=ArchiveItem, items=ids, chunk_size=chunk_size, kwargs=dict( to_folder=to_folder, )) ) def fetch(self, ids, folder=None, only_fields=None, chunk_size=None): """ Fetch items by ID :param ids: an iterable of either (id, changekey) tuples or Item objects. :param folder: used for validating 'only_fields' :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :param chunk_size: The number of items to send to the server in a single request :return: A generator of Item objects, in the same order as the input """ validation_folder = folder or Folder(root=self.root) # Default to a folder type that supports all item types # 'ids' could be an unevaluated QuerySet, e.g. if we ended up here via `fetch(ids=some_folder.filter(...))`. In # that case, we want to use its iterator. Otherwise, peek() will start a count() which is wasteful because we # need the item IDs immediately afterwards. iterator() will only do the bare minimum. if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. additional_fields = { FieldPath(field=f) for f in validation_folder.allowed_item_fields(version=self.version) } else: for field in only_fields: validation_folder.validate_item_field(field=field, version=self.version) additional_fields = validation_folder.normalize_fields(fields=only_fields) # Always use IdOnly here, because AllProperties doesn't actually get *all* properties for i in self._consume_item_service(service_cls=GetItem, items=ids, chunk_size=chunk_size, kwargs=dict( additional_fields=additional_fields, shape=ID_ONLY, )): if isinstance(i, Exception): yield i else: item = validation_folder.item_model_from_tag(i.tag).from_xml(elem=i, account=self) yield item @property def mail_tips(self): """See self.oof_settings about caching considerations """ # mail_tips_requested must be one of properties.MAIL_TIPS_TYPES res = list(GetMailTips(protocol=self.protocol).call( sending_as=SendingAs(email_address=self.primary_smtp_address), recipients=[Mailbox(email_address=self.primary_smtp_address)], mail_tips_requested='All', )) if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] return res[0] @property def delegates(self): """Returns 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)) def __str__(self): txt = '%s' % self.primary_smtp_address if self.fullname: txt += ' (%s)' % self.fullname return txt exchangelib-3.1.1/exchangelib/attachments.py000066400000000000000000000257251361226005600211370ustar00rootroot00000000000000from io import BytesIO import logging import mimetypes from .fields import BooleanField, TextField, IntegerField, URIField, DateTimeField, EWSElementField, Base64Field, \ ItemField, IdField from .properties import RootItemId, EWSElement from .services import GetAttachment, CreateAttachment, DeleteAttachment log = logging.getLogger(__name__) class AttachmentId(EWSElement): """'id' and 'changekey' are UUIDs generated by Exchange MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attachmentid """ ELEMENT_NAME = 'AttachmentId' ID_ATTR = 'Id' ROOT_ID_ATTR = 'RootItemId' ROOT_CHANGEKEY_ATTR = 'RootItemChangeKey' FIELDS = [ IdField('id', field_uri=ID_ATTR, is_required=True), IdField('root_id', field_uri=ROOT_ID_ATTR), IdField('root_changekey', field_uri=ROOT_CHANGEKEY_ATTR), ] __slots__ = tuple(f.name for f in FIELDS) class Attachment(EWSElement): """Base class for FileAttachment and ItemAttachment """ FIELDS = [ EWSElementField('attachment_id', value_cls=AttachmentId), TextField('name', field_uri='Name'), TextField('content_type', field_uri='ContentType'), TextField('content_id', field_uri='ContentId'), URIField('content_location', field_uri='ContentLocation'), IntegerField('size', field_uri='Size', is_read_only=True), # Attachment size in bytes DateTimeField('last_modified_time', field_uri='LastModifiedTime'), BooleanField('is_inline', field_uri='IsInline'), ] __slots__ = tuple(f.name for f in FIELDS) + ('parent_item',) def __init__(self, **kwargs): self.parent_item = kwargs.pop('parent_item', None) super().__init__(**kwargs) def clean(self, version=None): from .items import Item if self.parent_item is not None and not isinstance(self.parent_item, Item): raise ValueError("'parent_item' value %r must be an Item instance" % self.parent_item) # pylint: disable=access-member-before-definition if self.content_type is None and self.name is not None: self.content_type = mimetypes.guess_type(self.name)[0] or 'application/octet-stream' super().clean(version=version) def attach(self): # Adds this attachment to an item and updates the changekey of the parent item if self.attachment_id: raise ValueError('This attachment has already been created') if not self.parent_item or not self.parent_item.account: raise ValueError('Parent item %s must have an account' % self.parent_item) items = list( i if isinstance(i, Exception) else self.from_xml(elem=i, account=self.parent_item.account) for i in CreateAttachment(account=self.parent_item.account).call(parent_item=self.parent_item, items=[self]) ) if len(items) != 1: raise ValueError('Expected single item, got %s' % items) root_item_id = items[0] if isinstance(root_item_id, Exception): raise root_item_id attachment_id = root_item_id.attachment_id if attachment_id.root_id != self.parent_item.id: raise ValueError("'root_id' vs. 'id' mismatch") if attachment_id.root_changekey == self.parent_item.changekey: raise ValueError('root_id changekey match') self.parent_item.changekey = attachment_id.root_changekey # EWS does not like receiving root_id and root_changekey on subsequent requests attachment_id.root_id = None attachment_id.root_changekey = None self.attachment_id = attachment_id def detach(self): # Deletes an attachment remotely and updates the changekey of the parent item if not self.attachment_id: raise ValueError('This attachment has not been created') if not self.parent_item or not self.parent_item.account: raise ValueError('Parent item %s must have an account' % self.parent_item) items = list( i if isinstance(i, Exception) else RootItemId.from_xml(elem=i, account=self.parent_item.account) for i in DeleteAttachment(account=self.parent_item.account).call(items=[self.attachment_id]) ) if len(items) != 1: raise ValueError('Expected single item, got %s' % items) root_item_id = items[0] if isinstance(root_item_id, Exception): raise root_item_id if root_item_id.id != self.parent_item.id: raise ValueError("'root_item_id.id' mismatch") if root_item_id.changekey == self.parent_item.changekey: raise ValueError("'root_item_id.changekey' match") self.parent_item.changekey = root_item_id.changekey self.parent_item = None self.attachment_id = None def __hash__(self): if self.attachment_id: return hash(self.attachment_id) # Be careful to avoid recursion on the back-reference to the parent item return hash(tuple(getattr(self, f) for f in self._slots_keys() if f != 'parent_item')) def __repr__(self): return self.__class__.__name__ + '(%s)' % ', '.join( '%s=%r' % (f.name, getattr(self, f.name)) for f in self.FIELDS if f.name not in ('_item', '_content') ) class FileAttachment(Attachment): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fileattachment """ ELEMENT_NAME = 'FileAttachment' FIELDS = Attachment.FIELDS + [ BooleanField('is_contact_photo', field_uri='IsContactPhoto'), Base64Field('_content', field_uri='Content'), ] __slots__ = ('is_contact_photo', '_content', '_fp') def __init__(self, **kwargs): kwargs['_content'] = kwargs.pop('content', None) super().__init__(**kwargs) self._fp = None @property def fp(self): # Return a file-like object for the content. This avoids creating multiple in-memory copies of the content. if self._fp is None: self._init_fp() return self._fp def _init_fp(self): # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: raise ValueError('%s must have an account' % self.__class__.__name__) self._fp = FileAttachmentIO(attachment=self) @property def content(self): # Returns the attachment content. Stores a local copy of the content in case you want to upload the attachment # again later. if self.attachment_id is None: return self._content if self._content is not None: return self._content # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now. with self.fp as fp: self._content = fp.read() return self._content @content.setter def content(self, value): # Replaces the attachment content if not isinstance(value, bytes): raise ValueError("'value' %r must be a bytes object" % value) self._content = value @classmethod def from_xml(cls, elem, account): kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} kwargs['content'] = kwargs.pop('_content') cls._clear(elem) return cls(**kwargs) def to_xml(self, version): self._content = self.content # Make sure content is available, to avoid ErrorRequiredPropertyMissing return super().to_xml(version=version) def __getstate__(self): # The fp does not need to be pickled state = {k: getattr(self, k) for k in self._slots_keys()} del state['_fp'] return state def __setstate__(self, state): # Restore the fp for k in self._slots_keys(): setattr(self, k, state.get(k)) self._fp = None class ItemAttachment(Attachment): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/itemattachment """ ELEMENT_NAME = 'ItemAttachment' # noinspection PyTypeChecker FIELDS = Attachment.FIELDS + [ ItemField('_item', field_uri='Item'), ] __slots__ = ('_item',) def __init__(self, **kwargs): kwargs['_item'] = kwargs.pop('item', None) super().__init__(**kwargs) @property def item(self): if self.attachment_id is None: return self._item if self._item is not None: return self._item # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now. if not self.parent_item or not self.parent_item.account: raise ValueError('%s must have an account' % self.__class__.__name__) items = list( i if isinstance(i, Exception) else self.__class__.from_xml(elem=i, account=self.parent_item.account) for i in GetAttachment(account=self.parent_item.account).call( items=[self.attachment_id], include_mime_content=True) ) if len(items) != 1: raise ValueError('Expected single item, got %s' % items) attachment = items[0] if isinstance(attachment, Exception): raise attachment if attachment.item is None: raise ValueError('GetAttachment returned no item') self._item = attachment.item return self._item @item.setter def item(self, value): from .items import Item if not isinstance(value, Item): raise ValueError("'value' %r must be an Item object" % value) self._item = value @classmethod def from_xml(cls, elem, account): kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} kwargs['item'] = kwargs.pop('_item') cls._clear(elem) return cls(**kwargs) class FileAttachmentIO(BytesIO): def __init__(self, *args, **kwargs): self._attachment = kwargs.pop('attachment') super().__init__(*args, **kwargs) def __enter__(self): self._stream = GetAttachment(account=self._attachment.parent_item.account).stream_file_content( attachment_id=self._attachment.attachment_id ) self._overflow = b'' return self def __exit__(self, *args, **kwargs): self._stream = None self._overflow = None def read(self, size=-1): if size < 0: # Return everything return b''.join(self._stream) # Return only 'size' bytes buffer = [self._overflow] read_size = len(self._overflow) while True: if read_size >= size: break try: next_chunk = next(self._stream) except StopIteration: break buffer.append(next_chunk) read_size += len(next_chunk) res = b''.join(buffer) self._overflow = res[size:] return res[:size] exchangelib-3.1.1/exchangelib/autodiscover/000077500000000000000000000000001361226005600207465ustar00rootroot00000000000000exchangelib-3.1.1/exchangelib/autodiscover/__init__.py000066400000000000000000000007361361226005600230650ustar00rootroot00000000000000from .cache import AutodiscoverCache, autodiscover_cache from .discovery import Autodiscovery, discover from .protocol import AutodiscoverProtocol def close_connections(): with autodiscover_cache: autodiscover_cache.close() def clear_cache(): with autodiscover_cache: autodiscover_cache.clear() __all__ = [ 'AutodiscoverCache', 'AutodiscoverProtocol', 'Autodiscovery', 'discover', 'autodiscover_cache', 'close_connections', 'clear_cache' ] exchangelib-3.1.1/exchangelib/autodiscover/cache.py000066400000000000000000000133621361226005600223700ustar00rootroot00000000000000from contextlib import contextmanager import getpass import glob import logging import os import shelve import sys import tempfile from threading import RLock from ..configuration import Configuration from .protocol import AutodiscoverProtocol log = logging.getLogger(__name__) def shelve_filename(): # Add the version of the cache format to the filename. If we change the format of the cached data, this version # must be bumped. Otherwise, new versions of this package cannot open cache files generated by older versions. version = 2 # 'shelve' may pickle objects using different pickle protocol versions. Append the python major+minor version # numbers to the filename. Also append the username, to avoid permission errors. major, minor = sys.version_info[:2] try: user = getpass.getuser() except KeyError: # getuser() fails on some systems. Provide a sane default. See issue #448 user = 'exchangelib' return 'exchangelib.{version}.cache.{user}.py{major}{minor}'.format( version=version, user=user, major=major, minor=minor ) AUTODISCOVER_PERSISTENT_STORAGE = os.path.join(tempfile.gettempdir(), shelve_filename()) @contextmanager def shelve_open_with_failover(filename): # We can expect empty or corrupt files. Whatever happens, just delete the cache file and try again. # 'shelve' may add a backend-specific suffix to the file, so also delete all files with a suffix. # We don't know which file caused the error, so just delete them all. try: shelve_handle = shelve.open(filename) except Exception as e: for f in glob.glob(filename + '*'): log.warning('Deleting invalid cache file %s (%r)', f, e) os.unlink(f) shelve_handle = shelve.open(filename) yield shelve_handle class AutodiscoverCache: """Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object so we can re-use TCP connections to an autodiscover server within the same process. Also persists the email domain -> (autodiscover endpoint URL, auth_type) translation to the filesystem so the cache can be shared between multiple processes. According to Microsoft, we may forever cache the (email domain -> autodiscover endpoint URL) mapping, or until it stops responding. My previous experience with Exchange products in mind, I'm not sure if I should trust that advice. But it could save some valuable seconds every time we start a new connection to a known server. In any case, the persistent storage must not contain any sensitive information since the cache could be readable by unprivileged users. Domain, endpoint and auth_type are OK to cache since this info is make publicly available on HTTP and DNS servers via the autodiscover protocol. Just don't persist any credentials info. If an autodiscover lookup fails for any reason, the corresponding cache entry must be purged. 'shelve' is supposedly thread-safe and process-safe, which suits our needs. """ def __init__(self): self._protocols = {} # Mapping from (domain, credentials) to AutodiscoverProtocol self._lock = RLock() @property def _storage_file(self): return AUTODISCOVER_PERSISTENT_STORAGE def clear(self): # Wipe the entire cache with shelve_open_with_failover(self._storage_file) as db: db.clear() self._protocols.clear() def __len__(self): return len(self._protocols) def __contains__(self, key): domain = key[0] with shelve_open_with_failover(self._storage_file) as db: return str(domain) in db def __getitem__(self, key): protocol = self._protocols.get(key) if protocol: return protocol domain, credentials = key with shelve_open_with_failover(self._storage_file) as db: endpoint, auth_type, retry_policy = db[str(domain)] # It's OK to fail with KeyError here protocol = AutodiscoverProtocol(config=Configuration( service_endpoint=endpoint, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy )) self._protocols[key] = protocol return protocol def __setitem__(self, key, protocol): # Populate both local and persistent cache domain = key[0] with shelve_open_with_failover(self._storage_file) as db: # Don't change this payload without bumping the cache file version in shelve_filename() db[str(domain)] = (protocol.service_endpoint, protocol.auth_type, protocol.retry_policy) self._protocols[key] = protocol def __delitem__(self, key): # Empty both local and persistent cache. Don't fail on non-existing entries because we could end here # multiple times due to race conditions. domain = key[0] with shelve_open_with_failover(self._storage_file) as db: try: del db[str(domain)] except KeyError: pass try: del self._protocols[key] except KeyError: pass def close(self): # Close all open connections for (domain, _), protocol in self._protocols.items(): log.debug('Domain %s: Closing sessions', domain) protocol.close() del protocol self._protocols.clear() def __enter__(self): self._lock.__enter__() def __exit__(self, *args, **kwargs): self._lock.__exit__(*args, **kwargs) def __del__(self): # pylint: disable=bare-except try: self.close() except Exception: # nosec # __del__ should never fail pass def __str__(self): return str(self._protocols) autodiscover_cache = AutodiscoverCache() exchangelib-3.1.1/exchangelib/autodiscover/discovery.py000066400000000000000000000627651361226005600233470ustar00rootroot00000000000000from collections import namedtuple import logging import time from urllib.parse import urlparse import dns.resolver from ..configuration import Configuration from ..errors import AutoDiscoverFailed, AutoDiscoverCircularRedirect, TransportError, RedirectError, UnauthorizedError from ..protocol import Protocol, FailFast from ..transport import get_auth_method_from_response, DEFAULT_HEADERS, NOAUTH from ..util import post_ratelimited, get_domain, get_redirect_url, _back_off_if_needed, _may_retry_on_error, \ is_valid_hostname, DummyResponse, CONNECTION_ERRORS, TLS_ERRORS from ..version import Version from .cache import autodiscover_cache from .properties import Autodiscover from .protocol import AutodiscoverProtocol log = logging.getLogger(__name__) def discover(email, credentials=None, auth_type=None, retry_policy=None): return Autodiscovery( email=email, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy ).discover() SrvRecord = namedtuple('SrvRecord', ('priority', 'weight', 'port', 'srv')) class Autodiscovery: """Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other connection-related settings holding the email address using only the email address, and username and password of the user. For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers": https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29 Descriptions of the steps from the article are provided in their respective methods in this class. For a description of how to handle autodiscover error messages, see: https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages A tip from the article: The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to set up the Autodiscover service, the client might try this step first. Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover": https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this implementation, start by doing an official test at https://testconnectivity.microsoft.com """ # When connecting to servers that may not be serving the correct endpoint, we should use a retry policy that does # not leave us hanging for a long time on each step in the protocol. INITIAL_RETRY_POLICY = FailFast() RETRY_WAIT = 10 # Seconds to wait before retry on connection errors MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up def __init__(self, email, credentials=None, auth_type=None, retry_policy=None): """ :param email: The email address to autodiscover :param credentials: Credentials with authorization to make autodiscover lookups for this Account """ self.email = email self.credentials = credentials self.auth_type = auth_type # The auth type that the resulting protocol instance should have self.retry_policy = retry_policy # The retry policy that the resulting protocol instance should have self._urls_visited = [] # Collects HTTP and Autodiscover redirects self._redirect_count = 0 self._emails_visited = [] # Collects Autodiscover email redirects def discover(self): self._emails_visited.append(self.email) # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email # domain. Use a lock to guard against multiple threads competing to cache information. log.debug('Waiting for autodiscover_cache lock') with autodiscover_cache: log.debug('autodiscover_cache lock acquired') cache_key = self._cache_key domain = get_domain(self.email) if cache_key in autodiscover_cache: ad_protocol = autodiscover_cache[cache_key] log.debug('Cache hit for key %s: %s', cache_key, ad_protocol.service_endpoint) try: ad_response = self._quick(protocol=ad_protocol) except AutoDiscoverFailed: # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock log.debug('AD request failure. Removing cache for key %s', cache_key) del autodiscover_cache[cache_key] ad_response = self._step_1(hostname=domain) else: # This will cache the result ad_response = self._step_1(hostname=domain) log.debug('Released autodiscover_cache_lock') if ad_response.redirect_address: log.debug('Got a redirect address: %s', ad_response.redirect_address) if ad_response.redirect_address.lower() in self._emails_visited: raise AutoDiscoverCircularRedirect('We were redirected to an email address we have already seen') # Start over, but with the new email address self.email = ad_response.redirect_address return self.discover() # We successfully received a response. Clear the cache of seen emails etc. self.clear() return self._build_response(ad_response=ad_response) def clear(self): # This resets cached variables self._urls_visited = [] self._redirect_count = 0 self._emails_visited = [] @property def _cache_key(self): # We may be using multiple different credentials and changing our minds on TLS verification. This key # combination should be safe for caching. domain = get_domain(self.email) return domain, self.credentials def _build_response(self, ad_response): ews_url = ad_response.protocol.ews_url if not ews_url: raise AutoDiscoverFailed("Response is missing an 'ews_url' value") if not ad_response.autodiscover_smtp_address: # Autodiscover does not always return an email address. In that case, the requesting email should be used ad_response.user.autodiscover_smtp_address = self.email # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the # other ones that point to the same endpoint. for protocol in ad_response.account.protocols: if protocol.ews_url.lower() == ews_url.lower() and protocol.server_version: version = Version(build=protocol.server_version) break else: version = None # We may not want to use the auth_package hints in the AD response. It could be incorrect and we can just guess. protocol = Protocol( config=Configuration( service_endpoint=ews_url, credentials=self.credentials, version=version, auth_type=self.auth_type, retry_policy=self.retry_policy, ) ) return ad_response, protocol def _quick(self, protocol): # Reset auth type and retry policy if we requested non-default values if self.auth_type: protocol.config.auth_type = self.auth_type if self.retry_policy: protocol.config.retry_policy = self.retry_policy try: r = self._get_authenticated_response(protocol=protocol) except TransportError as e: raise AutoDiscoverFailed('Response error: %s' % e) if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) return self._step_5(ad=ad) except ValueError as e: raise AutoDiscoverFailed('Invalid response: %s' % e) raise AutoDiscoverFailed('Invalid response code: %s' % r.status_code) def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: * An HTTP status code (301, 302) with a new URL * An HTTP status code of 200, but with a payload XML containing a redirect to a different URL * An HTTP status code of 200, but with a payload XML containing a different SMTP address as the target address We only handle the HTTP 302 redirects here. We validate the URL received in the redirect response to ensure that it does not redirect to non-SSL endpoints or SSL endpoints with invalid certificates, and that the redirect is not circular. Finally, we should fail after 10 redirects. """ if url.lower() in self._urls_visited: log.warning('We have already tried this URL: %s', url) return False if self._redirect_count >= self.MAX_REDIRECTS: log.warning('We reached max redirects at URL: %s', url) return False # We require TLS endpoints if not url.startswith('https://'): log.debug('Invalid scheme for URL: %s', url) return False # Quick test that the endpoint responds and that TLS handshake is OK try: self._get_unauthenticated_response(url, method='head') except TransportError as e: log.debug('Response error on redirect URL %s: %s', url, e) return False self._redirect_count += 1 return True def _get_unauthenticated_response(self, url, method='post'): """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error prone, and some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint. """ # We are connecting to untrusted servers here, so take necessary precautions. hostname = urlparse(url).netloc if not is_valid_hostname(hostname, timeout=AutodiscoverProtocol.TIMEOUT): # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. # Don't retry on DNS errors. They will most likely be persistent. raise TransportError('%r has no DNS entry' % hostname) kwargs = dict( url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT ) if method == 'post': kwargs['data'] = Autodiscover.payload(email=self.email) retry = 0 t_start = time.monotonic() while True: _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until) log.debug('Trying to get response from %s', url) with AutodiscoverProtocol.raw_session() as s: try: r = getattr(s, method)(**kwargs) break except TLS_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) except CONNECTION_ERRORS as e: r = DummyResponse(url=url, headers={}, request_headers=kwargs['headers']) total_wait = time.monotonic() - t_start if _may_retry_on_error(response=r, retry_policy=self.INITIAL_RETRY_POLICY, wait=total_wait): log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) self.INITIAL_RETRY_POLICY.back_off(self.RETRY_WAIT) retry += 1 continue else: log.debug("Connection error on URL %s: %s", url, e) raise TransportError(str(e)) try: auth_type = get_auth_method_from_response(response=r) except UnauthorizedError: # Failed to guess the auth type auth_type = NOAUTH if r.status_code in (301, 302): if 'location' in r.headers: # Make the redirect URL absolute try: r.headers['location'] = get_redirect_url(r) except TransportError: del r.headers['location'] return auth_type, r def _get_authenticated_response(self, protocol): """Get a response by using the credentials provided. We guess the auth type along the way. """ # Redo the request with the correct auth data = Autodiscover.payload(email=self.email) # TODO: If Kerberos auth is set, we should set the X-ClientCanHandle='Negotiate' header. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange headers = DEFAULT_HEADERS.copy() try: session = protocol.get_session() r, session = post_ratelimited(protocol=protocol, session=session, url=protocol.service_endpoint, headers=headers, data=data, allow_redirects=False) protocol.release_session(session) except UnauthorizedError as e: # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this # isn't necessarily the right endpoint to use. raise TransportError(str(e)) except RedirectError as e: r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, request_headers=None, status_code=302) return r def _attempt_response(self, url): """Returns a (is_valid_response, response) tuple """ self._urls_visited.append(url.lower()) log.debug('Attempting to get a valid response from %s', url) try: auth_type, r = self._get_unauthenticated_response(url=url) ad_protocol = AutodiscoverProtocol( config=Configuration( service_endpoint=url, credentials=self.credentials, auth_type=auth_type, retry_policy=self.INITIAL_RETRY_POLICY, ) ) if auth_type != NOAUTH: r = self._get_authenticated_response(protocol=ad_protocol) except TransportError as e: log.debug('Failed to get a response: %s', e) return False, None if r.status_code in (301, 302) and 'location' in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com # works, it seems that we should follow this URL now and try to get a valid response. return self._attempt_response(url=redirect_url) if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) # We got a valid response. Unless this is a URL redirect response, we cache the result if ad.response is None or not ad.response.redirect_url: cache_key = self._cache_key log.debug('Adding cache entry for key %s: %s', cache_key, ad_protocol.service_endpoint) autodiscover_cache[cache_key] = ad_protocol return True, ad except ValueError as e: log.debug('Invalid response: %s', e) return False, None def _step_1(self, hostname): """The client sends an Autodiscover request to https://example.com/autodiscover/autodiscover.xml and then does one of the following: * If the Autodiscover attempt succeeds, the client proceeds to step 5. * If the Autodiscover attempt fails, the client proceeds to step 2. """ url = 'https://%s/Autodiscover/Autodiscover.xml' % hostname log.info('Step 1: Trying autodiscover on %r with email %r', url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: return self._step_5(ad=ad) else: return self._step_2(hostname=hostname) def _step_2(self, hostname): """The client sends an Autodiscover request to https://autodiscover.example.com/autodiscover/autodiscover.xml and then does one of the following: * If the Autodiscover attempt succeeds, the client proceeds to step 5. * If the Autodiscover attempt fails, the client proceeds to step 3. """ url = 'https://autodiscover.%s/Autodiscover/Autodiscover.xml' % hostname log.info('Step 2: Trying autodiscover on %r with email %r', url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: return self._step_5(ad=ad) else: return self._step_3(hostname=hostname) def _step_3(self, hostname): """The client sends an unauth'ed GET method request to http://autodiscover.example.com/autodiscover/autodiscover.xml (Note that this is a non-HTTPS endpoint). The client then does one of the following: * If the GET request returns a 302 redirect response, it gets the redirection URL from the 'Location' HTTP header and validates it as described in the "Redirect responses" section. The client then does one of the following: * If the redirection URL is valid, the client tries the URL and then does one of the following: * If the attempt succeeds, the client proceeds to step 5. * If the attempt fails, the client proceeds to step 4. * If the redirection URL is not valid, the client proceeds to step 4. * If the GET request does not return a 302 redirect response, the client proceeds to step 4. """ url = 'http://autodiscover.%s/Autodiscover/Autodiscover.xml' % hostname log.info('Step 3: Trying autodiscover on %r with email %r', url, self.email) try: _, r = self._get_unauthenticated_response(url=url, method='get') except TransportError: r = DummyResponse(url=url, headers={}, request_headers={}) if r.status_code in (301, 302) and 'location' in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) else: return self._step_4(hostname=hostname) else: return self._step_4(hostname=hostname) else: return self._step_4(hostname=hostname) def _step_4(self, hostname): """The client performs a Domain Name System (DNS) query for an SRV record for _autodiscover._tcp.example.com. The query might return multiple records. The client selects only records that point to an SSL endpoint and that have the highest priority and weight. One of the following actions then occurs: * If no such records are returned, the client proceeds to step 6. * If records are returned, the application randomly chooses a record in the list and validates the endpoint that it points to by following the process described in the "Redirect Response" section. The client then does one of the following: * If the redirection URL is valid, the client tries the URL and then does one of the following: * If the attempt succeeds, the client proceeds to step 5. * If the attempt fails, the client proceeds to step 6. * If the redirection URL is not valid, the client proceeds to step 6. """ dns_hostname = '_autodiscover._tcp.%s' % hostname log.info('Step 4: Trying autodiscover on %r with email %r', dns_hostname, self.email) srv_records = _get_srv_records(dns_hostname) try: srv_host = _select_srv_host(srv_records) except ValueError: srv_host = None if not srv_host: return self._step_6() else: redirect_url = 'https://%s/Autodiscover/Autodiscover.xml' % srv_host if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) else: return self._step_6() else: return self._step_6() def _step_5(self, ad): """When a valid Autodiscover request succeeds, the following sequence occurs: * If the server responds with an HTTP 302 redirect, the client validates the redirection URL according to the process defined in the "Redirect responses" and then does one of the following: * If the redirection URL is valid, the client tries the URL and then does one of the following: * If the attempt succeeds, the client repeats step 5 from the beginning. * If the attempt fails, the client proceeds to step 6. * If the redirection URL is not valid, the client proceeds to step 6. * If the server responds with a valid Autodiscover response, the client does one of the following: * If the value of the Action element is "Redirect", the client gets the redirection email address from the Redirect element and then returns to step 1, using this new email address. * If the value of the Action element is "Settings", the client has successfully received the requested configuration settings for the specified user. The client does not need to proceed to step 6. """ log.info('Step 5: Checking response') if ad.response is None: # This is not explicit in the protocol, but let's raise errors here ad.raise_errors() ad_response = ad.response if ad_response.redirect_url: log.debug('Got a redirect URL: %s', ad_response.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already # followed the redirects where possible. Instead, we handle retirect responses here. if self._redirect_url_is_valid(url=ad_response.redirect_url): is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) if is_valid_response: return self._step_5(ad=ad) else: return self._step_6() else: log.debug('Invalid redirect URL: %s', ad_response.redirect_url) return self._step_6() else: # This could be an email redirect. Let outer layer handle this return ad_response def _step_6(self): """If the client cannot contact the Autodiscover service, the client should ask the user for the Exchange server name and use it to construct an Exchange EWS URL. The client should try to use this URL for future requests. """ raise AutoDiscoverFailed( 'All steps in the autodiscover protocol failed for email %r. If you think this is an error, consider doing ' 'an official test at https://testconnectivity.microsoft.com' % self.email) def _get_srv_records(hostname): """Send a DNS query for SRV entries for the hostname. An SRV entry that has been formatted for autodiscovery will have the following format: canonical name = mail.example.com. service = 8 100 443 webmail.example.com. The first three numbers in the service line are: priority, weight, port """ log.debug('Attempting to get SRV records for %s', hostname) resolver = dns.resolver.Resolver() resolver.timeout = AutodiscoverProtocol.TIMEOUT records = [] try: answers = resolver.query('%s.' % hostname, 'SRV') except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: log.debug('DNS lookup failure: %s', e) return records for rdata in answers: try: vals = rdata.to_text().strip().rstrip('.').split(' ') # Raise ValueError if the first three are not ints, and IndexError if there are less than 4 values priority, weight, port, srv = int(vals[0]), int(vals[1]), int(vals[2]), vals[3] record = SrvRecord(priority=priority, weight=weight, port=port, srv=srv) log.debug('Found SRV record %s ', record) records.append(record) except (ValueError, IndexError): log.debug('Incompatible SRV record for %s (%s)', hostname, rdata.to_text()) return records def _select_srv_host(srv_records): """Select the record with the highest priority, that also supports TLS """ best_record = None for srv_record in srv_records: if srv_record.port != 443: log.debug('Skipping SRV record %r (no TLS)', srv_record) continue # Assume port 443 will serve TLS. If not, autodiscover will probably also be broken for others. if best_record is None or best_record.priority < srv_record.priority: best_record = srv_record if not best_record: raise ValueError('No suitable records') return best_record.srv exchangelib-3.1.1/exchangelib/autodiscover/properties.py000066400000000000000000000362441361226005600235250ustar00rootroot00000000000000from ..errors import ErrorNonExistentMailbox, AutoDiscoverFailed from ..fields import TextField, EmailAddressField, ChoiceField, Choice, EWSElementField, OnOffField, BooleanField, \ IntegerField, BuildField, ProtocolListField from ..properties import EWSElement from ..transport import DEFAULT_ENCODING from ..util import create_element, add_xml_child, to_xml, is_xml, xml_to_str, AUTODISCOVER_REQUEST_NS, \ AUTODISCOVER_BASE_NS, AUTODISCOVER_RESPONSE_NS as RNS, ParseError class AutodiscoverBase(EWSElement): NAMESPACE = RNS class User(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/user-pox""" ELEMENT_NAME = 'User' FIELDS = [ TextField('display_name', field_uri='DisplayName', namespace=RNS), TextField('legacy_dn', field_uri='LegacyDN', namespace=RNS), TextField('deployment_id', field_uri='DeploymentId', namespace=RNS), # GUID format EmailAddressField('autodiscover_smtp_address', field_uri='AutoDiscoverSMTPAddress', namespace=RNS), ] __slots__ = tuple(f.name for f in FIELDS) class IntExtUrlBase(AutodiscoverBase): FIELDS = [ TextField('external_url', field_uri='ExternalUrl', namespace=RNS), TextField('internal_url', field_uri='InternalUrl', namespace=RNS), ] __slots__ = tuple(f.name for f in FIELDS) class AddressBook(IntExtUrlBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/addressbook-pox""" ELEMENT_NAME = 'AddressBook' __slots__ = tuple() class MailStore(IntExtUrlBase): ELEMENT_NAME = 'MailStore' __slots__ = tuple() class NetworkRequirements(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/networkrequirements-pox""" ELEMENT_NAME = 'NetworkRequirements' FIELDS = [ TextField('ipv4_start', field_uri='IPv4Start', namespace=RNS), TextField('ipv4_end', field_uri='IPv4End', namespace=RNS), TextField('ipv6_start', field_uri='IPv6Start', namespace=RNS), TextField('ipv6_end', field_uri='IPv6End', namespace=RNS), ] __slots__ = tuple(f.name for f in FIELDS) class SimpleProtocol(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox Used for the 'Internal' and 'External' elements that may contain a stripped-down version of the Protocol element. """ ELEMENT_NAME = 'Protocol' FIELDS = [ ChoiceField('type', field_uri='Type', choices={ Choice('WEB'), Choice('EXCH'), Choice('EXPR'), Choice('EXHTTP') }, namespace=RNS), TextField('as_url', field_uri='ASUrl', namespace=RNS), ] __slots__ = tuple(f.name for f in FIELDS) class IntExtBase(AutodiscoverBase): FIELDS = [ # TODO: 'OWAUrl' also has an AuthenticationMethod enum-style XML attribute TextField('owa_url', field_uri='OWAUrl', namespace=RNS), EWSElementField('protocol', value_cls=SimpleProtocol), ] __slots__ = tuple(f.name for f in FIELDS) class Internal(IntExtBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/internal-pox""" ELEMENT_NAME = 'Internal' __slots__ = tuple() class External(IntExtBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/external-pox""" ELEMENT_NAME = 'External' __slots__ = tuple() class Protocol(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox""" ELEMENT_NAME = 'Protocol' TYPES = ('WEB', 'EXCH', 'EXPR', 'EXHTTP') FIELDS = [ # Attribute 'Type' is ignored here. Has a name conflict with the child element and does not seem useful. TextField('version', field_uri='Version', is_attribute=True, namespace=RNS), ChoiceField('type', field_uri='Type', namespace=RNS, choices={Choice(p) for p in TYPES}), TextField('internal', field_uri='Internal', namespace=RNS), TextField('external', field_uri='External', namespace=RNS), IntegerField('ttl', field_uri='TTL', namespace=RNS, default=1), # TTL for this autodiscover response, in hours TextField('server', field_uri='Server', namespace=RNS), TextField('server_dn', field_uri='ServerDN', namespace=RNS), BuildField('server_version', field_uri='ServerVersion', namespace=RNS), TextField('mdb_dn', field_uri='MdbDN', namespace=RNS), TextField('public_folder_server', field_uri='PublicFolderServer', namespace=RNS), IntegerField('port', field_uri='Port', namespace=RNS, min=1, max=65535), IntegerField('directory_port', field_uri='DirectoryPort', namespace=RNS, min=1, max=65535), IntegerField('referral_port', field_uri='ReferralPort', namespace=RNS, min=1, max=65535), TextField('as_url', field_uri='ASUrl', namespace=RNS), TextField('ews_url', field_uri='EwsUrl', namespace=RNS), TextField('emws_url', field_uri='EmwsUrl', namespace=RNS), TextField('sharing_url', field_uri='SharingUrl', namespace=RNS), TextField('ecp_url', field_uri='EcpUrl', namespace=RNS), TextField('ecp_url_um', field_uri='EcpUrl-um', namespace=RNS), TextField('ecp_url_aggr', field_uri='EcpUrl-aggr', namespace=RNS), TextField('ecp_url_mt', field_uri='EcpUrl-mt', namespace=RNS), TextField('ecp_url_ret', field_uri='EcpUrl-ret', namespace=RNS), TextField('ecp_url_sms', field_uri='EcpUrl-sms', namespace=RNS), TextField('ecp_url_publish', field_uri='EcpUrl-publish', namespace=RNS), TextField('ecp_url_photo', field_uri='EcpUrl-photo', namespace=RNS), TextField('ecp_url_tm', field_uri='EcpUrl-tm', namespace=RNS), TextField('ecp_url_tm_creating', field_uri='EcpUrl-tmCreating', namespace=RNS), TextField('ecp_url_tm_hiding', field_uri='EcpUrl-tmHiding', namespace=RNS), TextField('ecp_url_tm_editing', field_uri='EcpUrl-tmEditing', namespace=RNS), TextField('ecp_url_extinstall', field_uri='EcpUrl-extinstall', namespace=RNS), TextField('oof_url', field_uri='OOFUrl', namespace=RNS), TextField('oab_url', field_uri='OABUrl', namespace=RNS), TextField('um_url', field_uri='UMUrl', namespace=RNS), TextField('ews_partner_url', field_uri='EwsPartnerUrl', namespace=RNS), TextField('login_name', field_uri='LoginName', namespace=RNS), OnOffField('domain_required', field_uri='DomainRequired', namespace=RNS), TextField('domain_name', field_uri='DomainName', namespace=RNS), OnOffField('spa', field_uri='SPA', namespace=RNS, default=True), ChoiceField('auth_package', field_uri='AuthPackage', namespace=RNS, choices={ Choice(c) for c in ('basic', 'kerb', 'kerbntlm', 'ntlm', 'certificate', 'negotiate', 'nego2') }), TextField('cert_principal_name', field_uri='CertPrincipalName', namespace=RNS), OnOffField('ssl', field_uri='SSL', namespace=RNS, default=True), OnOffField('auth_required', field_uri='AuthRequired', namespace=RNS, default=True), OnOffField('use_pop_path', field_uri='UsePOPAuth', namespace=RNS), OnOffField('smtp_last', field_uri='SMTPLast', namespace=RNS, default=False), EWSElementField('network_requirements', value_cls=NetworkRequirements), EWSElementField('address_book', value_cls=AddressBook), EWSElementField('mail_store', value_cls=MailStore), ] __slots__ = tuple(f.name for f in FIELDS) @property def auth_type(self): # Translates 'auth_package' value to our own 'auth_type' enum vals from ..transport import NOAUTH, NTLM, BASIC, GSSAPI, SSPI if not self.auth_required: return NOAUTH return { # Missing in list are DIGEST and OAUTH2 'basic': BASIC, 'kerb': GSSAPI, 'kerbntlm': NTLM, # Means client can chose between NTLM and GSSAPI 'ntlm': NTLM, # 'certificate' is not supported by us 'negotiate': SSPI, # Unsure about this one 'nego2': GSSAPI, 'anonymous': NOAUTH, # Seen in some docs even though it's not mentioned in MSDN }.get(self.auth_package.lower(), NTLM) # Default to NTLM class Error(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/error-pox""" ELEMENT_NAME = 'Error' NAMESPACE = AUTODISCOVER_BASE_NS FIELDS = [ TextField('id', field_uri='Id', namespace=AUTODISCOVER_BASE_NS, is_attribute=True), TextField('time', field_uri='Time', namespace=AUTODISCOVER_BASE_NS, is_attribute=True), TextField('code', field_uri='ErrorCode', namespace=AUTODISCOVER_BASE_NS), TextField('message', field_uri='Message', namespace=AUTODISCOVER_BASE_NS), TextField('debug_data', field_uri='DebugData', namespace=AUTODISCOVER_BASE_NS), ] __slots__ = tuple(f.name for f in FIELDS) class Account(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/account-pox""" ELEMENT_NAME = 'Account' REDIRECT_URL = 'redirectUrl' REDIRECT_ADDR = 'redirectAddr' SETTINGS = 'settings' ACTIONS = (REDIRECT_URL, REDIRECT_ADDR, SETTINGS) FIELDS = [ ChoiceField('type', field_uri='AccountType', namespace=RNS, choices={Choice('email')}), ChoiceField('action', field_uri='Action', namespace=RNS, choices={Choice(p) for p in ACTIONS}), BooleanField('microsoft_online', field_uri='MicrosoftOnline', namespace=RNS), TextField('redirect_url', field_uri='RedirectURL', namespace=RNS), EmailAddressField('redirect_address', field_uri='RedirectAddr', namespace=RNS), TextField('image', field_uri='Image', namespace=RNS), # Path to image used for branding TextField('service_home', field_uri='ServiceHome', namespace=RNS), # URL to website of ISP ProtocolListField('protocols'), # 'SmtpAddress' is inside the 'PublicFolderInformation' element TextField('public_folder_smtp_address', field_uri='SmtpAddress', namespace=RNS), ] __slots__ = tuple(f.name for f in FIELDS) @classmethod def from_xml(cls, elem, account): kwargs = {} public_folder_information = elem.find('{%s}PublicFolderInformation' % cls.NAMESPACE) for f in cls.FIELDS: if f.name == 'public_folder_smtp_address': if public_folder_information is None: continue kwargs[f.name] = f.from_xml(elem=public_folder_information, account=account) continue kwargs[f.name] = f.from_xml(elem=elem, account=account) cls._clear(elem) return cls(**kwargs) class Response(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox""" ELEMENT_NAME = 'Response' FIELDS = [ EWSElementField('user', value_cls=User), EWSElementField('account', value_cls=Account), ] __slots__ = tuple(f.name for f in FIELDS) @property def redirect_address(self): try: if self.account.action != Account.REDIRECT_ADDR: return None return self.account.redirect_address except AttributeError: return None @property def redirect_url(self): try: if self.account.action != Account.REDIRECT_URL: return None return self.account.redirect_url except AttributeError: return None @property def autodiscover_smtp_address(self): # AutoDiscoverSMTPAddress might not be present in the XML. In this case, use the original email address. try: if self.account.action != Account.SETTINGS: return None return self.user.autodiscover_smtp_address except AttributeError: return None @property def protocol(self): # There are three possible protocol types: EXCH, EXPR and WEB. EXPR is meant for EWS. See # https://techcommunity.microsoft.com/t5/blogs/blogarticleprintpage/blog-id/Exchange/article-id/16 # We allow fallback to EXCH if EXPR is not available, to support installations where EXPR is not available. protocols = {p.type: p for p in self.account.protocols} if 'EXPR' in protocols: return protocols['EXPR'] if 'EXCH' in protocols: return protocols['EXCH'] # Neither type was found. Give up raise ValueError('No valid protocols in response: %s' % self.account.protocols) class ErrorResponse(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox Like 'Response', but with a different namespace. """ ELEMENT_NAME = 'Response' NAMESPACE = AUTODISCOVER_BASE_NS FIELDS = [ EWSElementField('error', value_cls=Error), ] __slots__ = tuple(f.name for f in FIELDS) class Autodiscover(EWSElement): ELEMENT_NAME = 'Autodiscover' NAMESPACE = AUTODISCOVER_BASE_NS FIELDS = [ EWSElementField('response', value_cls=Response), EWSElementField('error_response', value_cls=ErrorResponse), ] __slots__ = tuple(f.name for f in FIELDS) @staticmethod def _clear(elem): # Parent implementation also clears the parent, but this element doesn't have one. elem.clear() @classmethod def from_bytes(cls, bytes_content): """An Autodiscover request and response example is available at: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-response-for-exchange """ if not is_xml(bytes_content): raise ValueError('Response is not XML: %s' % bytes_content) try: root = to_xml(bytes_content).getroot() except ParseError: raise ValueError('Error parsing XML: %s' % bytes_content) if root.tag != cls.response_tag(): raise ValueError('Unknown root element in XML: %s' % bytes_content) return cls.from_xml(elem=root, account=None) def raise_errors(self): # Find an error message in the response and raise the relevant exception try: errorcode = self.error_response.error.code message = self.error_response.error.message if message in ('The e-mail address cannot be found.', "The email address can't be found."): raise ErrorNonExistentMailbox('The SMTP address has no mailbox associated with it') raise AutoDiscoverFailed('Unknown error %s: %s' % (errorcode, message)) except AttributeError: raise AutoDiscoverFailed('Unknown autodiscover error response: %s' % self) @staticmethod def payload(email): # Builds a full Autodiscover XML request payload = create_element('Autodiscover', attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS)) request = create_element('Request') add_xml_child(request, 'EMailAddress', email) add_xml_child(request, 'AcceptableResponseSchema', RNS) payload.append(request) return xml_to_str(payload, encoding=DEFAULT_ENCODING, xml_declaration=True) exchangelib-3.1.1/exchangelib/autodiscover/protocol.py000066400000000000000000000005311361226005600231600ustar00rootroot00000000000000from ..protocol import BaseProtocol class AutodiscoverProtocol(BaseProtocol): """Protocol which implements the bare essentials for autodiscover""" TIMEOUT = 10 # Seconds def __str__(self): return '''\ Autodiscover endpoint: %s Auth type: %s''' % ( self.service_endpoint, self.auth_type, ) exchangelib-3.1.1/exchangelib/configuration.py000066400000000000000000000062111361226005600214600ustar00rootroot00000000000000import logging from cached_property import threaded_cached_property from .credentials import BaseCredentials from .protocol import RetryPolicy, FailFast from .transport import AUTH_TYPE_MAP from .util import split_url from .version import Version log = logging.getLogger(__name__) class Configuration: """ Assembles a connection protocol when autodiscover is not used. If the server is not configured with autodiscover, the following should be sufficient: config = Configuration(server='example.com', credentials=Credentials('MYWINDOMAIN\\myusername', 'topsecret')) account = Account(primary_smtp_address='john@example.com', config=config) You can also set the EWS service endpoint directly: config = Configuration(service_endpoint='https://mail.example.com/EWS/Exchange.asmx', credentials=...) If you know which authentication type the server uses, you add that as a hint: config = Configuration(service_endpoint='https://example.com/EWS/Exchange.asmx', auth_type=NTLM, credentials=..) If you want to use autodiscover, don't use a Configuration object. Instead, set up an account like this: credentials = Credentials(username='MYWINDOMAIN\\myusername', password='topsecret') account = Account(primary_smtp_address='john@example.com', credentials=credentials, autodiscover=True) """ def __init__(self, credentials=None, server=None, service_endpoint=None, auth_type=None, version=None, retry_policy=None): if not isinstance(credentials, (BaseCredentials, type(None))): raise ValueError("'credentials' %r must be a Credentials instance" % credentials) if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") if auth_type is not None and auth_type not in AUTH_TYPE_MAP: raise ValueError("'auth_type' %r must be one of %s" % (auth_type, ', '.join("'%s'" % k for k in sorted(AUTH_TYPE_MAP.keys())))) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): raise ValueError("'version' %r must be a Version instance" % version) if not isinstance(retry_policy, RetryPolicy): raise ValueError("'retry_policy' %r must be a RetryPolicy instance" % retry_policy) self._credentials = credentials if server: self.service_endpoint = 'https://%s/EWS/Exchange.asmx' % server else: self.service_endpoint = service_endpoint self.auth_type = auth_type self.version = version self.retry_policy = retry_policy @property def credentials(self): # Do not update credentials from this class. Instead, do it from Protocol return self._credentials @threaded_cached_property def server(self): return split_url(self.service_endpoint)[1] def __repr__(self): return self.__class__.__name__ + '(%s)' % ', '.join('%s=%r' % (k, getattr(self, k)) for k in ( 'credentials', 'service_endpoint', 'auth_type', 'version', 'retry_policy' )) exchangelib-3.1.1/exchangelib/credentials.py000066400000000000000000000176471361226005600211250ustar00rootroot00000000000000""" Implements an Exchange user object and access types. Exchange provides two different ways of granting access for a login to a specific account. Impersonation is used mainly for service accounts that connect via EWS. Delegate is used for ad-hoc access e.g. granted manually by the user. See http://blogs.msdn.com/b/exchangedev/archive/2009/06/15/exchange-impersonation-vs-delegate-access.aspx """ import abc import logging from threading import RLock log = logging.getLogger(__name__) IMPERSONATION = 'impersonation' DELEGATE = 'delegate' ACCESS_TYPES = (IMPERSONATION, DELEGATE) class BaseCredentials(metaclass=abc.ABCMeta): """ Base for credential storage. Establishes a method for refreshing credentials (mostly useful with OAuth, which expires tokens relatively frequently) and provides a lock for synchronizing access to the object around refreshes. """ def __init__(self): self._lock = RLock() @property def lock(self): return self._lock @abc.abstractmethod def refresh(self, session): """ Obtain a new set of valid credentials. This is mostly intended to support OAuth token refreshing, which can happen in long- running applications or those that cache access tokens and so might start with a token close to expiration. :param session: requests session asking for refreshed credentials """ raise NotImplementedError( 'Credentials object does not support refreshing. ' + 'See class documentation on automatic refreshing, or subclass and implement refresh().' ) def _get_hash_values(self): return (getattr(self, k) for k in self.__dict__.keys() if k != '_lock') def __eq__(self, other): for k in self.__dict__.keys(): if k == '_lock': continue if getattr(self, k) != getattr(other, k): return False return True def __hash__(self): return hash(tuple(self._get_hash_values())) def __getstate__(self): # The lock cannot be pickled state = self.__dict__.copy() del state['_lock'] return state def __setstate__(self, state): # Restore the lock self.__dict__.update(state) self._lock = RLock() class Credentials(BaseCredentials): """ Keeps login info the way Exchange likes it. :param username: Usernames for authentication are of one of these forms: * PrimarySMTPAddress * WINDOMAIN\\username * User Principal Name (UPN) :param password: Clear-text password """ EMAIL = 'email' DOMAIN = 'domain' UPN = 'upn' def __init__(self, username, password): super().__init__() if username.count('@') == 1: self.type = self.EMAIL elif username.count('\\') == 1: self.type = self.DOMAIN else: self.type = self.UPN self.username = username self.password = password def refresh(self, session): pass def __repr__(self): return self.__class__.__name__ + repr((self.username, '********')) def __str__(self): return self.username class OAuth2Credentials(BaseCredentials): """ Login info for OAuth 2.0 client credentials authentication, as well as a base for other OAuth 2.0 grant types. This is primarily useful for in-house applications accessing data from a single Microsoft account. For applications that will access multiple tenants' data, the client credentials flow does not give the application enough information to restrict end users' access to the appropriate account. Use OAuth2AuthorizationCodeCredentials and the associated auth code grant type for multi-tenant applications. :param client_id: ID of an authorized OAuth application :param client_secret: Secret associated with the OAuth application :param tenant_id: Microsoft tenant ID of the account to access """ def __init__(self, client_id, client_secret, tenant_id): super().__init__() self.client_id = client_id self.client_secret = client_secret self.tenant_id = tenant_id def refresh(self, session): # Creating a new session gets a new access token, so there's no # work here to refresh the credentials. This implementation just # makes sure we don't raise a NotImplementedError. pass def on_token_auto_refreshed(self, access_token): """ Called after the access token is refreshed (requests-oauthlib can automatically refresh tokens if given an OAuth client ID and secret, so this is how our copy of the token stays up-to-date). Applications that cache access tokens can override this to store the new token - just remember to call the super() method! :param access_token: New token obtained by refreshing """ # Ensure we don't update the object in the middle of a new session # being created, which could cause a race with self.lock: self.access_token = access_token def _get_hash_values(self): # access_token is a dict (or an oauthlib.oauth2.OAuth2Token, # which is also a dict) and isn't hashable. Extract its # access_token field, which is the important one. return ( getattr(self, k) if k != 'access_token' else self.access_token['access_token'] for k in self.__dict__.keys() if k != '_lock' ) def __repr__(self): return self.__class__.__name__ + repr((self.client_id, '********')) def __str__(self): return self.client_id class OAuth2AuthorizationCodeCredentials(OAuth2Credentials): """ Login info for OAuth 2.0 authentication using the authorization code grant type. This can be used in one of several ways: * Given an authorization code, client ID, and client secret, fetch a token ourselves and refresh it as needed if supplied with a refresh token. * Given an existing access token, refresh token, client ID, and client secret, use the access token until it expires and then refresh it as needed. * Given only an existing access token, use it until it expires. This can be used to let the calling application refresh tokens itself by subclassing and implementing refresh(). Unlike the base (client credentials) grant, authorization code credentials don't require a Microsoft tenant ID because each access token (and the authorization code used to get the access token) is restricted to a single tenant. :params client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing :params client_secret: Secret associated with the OAuth application :params authorization_code: Code obtained when authorizing the application to access an account. In combination with client_id and client_secret, will be used to obtain an access token. :params access_token: Previously-obtained access token. If a token exists and the application will handle refreshing by itself (or opts not to handle it), this parameter alone is sufficient. """ def __init__(self, client_id=None, client_secret=None, authorization_code=None, access_token=None): super().__init__(client_id, client_secret, tenant_id=None) self.authorization_code = authorization_code self.access_token = access_token def __repr__(self): return self.__class__.__name__ + repr( (self.client_id, '[client_secret]', '[authorization_code]', '[access_token]') ) def __str__(self): client_id = self.client_id credential = '[access_token]' if self.access_token is not None else \ ('[authorization_code]' if self.authorization_code is not None else None) description = ' '.join(filter(None, [client_id, credential])) return description or '[underspecified credentials]' exchangelib-3.1.1/exchangelib/errors.py000066400000000000000000000677401361226005600201430ustar00rootroot00000000000000# flake8: noqa """ Stores errors specific to this package, and mirrors all the possible errors that EWS can return. """ from urllib.parse import urlparse import pytz.exceptions class MultipleObjectsReturned(Exception): pass class DoesNotExist(Exception): pass class EWSError(Exception): """Global error type within this module. """ def __init__(self, value): super().__init__(value) self.value = value def __str__(self): return str(self.value) # Warnings class EWSWarning(EWSError): pass # Misc errors class TransportError(EWSError): pass class RateLimitError(TransportError): def __init__(self, value, url, status_code, total_wait): super().__init__(value) self.url = url self.status_code = status_code self.total_wait = total_wait def __str__(self): return str( '{value} (gave up after {total_wait:.3f} seconds. URL {url} returned status code {status_code})'.format( value=self.value, url=self.url, status_code=self.status_code, total_wait=self.total_wait) ) class SOAPError(TransportError): pass class MalformedResponseError(TransportError): pass class UnauthorizedError(EWSError): pass class RedirectError(TransportError): def __init__(self, url): parsed_url = urlparse(url) self.url = url self.server = parsed_url.hostname.lower() self.has_ssl = parsed_url.scheme == 'https' super().__init__(str(self)) def __str__(self): return 'We were redirected to %s' % self.url class RelativeRedirect(TransportError): pass class AutoDiscoverError(TransportError): pass class AutoDiscoverFailed(AutoDiscoverError): pass class AutoDiscoverCircularRedirect(AutoDiscoverError): pass class AutoDiscoverRedirect(AutoDiscoverError): def __init__(self, redirect_email): self.redirect_email = redirect_email super().__init__(str(self)) def __str__(self): return 'AutoDiscover redirects to %s' % self.redirect_email class NaiveDateTimeNotAllowed(ValueError): pass class UnknownTimeZone(EWSError): pass class AmbiguousTimeError(EWSError, pytz.exceptions.AmbiguousTimeError): pass class NonExistentTimeError(EWSError, pytz.exceptions.NonExistentTimeError): pass class SessionPoolMinSizeReached(EWSError): pass class ResponseMessageError(TransportError): pass class CASError(EWSError): """EWS will sometimes return an error message in an 'X-CasErrorCode' custom HTTP header in an HTTP 500 error code. This exception is for those cases. The caller may want to do something with the original response, so store that. """ def __init__(self, cas_error, response): self.cas_error = cas_error self.response = response super().__init__(str(self)) def __str__(self): return 'CAS error: %s' % self.cas_error # Somewhat-authoritative list of possible response message error types from EWS. See full list at # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode # class ErrorAccessDenied(ResponseMessageError): pass class ErrorAccessModeSpecified(ResponseMessageError): pass class ErrorAccountDisabled(ResponseMessageError): pass class ErrorAddDelegatesFailed(ResponseMessageError): pass class ErrorAddressSpaceNotFound(ResponseMessageError): pass class ErrorADOperation(ResponseMessageError): pass class ErrorADSessionFilter(ResponseMessageError): pass class ErrorADUnavailable(ResponseMessageError): pass class ErrorAffectedTaskOccurrencesRequired(ResponseMessageError): pass class ErrorApplyConversationActionFailed(ResponseMessageError): pass class ErrorAttachmentSizeLimitExceeded(ResponseMessageError): pass class ErrorAutoDiscoverFailed(ResponseMessageError): pass class ErrorAvailabilityConfigNotFound(ResponseMessageError): pass class ErrorBatchProcessingStopped(ResponseMessageError): pass class ErrorCalendarCannotMoveOrCopyOccurrence(ResponseMessageError): pass class ErrorCalendarCannotUpdateDeletedItem(ResponseMessageError): pass class ErrorCalendarCannotUseIdForOccurrenceId(ResponseMessageError): pass class ErrorCalendarCannotUseIdForRecurringMasterId(ResponseMessageError): pass class ErrorCalendarDurationIsTooLong(ResponseMessageError): pass class ErrorCalendarEndDateIsEarlierThanStartDate(ResponseMessageError): pass class ErrorCalendarFolderIsInvalidForCalendarView(ResponseMessageError): pass class ErrorCalendarInvalidAttributeValue(ResponseMessageError): pass class ErrorCalendarInvalidDayForTimeChangePattern(ResponseMessageError): pass class ErrorCalendarInvalidDayForWeeklyRecurrence(ResponseMessageError): pass class ErrorCalendarInvalidPropertyState(ResponseMessageError): pass class ErrorCalendarInvalidPropertyValue(ResponseMessageError): pass class ErrorCalendarInvalidRecurrence(ResponseMessageError): pass class ErrorCalendarInvalidTimeZone(ResponseMessageError): pass class ErrorCalendarIsCancelledForAccept(ResponseMessageError): pass class ErrorCalendarIsCancelledForDecline(ResponseMessageError): pass class ErrorCalendarIsCancelledForRemove(ResponseMessageError): pass class ErrorCalendarIsCancelledForTentative(ResponseMessageError): pass class ErrorCalendarIsDelegatedForAccept(ResponseMessageError): pass class ErrorCalendarIsDelegatedForDecline(ResponseMessageError): pass class ErrorCalendarIsDelegatedForRemove(ResponseMessageError): pass class ErrorCalendarIsDelegatedForTentative(ResponseMessageError): pass class ErrorCalendarIsNotOrganizer(ResponseMessageError): pass class ErrorCalendarIsOrganizerForAccept(ResponseMessageError): pass class ErrorCalendarIsOrganizerForDecline(ResponseMessageError): pass class ErrorCalendarIsOrganizerForRemove(ResponseMessageError): pass class ErrorCalendarIsOrganizerForTentative(ResponseMessageError): pass class ErrorCalendarMeetingRequestIsOutOfDate(ResponseMessageError): pass class ErrorCalendarOccurrenceIndexIsOutOfRecurrenceRange(ResponseMessageError): pass class ErrorCalendarOccurrenceIsDeletedFromRecurrence(ResponseMessageError): pass class ErrorCalendarOutOfRange(ResponseMessageError): pass class ErrorCalendarViewRangeTooBig(ResponseMessageError): pass class ErrorCallerIsInvalidADAccount(ResponseMessageError): pass class ErrorCannotCreateCalendarItemInNonCalendarFolder(ResponseMessageError): pass class ErrorCannotCreateContactInNonContactFolder(ResponseMessageError): pass class ErrorCannotCreatePostItemInNonMailFolder(ResponseMessageError): pass class ErrorCannotCreateTaskInNonTaskFolder(ResponseMessageError): pass class ErrorCannotDeleteObject(ResponseMessageError): pass class ErrorCannotDeleteTaskOccurrence(ResponseMessageError): pass class ErrorCannotEmptyFolder(ResponseMessageError): pass class ErrorCannotOpenFileAttachment(ResponseMessageError): pass class ErrorCannotSetCalendarPermissionOnNonCalendarFolder(ResponseMessageError): pass class ErrorCannotSetNonCalendarPermissionOnCalendarFolder(ResponseMessageError): pass class ErrorCannotSetPermissionUnknownEntries(ResponseMessageError): pass class ErrorCannotUseFolderIdForItemId(ResponseMessageError): pass class ErrorCannotUseItemIdForFolderId(ResponseMessageError): pass class ErrorChangeKeyRequired(ResponseMessageError): pass class ErrorChangeKeyRequiredForWriteOperations(ResponseMessageError): pass class ErrorClientDisconnected(ResponseMessageError): pass class ErrorConnectionFailed(ResponseMessageError): pass class ErrorContainsFilterWrongType(ResponseMessageError): pass class ErrorContentConversionFailed(ResponseMessageError): pass class ErrorCorruptData(ResponseMessageError): pass class ErrorCreateItemAccessDenied(ResponseMessageError): pass class ErrorCreateManagedFolderPartialCompletion(ResponseMessageError): pass class ErrorCreateSubfolderAccessDenied(ResponseMessageError): pass class ErrorCrossMailboxMoveCopy(ResponseMessageError): pass class ErrorCrossSiteRequest(ResponseMessageError): pass class ErrorDataSizeLimitExceeded(ResponseMessageError): pass class ErrorDataSourceOperation(ResponseMessageError): pass class ErrorDelegateAlreadyExists(ResponseMessageError): pass class ErrorDelegateCannotAddOwner(ResponseMessageError): pass class ErrorDelegateMissingConfiguration(ResponseMessageError): pass class ErrorDelegateNoUser(ResponseMessageError): pass class ErrorDelegateValidationFailed(ResponseMessageError): pass class ErrorDeleteDistinguishedFolder(ResponseMessageError): pass class ErrorDeleteItemsFailed(ResponseMessageError): pass class ErrorDistinguishedUserNotSupported(ResponseMessageError): pass class ErrorDistributionListMemberNotExist(ResponseMessageError): pass class ErrorDuplicateInputFolderNames(ResponseMessageError): pass class ErrorDuplicateSOAPHeader(ResponseMessageError): pass class ErrorDuplicateUserIdsSpecified(ResponseMessageError): pass class ErrorEmailAddressMismatch(ResponseMessageError): pass class ErrorEventNotFound(ResponseMessageError): pass class ErrorExceededConnectionCount(ResponseMessageError): pass class ErrorExceededFindCountLimit(ResponseMessageError): pass class ErrorExceededSubscriptionCount(ResponseMessageError): pass class ErrorExpiredSubscription(ResponseMessageError): pass class ErrorFolderCorrupt(ResponseMessageError): pass class ErrorFolderExists(ResponseMessageError): pass class ErrorFolderNotFound(ResponseMessageError): pass class ErrorFolderPropertyRequestFailed(ResponseMessageError): pass class ErrorFolderSave(ResponseMessageError): pass class ErrorFolderSaveFailed(ResponseMessageError): pass class ErrorFolderSavePropertyError(ResponseMessageError): pass class ErrorFreeBusyDLLimitReached(ResponseMessageError): pass class ErrorFreeBusyGenerationFailed(ResponseMessageError): pass class ErrorGetServerSecurityDescriptorFailed(ResponseMessageError): pass class ErrorImpersonateUserDenied(ResponseMessageError): pass class ErrorImpersonationDenied(ResponseMessageError): pass class ErrorImpersonationFailed(ResponseMessageError): pass class ErrorInboxRulesValidationError(ResponseMessageError): pass class ErrorIncorrectSchemaVersion(ResponseMessageError): pass class ErrorIncorrectUpdatePropertyCount(ResponseMessageError): pass class ErrorIndividualMailboxLimitReached(ResponseMessageError): pass class ErrorInsufficientResources(ResponseMessageError): pass class ErrorInternalServerError(ResponseMessageError): pass class ErrorInternalServerTransientError(ResponseMessageError): pass class ErrorInvalidAccessLevel(ResponseMessageError): pass class ErrorInvalidArgument(ResponseMessageError): pass class ErrorInvalidAttachmentId(ResponseMessageError): pass class ErrorInvalidAttachmentSubfilter(ResponseMessageError): pass class ErrorInvalidAttachmentSubfilterTextFilter(ResponseMessageError): pass class ErrorInvalidAuthorizationContext(ResponseMessageError): pass class ErrorInvalidChangeKey(ResponseMessageError): pass class ErrorInvalidClientSecurityContext(ResponseMessageError): pass class ErrorInvalidCompleteDate(ResponseMessageError): pass class ErrorInvalidContactEmailAddress(ResponseMessageError): pass class ErrorInvalidContactEmailIndex(ResponseMessageError): pass class ErrorInvalidCrossForestCredentials(ResponseMessageError): pass class ErrorInvalidDelegatePermission(ResponseMessageError): pass class ErrorInvalidDelegateUserId(ResponseMessageError): pass class ErrorInvalidExchangeImpersonationHeaderData(ResponseMessageError): pass class ErrorInvalidExcludesRestriction(ResponseMessageError): pass class ErrorInvalidExpressionTypeForSubFilter(ResponseMessageError): pass class ErrorInvalidExtendedProperty(ResponseMessageError): pass class ErrorInvalidExtendedPropertyValue(ResponseMessageError): pass class ErrorInvalidExternalSharingInitiator(ResponseMessageError): pass class ErrorInvalidExternalSharingSubscriber(ResponseMessageError): pass class ErrorInvalidFederatedOrganizationId(ResponseMessageError): pass class ErrorInvalidFolderId(ResponseMessageError): pass class ErrorInvalidFolderTypeForOperation(ResponseMessageError): pass class ErrorInvalidFractionalPagingParameters(ResponseMessageError): pass class ErrorInvalidFreeBusyViewType(ResponseMessageError): pass class ErrorInvalidGetSharingFolderRequest(ResponseMessageError): pass class ErrorInvalidId(ResponseMessageError): pass class ErrorInvalidIdEmpty(ResponseMessageError): pass class ErrorInvalidIdMalformed(ResponseMessageError): pass class ErrorInvalidIdMalformedEwsLegacyIdFormat(ResponseMessageError): pass class ErrorInvalidIdMonikerTooLong(ResponseMessageError): pass class ErrorInvalidIdNotAnItemAttachmentId(ResponseMessageError): pass class ErrorInvalidIdReturnedByResolveNames(ResponseMessageError): pass class ErrorInvalidIdStoreObjectIdTooLong(ResponseMessageError): pass class ErrorInvalidIdTooManyAttachmentLevels(ResponseMessageError): pass class ErrorInvalidIdXml(ResponseMessageError): pass class ErrorInvalidIndexedPagingParameters(ResponseMessageError): pass class ErrorInvalidInternetHeaderChildNodes(ResponseMessageError): pass class ErrorInvalidItemForOperationAcceptItem(ResponseMessageError): pass class ErrorInvalidItemForOperationCancelItem(ResponseMessageError): pass class ErrorInvalidItemForOperationCreateItem(ResponseMessageError): pass class ErrorInvalidItemForOperationCreateItemAttachment(ResponseMessageError): pass class ErrorInvalidItemForOperationDeclineItem(ResponseMessageError): pass class ErrorInvalidItemForOperationExpandDL(ResponseMessageError): pass class ErrorInvalidItemForOperationRemoveItem(ResponseMessageError): pass class ErrorInvalidItemForOperationSendItem(ResponseMessageError): pass class ErrorInvalidItemForOperationTentative(ResponseMessageError): pass class ErrorInvalidLicense(ResponseMessageError): pass class ErrorInvalidLogonType(ResponseMessageError): pass class ErrorInvalidMailbox(ResponseMessageError): pass class ErrorInvalidManagedFolderProperty(ResponseMessageError): pass class ErrorInvalidManagedFolderQuota(ResponseMessageError): pass class ErrorInvalidManagedFolderSize(ResponseMessageError): pass class ErrorInvalidMergedFreeBusyInterval(ResponseMessageError): pass class ErrorInvalidNameForNameResolution(ResponseMessageError): pass class ErrorInvalidNetworkServiceContext(ResponseMessageError): pass class ErrorInvalidOofParameter(ResponseMessageError): pass class ErrorInvalidOperation(ResponseMessageError): pass class ErrorInvalidOrganizationRelationshipForFreeBusy(ResponseMessageError): pass class ErrorInvalidPagingMaxRows(ResponseMessageError): pass class ErrorInvalidParentFolder(ResponseMessageError): pass class ErrorInvalidPercentCompleteValue(ResponseMessageError): pass class ErrorInvalidPermissionSettings(ResponseMessageError): pass class ErrorInvalidPhoneCallId(ResponseMessageError): pass class ErrorInvalidPhoneNumber(ResponseMessageError): pass class ErrorInvalidPropertyAppend(ResponseMessageError): pass class ErrorInvalidPropertyDelete(ResponseMessageError): pass class ErrorInvalidPropertyForExists(ResponseMessageError): pass class ErrorInvalidPropertyForOperation(ResponseMessageError): pass class ErrorInvalidPropertyRequest(ResponseMessageError): pass class ErrorInvalidPropertySet(ResponseMessageError): pass class ErrorInvalidPropertyUpdateSentMessage(ResponseMessageError): pass class ErrorInvalidProxySecurityContext(ResponseMessageError): pass class ErrorInvalidPullSubscriptionId(ResponseMessageError): pass class ErrorInvalidPushSubscriptionUrl(ResponseMessageError): pass class ErrorInvalidRecipients(ResponseMessageError): pass class ErrorInvalidRecipientSubfilter(ResponseMessageError): pass class ErrorInvalidRecipientSubfilterComparison(ResponseMessageError): pass class ErrorInvalidRecipientSubfilterOrder(ResponseMessageError): pass class ErrorInvalidRecipientSubfilterTextFilter(ResponseMessageError): pass class ErrorInvalidReferenceItem(ResponseMessageError): pass class ErrorInvalidRequest(ResponseMessageError): pass class ErrorInvalidRestriction(ResponseMessageError): pass class ErrorInvalidRoutingType(ResponseMessageError): pass class ErrorInvalidScheduledOofDuration(ResponseMessageError): pass class ErrorInvalidSchemaVersionForMailboxVersion(ResponseMessageError): pass class ErrorInvalidSecurityDescriptor(ResponseMessageError): pass class ErrorInvalidSendItemSaveSettings(ResponseMessageError): pass class ErrorInvalidSerializedAccessToken(ResponseMessageError): pass class ErrorInvalidServerVersion(ResponseMessageError): pass class ErrorInvalidSharingData(ResponseMessageError): pass class ErrorInvalidSharingMessage(ResponseMessageError): pass class ErrorInvalidSid(ResponseMessageError): pass class ErrorInvalidSIPUri(ResponseMessageError): pass class ErrorInvalidSmtpAddress(ResponseMessageError): pass class ErrorInvalidSubfilterType(ResponseMessageError): pass class ErrorInvalidSubfilterTypeNotAttendeeType(ResponseMessageError): pass class ErrorInvalidSubfilterTypeNotRecipientType(ResponseMessageError): pass class ErrorInvalidSubscription(ResponseMessageError): pass class ErrorInvalidSubscriptionRequest(ResponseMessageError): pass class ErrorInvalidSyncStateData(ResponseMessageError): pass class ErrorInvalidTimeInterval(ResponseMessageError): pass class ErrorInvalidUserInfo(ResponseMessageError): pass class ErrorInvalidUserOofSettings(ResponseMessageError): pass class ErrorInvalidUserPrincipalName(ResponseMessageError): pass class ErrorInvalidUserSid(ResponseMessageError): pass class ErrorInvalidUserSidMissingUPN(ResponseMessageError): pass class ErrorInvalidValueForProperty(ResponseMessageError): pass class ErrorInvalidWatermark(ResponseMessageError): pass class ErrorIPGatewayNotFound(ResponseMessageError): pass class ErrorIrresolvableConflict(ResponseMessageError): pass class ErrorItemCorrupt(ResponseMessageError): pass class ErrorItemNotFound(ResponseMessageError): pass class ErrorItemPropertyRequestFailed(ResponseMessageError): pass class ErrorItemSave(ResponseMessageError): pass class ErrorItemSavePropertyError(ResponseMessageError): pass class ErrorLegacyMailboxFreeBusyViewTypeNotMerged(ResponseMessageError): pass class ErrorLocalServerObjectNotFound(ResponseMessageError): pass class ErrorLogonAsNetworkServiceFailed(ResponseMessageError): pass class ErrorMailboxConfiguration(ResponseMessageError): pass class ErrorMailboxDataArrayEmpty(ResponseMessageError): pass class ErrorMailboxDataArrayTooBig(ResponseMessageError): pass class ErrorMailboxFailover(ResponseMessageError): pass class ErrorMailboxLogonFailed(ResponseMessageError): pass class ErrorMailboxMoveInProgress(ResponseMessageError): pass class ErrorMailboxStoreUnavailable(ResponseMessageError): pass class ErrorMailRecipientNotFound(ResponseMessageError): pass class ErrorMailTipsDisabled(ResponseMessageError): pass class ErrorManagedFolderAlreadyExists(ResponseMessageError): pass class ErrorManagedFolderNotFound(ResponseMessageError): pass class ErrorManagedFoldersRootFailure(ResponseMessageError): pass class ErrorMeetingSuggestionGenerationFailed(ResponseMessageError): pass class ErrorMessageDispositionRequired(ResponseMessageError): pass class ErrorMessageSizeExceeded(ResponseMessageError): pass class ErrorMessageTrackingNoSuchDomain(ResponseMessageError): pass class ErrorMessageTrackingPermanentError(ResponseMessageError): pass class ErrorMessageTrackingTransientError(ResponseMessageError): pass class ErrorMimeContentConversionFailed(ResponseMessageError): pass class ErrorMimeContentInvalid(ResponseMessageError): pass class ErrorMimeContentInvalidBase64String(ResponseMessageError): pass class ErrorMissedNotificationEvents(ResponseMessageError): pass class ErrorMissingArgument(ResponseMessageError): pass class ErrorMissingEmailAddress(ResponseMessageError): pass class ErrorMissingEmailAddressForManagedFolder(ResponseMessageError): pass class ErrorMissingInformationEmailAddress(ResponseMessageError): pass class ErrorMissingInformationReferenceItemId(ResponseMessageError): pass class ErrorMissingInformationSharingFolderId(ResponseMessageError): pass class ErrorMissingItemForCreateItemAttachment(ResponseMessageError): pass class ErrorMissingManagedFolderId(ResponseMessageError): pass class ErrorMissingRecipients(ResponseMessageError): pass class ErrorMissingUserIdInformation(ResponseMessageError): pass class ErrorMoreThanOneAccessModeSpecified(ResponseMessageError): pass class ErrorMoveCopyFailed(ResponseMessageError): pass class ErrorMoveDistinguishedFolder(ResponseMessageError): pass class ErrorNameResolutionMultipleResults(ResponseMessageError): pass class ErrorNameResolutionNoMailbox(ResponseMessageError): pass class ErrorNameResolutionNoResults(ResponseMessageError): pass class ErrorNewEventStreamConnectionOpened(ResponseMessageError): pass class ErrorNoApplicableProxyCASServersAvailable(ResponseMessageError): pass class ErrorNoCalendar(ResponseMessageError): pass class ErrorNoDestinationCASDueToKerberosRequirements(ResponseMessageError): pass class ErrorNoDestinationCASDueToSSLRequirements(ResponseMessageError): pass class ErrorNoDestinationCASDueToVersionMismatch(ResponseMessageError): pass class ErrorNoFolderClassOverride(ResponseMessageError): pass class ErrorNoFreeBusyAccess(ResponseMessageError): pass class ErrorNonExistentMailbox(ResponseMessageError): pass class ErrorNonPrimarySmtpAddress(ResponseMessageError): pass class ErrorNoPropertyTagForCustomProperties(ResponseMessageError): pass class ErrorNoPublicFolderReplicaAvailable(ResponseMessageError): pass class ErrorNoPublicFolderServerAvailable(ResponseMessageError): pass class ErrorNoRespondingCASInDestinationSite(ResponseMessageError): pass class ErrorNotAllowedExternalSharingByPolicy(ResponseMessageError): pass class ErrorNotDelegate(ResponseMessageError): pass class ErrorNotEnoughMemory(ResponseMessageError): pass class ErrorNotSupportedSharingMessage(ResponseMessageError): pass class ErrorObjectTypeChanged(ResponseMessageError): pass class ErrorOccurrenceCrossingBoundary(ResponseMessageError): pass class ErrorOccurrenceTimeSpanTooBig(ResponseMessageError): pass class ErrorOperationNotAllowedWithPublicFolderRoot(ResponseMessageError): pass class ErrorOrganizationNotFederated(ResponseMessageError): pass class ErrorOutlookRuleBlobExists(ResponseMessageError): pass class ErrorParentFolderIdRequired(ResponseMessageError): pass class ErrorParentFolderNotFound(ResponseMessageError): pass class ErrorPasswordChangeRequired(ResponseMessageError): pass class ErrorPasswordExpired(ResponseMessageError): pass class ErrorPermissionNotAllowedByPolicy(ResponseMessageError): pass class ErrorPhoneNumberNotDialable(ResponseMessageError): pass class ErrorPropertyUpdate(ResponseMessageError): pass class ErrorPropertyValidationFailure(ResponseMessageError): pass class ErrorProxiedSubscriptionCallFailure(ResponseMessageError): pass class ErrorProxyCallFailed(ResponseMessageError): pass class ErrorProxyGroupSidLimitExceeded(ResponseMessageError): pass class ErrorProxyRequestNotAllowed(ResponseMessageError): pass class ErrorProxyRequestProcessingFailed(ResponseMessageError): pass class ErrorProxyServiceDiscoveryFailed(ResponseMessageError): pass class ErrorProxyTokenExpired(ResponseMessageError): pass class ErrorPublicFolderRequestProcessingFailed(ResponseMessageError): pass class ErrorPublicFolderServerNotFound(ResponseMessageError): pass class ErrorQueryFilterTooLong(ResponseMessageError): pass class ErrorQuotaExceeded(ResponseMessageError): pass class ErrorReadEventsFailed(ResponseMessageError): pass class ErrorReadReceiptNotPending(ResponseMessageError): pass class ErrorRecurrenceEndDateTooBig(ResponseMessageError): pass class ErrorRecurrenceHasNoOccurrence(ResponseMessageError): pass class ErrorRemoveDelegatesFailed(ResponseMessageError): pass class ErrorRequestAborted(ResponseMessageError): pass class ErrorRequestStreamTooBig(ResponseMessageError): pass class ErrorRequiredPropertyMissing(ResponseMessageError): pass class ErrorResolveNamesInvalidFolderType(ResponseMessageError): pass class ErrorResolveNamesOnlyOneContactsFolderAllowed(ResponseMessageError): pass class ErrorResponseSchemaValidation(ResponseMessageError): pass class ErrorRestrictionTooComplex(ResponseMessageError): pass class ErrorRestrictionTooLong(ResponseMessageError): pass class ErrorResultSetTooBig(ResponseMessageError): pass class ErrorRulesOverQuota(ResponseMessageError): pass class ErrorSavedItemFolderNotFound(ResponseMessageError): pass class ErrorSchemaValidation(ResponseMessageError): pass class ErrorSearchFolderNotInitialized(ResponseMessageError): pass class ErrorSendAsDenied(ResponseMessageError): pass class ErrorSendMeetingCancellationsRequired(ResponseMessageError): pass class ErrorSendMeetingInvitationsOrCancellationsRequired(ResponseMessageError): pass class ErrorSendMeetingInvitationsRequired(ResponseMessageError): pass class ErrorSentMeetingRequestUpdate(ResponseMessageError): pass class ErrorSentTaskRequestUpdate(ResponseMessageError): pass class ErrorServerBusy(ResponseMessageError): def __init__(self, *args, **kwargs): self.back_off = kwargs.pop('back_off', None) # Requested back off value in seconds super().__init__(*args, **kwargs) class ErrorServiceDiscoveryFailed(ResponseMessageError): pass class ErrorSharingNoExternalEwsAvailable(ResponseMessageError): pass class ErrorSharingSynchronizationFailed(ResponseMessageError): pass class ErrorStaleObject(ResponseMessageError): pass class ErrorSubmissionQuotaExceeded(ResponseMessageError): pass class ErrorSubscriptionAccessDenied(ResponseMessageError): pass class ErrorSubscriptionDelegateAccessNotSupported(ResponseMessageError): pass class ErrorSubscriptionNotFound(ResponseMessageError): pass class ErrorSubscriptionUnsubsribed(ResponseMessageError): pass class ErrorSyncFolderNotFound(ResponseMessageError): pass class ErrorTimeIntervalTooBig(ResponseMessageError): pass class ErrorTimeoutExpired(ResponseMessageError): pass class ErrorTimeZone(ResponseMessageError): pass class ErrorToFolderNotFound(ResponseMessageError): pass class ErrorTokenSerializationDenied(ResponseMessageError): pass class ErrorTooManyObjectsOpened(ResponseMessageError): pass class ErrorUnableToGetUserOofSettings(ResponseMessageError): pass class ErrorUnifiedMessagingDialPlanNotFound(ResponseMessageError): pass class ErrorUnifiedMessagingRequestFailed(ResponseMessageError): pass class ErrorUnifiedMessagingServerNotFound(ResponseMessageError): pass class ErrorUnsupportedCulture(ResponseMessageError): pass class ErrorUnsupportedMapiPropertyType(ResponseMessageError): pass class ErrorUnsupportedMimeConversion(ResponseMessageError): pass class ErrorUnsupportedPathForQuery(ResponseMessageError): pass class ErrorUnsupportedPathForSortGroup(ResponseMessageError): pass class ErrorUnsupportedPropertyDefinition(ResponseMessageError): pass class ErrorUnsupportedQueryFilter(ResponseMessageError): pass class ErrorUnsupportedRecurrence(ResponseMessageError): pass class ErrorUnsupportedSubFilter(ResponseMessageError): pass class ErrorUnsupportedTypeForConversion(ResponseMessageError): pass class ErrorUpdateDelegatesFailed(ResponseMessageError): pass class ErrorUpdatePropertyMismatch(ResponseMessageError): pass class ErrorUserNotAllowedByPolicy(ResponseMessageError): pass class ErrorUserNotUnifiedMessagingEnabled(ResponseMessageError): pass class ErrorUserWithoutFederatedProxyAddress(ResponseMessageError): pass class ErrorValueOutOfRange(ResponseMessageError): pass class ErrorVirusDetected(ResponseMessageError): pass class ErrorVirusMessageDeleted(ResponseMessageError): pass class ErrorVoiceMailNotImplemented(ResponseMessageError): pass class ErrorWebRequestInInvalidState(ResponseMessageError): pass class ErrorWin32InteropError(ResponseMessageError): pass class ErrorWorkingHoursSaveFailed(ResponseMessageError): pass class ErrorWorkingHoursXmlMalformed(ResponseMessageError): pass class ErrorWrongServerVersion(ResponseMessageError): pass class ErrorWrongServerVersionDelegate(ResponseMessageError): pass # Microsoft recommends to cache the autodiscover data around 24 hours and perform autodiscover # immediately following certain error responses from EWS. See more at # http://blogs.msdn.com/b/mstehle/archive/2010/11/09/ews-best-practices-use-autodiscover.aspx ERRORS_REQUIRING_REAUTODISCOVER = ( ErrorAutoDiscoverFailed, ErrorConnectionFailed, ErrorIncorrectSchemaVersion, ErrorInvalidCrossForestCredentials, ErrorInvalidIdReturnedByResolveNames, ErrorInvalidNetworkServiceContext, ErrorMailboxMoveInProgress, ErrorMailboxMoveInProgress, ErrorMailboxStoreUnavailable, ErrorNameResolutionNoMailbox, ErrorNameResolutionNoResults, ErrorNotEnoughMemory, ) exchangelib-3.1.1/exchangelib/ewsdatetime.py000066400000000000000000000271731361226005600211360ustar00rootroot00000000000000import datetime import logging import dateutil.parser import pytz import pytz.exceptions import tzlocal from .errors import NaiveDateTimeNotAllowed, UnknownTimeZone, AmbiguousTimeError, NonExistentTimeError from .winzone import PYTZ_TO_MS_TIMEZONE_MAP, MS_TIMEZONE_TO_PYTZ_MAP log = logging.getLogger(__name__) class EWSDate(datetime.date): """ Extends the normal date implementation to satisfy EWS """ __slots__ = '_year', '_month', '_day', '_hashcode' def ewsformat(self): """ ISO 8601 format to satisfy xs:date as interpreted by EWS. Example: 2009-01-15 """ return self.isoformat() def __add__(self, other): dt = super().__add__(other) if isinstance(dt, self.__class__): return dt return self.from_date(dt) # We want to return EWSDate objects def __iadd__(self, other): return self + other def __sub__(self, other): dt = super().__sub__(other) if isinstance(dt, datetime.timedelta): return dt if isinstance(dt, self.__class__): return dt return self.from_date(dt) # We want to return EWSDate objects def __isub__(self, other): return self - other @classmethod def fromordinal(cls, n): dt = super().fromordinal(n) if isinstance(dt, cls): return dt return cls.from_date(dt) # We want to return EWSDate objects @classmethod def from_date(cls, d): if d.__class__ != datetime.date: raise ValueError("%r must be a date instance" % d) return cls(d.year, d.month, d.day) @classmethod def from_string(cls, date_string): # Sometimes, we'll receive a date string with timezone information. Not very useful. if date_string.endswith('Z'): dt = datetime.datetime.strptime(date_string, '%Y-%m-%dZ') elif ':' in date_string: if '+' in date_string: dt = datetime.datetime.strptime(date_string, '%Y-%m-%d+%H:%M') else: dt = datetime.datetime.strptime(date_string, '%Y-%m-%d-%H:%M') else: dt = datetime.datetime.strptime(date_string, '%Y-%m-%d') d = dt.date() if isinstance(d, cls): return d return cls.from_date(d) # We want to return EWSDate objects class EWSDateTime(datetime.datetime): """ Extends the normal datetime implementation to satisfy EWS """ __slots__ = '_year', '_month', '_day', '_hour', '_minute', '_second', '_microsecond', '_tzinfo', '_hashcode' def __new__(cls, *args, **kwargs): # pylint: disable=arguments-differ # Not all Python versions have the same signature for datetime.datetime """ Inherits datetime and adds extra formatting required by EWS. Do not set tzinfo directly. Use EWSTimeZone.localize() instead. """ # We can't use the exact signature of datetime.datetime because we get pickle errors, and implementing pickle # support requires copy-pasting lots of code from datetime.datetime. if not isinstance(kwargs.get('tzinfo'), (EWSTimeZone, type(None))): raise ValueError('tzinfo must be an EWSTimeZone instance') return super().__new__(cls, *args, **kwargs) def ewsformat(self): """ ISO 8601 format to satisfy xs:datetime as interpreted by EWS. Examples: 2009-01-15T13:45:56Z 2009-01-15T13:45:56+01:00 """ if not self.tzinfo: raise ValueError('EWSDateTime must be timezone-aware') if self.tzinfo.zone == 'UTC': return self.strftime('%Y-%m-%dT%H:%M:%SZ') return self.replace(microsecond=0).isoformat() @classmethod def from_datetime(cls, d): if d.__class__ != datetime.datetime: raise ValueError("%r must be a datetime instance" % d) if d.tzinfo is None: tz = None elif isinstance(d.tzinfo, EWSTimeZone): tz = d.tzinfo else: tz = EWSTimeZone.from_pytz(d.tzinfo) return cls(d.year, d.month, d.day, d.hour, d.minute, d.second, d.microsecond, tzinfo=tz) def astimezone(self, tz=None): t = super().astimezone(tz=tz) if isinstance(t, self.__class__): return t return self.from_datetime(t) # We want to return EWSDateTime objects def __add__(self, other): t = super().__add__(other) if isinstance(t, self.__class__): return t return self.from_datetime(t) # We want to return EWSDateTime objects def __iadd__(self, other): return self + other def __sub__(self, other): t = super().__sub__(other) if isinstance(t, datetime.timedelta): return t if isinstance(t, self.__class__): return t return self.from_datetime(t) # We want to return EWSDateTime objects def __isub__(self, other): return self - other @classmethod def from_string(cls, date_string): # Parses several common datetime formats and returns timezone-aware EWSDateTime objects if date_string.endswith('Z'): # UTC datetime naive_dt = super().strptime(date_string, '%Y-%m-%dT%H:%M:%SZ') return UTC.localize(naive_dt) if len(date_string) == 19: # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error local_dt = super().strptime(date_string, '%Y-%m-%dT%H:%M:%S') raise NaiveDateTimeNotAllowed(local_dt) # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM' but the Python # strptime '%z' directive cannot yet handle full ISO8601 formatted timezone information (see # http://bugs.python.org/issue15873). Use the 'dateutil' package instead. aware_dt = dateutil.parser.parse(date_string) return cls.from_datetime(aware_dt.astimezone(UTC)) # We want to return EWSDateTime objects @classmethod def fromtimestamp(cls, t, tz=None): dt = super().fromtimestamp(t, tz=tz) if isinstance(dt, cls): return dt return cls.from_datetime(dt) # We want to return EWSDateTime objects @classmethod def utcfromtimestamp(cls, t): dt = super().utcfromtimestamp(t) if isinstance(dt, cls): return dt return cls.from_datetime(dt) # We want to return EWSDateTime objects @classmethod def now(cls, tz=None): t = super().now(tz=tz) if isinstance(t, cls): return t return cls.from_datetime(t) # We want to return EWSDateTime objects @classmethod def utcnow(cls): t = super().utcnow() if isinstance(t, cls): return t return cls.from_datetime(t) # We want to return EWSDateTime objects def date(self): d = super().date() if isinstance(d, EWSDate): return d return EWSDate.from_date(d) # We want to return EWSDate objects class EWSTimeZone: """ Represents a timezone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by services.GetServerTimeZones. """ PYTZ_TO_MS_MAP = PYTZ_TO_MS_TIMEZONE_MAP MS_TO_PYTZ_MAP = MS_TIMEZONE_TO_PYTZ_MAP def __eq__(self, other): # Microsoft timezones are less granular than pytz, so an EWSTimeZone created from 'Europe/Copenhagen' may return # from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the Microsoft # timezone ID. if not hasattr(other, 'ms_id'): # Due to the type magic in from_pytz(), we cannot use isinstance() here return NotImplemented return self.ms_id == other.ms_id def __hash__(self): # We're shuffling around with base classes in from_pytz(). Make sure we have __hash__() implementation. return super().__hash__() @classmethod def from_ms_id(cls, ms_id): # Create a timezone instance from a Microsoft timezone ID. This is lossy because there is not a 1:1 translation # from MS timezone ID to pytz timezone. try: return cls.timezone(cls.MS_TO_PYTZ_MAP[ms_id]) except KeyError: if '/' in ms_id: # EWS sometimes returns an ID that has a region/location format, e.g. 'Europe/Copenhagen'. Try the # string unaltered. return cls.timezone(ms_id) raise UnknownTimeZone("Windows timezone ID '%s' is unknown by CLDR" % ms_id) @classmethod def from_pytz(cls, tz): # pytz timezones are dynamically generated. Subclass the tz.__class__ and add the extra Microsoft timezone # labels we need. # type() does not allow duplicate base classes. For static timezones, 'cls' and 'tz' are the same class. base_classes = (cls,) if cls == tz.__class__ else (cls, tz.__class__) self_cls = type(cls.__name__, base_classes, dict(tz.__class__.__dict__)) try: self_cls.ms_id = cls.PYTZ_TO_MS_MAP[tz.zone][0] except KeyError: raise UnknownTimeZone('No Windows timezone name found for timezone "%s"' % tz.zone) # We don't need the Windows long-format timezone name in long format. It's used in timezone XML elements, but # EWS happily accepts empty strings. For a full list of timezones supported by the target server, including # long-format names, see output of services.GetServerTimeZones(account.protocol).call() self_cls.ms_name = '' self = self_cls() for k, v in tz.__dict__.items(): setattr(self, k, v) return self @classmethod def localzone(cls): try: tz = tzlocal.get_localzone() except pytz.exceptions.UnknownTimeZoneError: raise UnknownTimeZone("Failed to guess local timezone") return cls.from_pytz(tz) @classmethod def timezone(cls, location): # Like pytz.timezone() but returning EWSTimeZone instances try: tz = pytz.timezone(location) except pytz.exceptions.UnknownTimeZoneError: raise UnknownTimeZone("Timezone '%s' is unknown by pytz" % location) return cls.from_pytz(tz) def normalize(self, dt, is_dst=False): return self._localize_or_normalize(func='normalize', dt=dt, is_dst=is_dst) def localize(self, dt, is_dst=False): return self._localize_or_normalize(func='localize', dt=dt, is_dst=is_dst) def _localize_or_normalize(self, func, dt, is_dst=False): """localize() and normalize() have common code paths """ # super() returns a dt.tzinfo of class pytz.tzinfo.FooBar. We need to return type EWSTimeZone if is_dst is not False: # Not all pytz timezones support 'is_dst' argument. Only pass it on if it's set explicitly. try: res = getattr(super(EWSTimeZone, self), func)(dt, is_dst=is_dst) except pytz.exceptions.AmbiguousTimeError as exc: raise AmbiguousTimeError(str(dt)) from exc except pytz.exceptions.NonExistentTimeError as exc: raise NonExistentTimeError(str(dt)) from exc else: res = getattr(super(EWSTimeZone, self), func)(dt) if not isinstance(res.tzinfo, EWSTimeZone): return res.replace(tzinfo=self.from_pytz(res.tzinfo)) return res def fromutc(self, dt): t = super().fromutc(dt) if isinstance(t, EWSDateTime): return t return EWSDateTime.from_datetime(t) # We want to return EWSDateTime objects UTC = EWSTimeZone.timezone('UTC') UTC_NOW = lambda: EWSDateTime.now(tz=UTC) # noqa: E731 exchangelib-3.1.1/exchangelib/extended_properties.py000066400000000000000000000323131361226005600226670ustar00rootroot00000000000000import base64 import logging from decimal import Decimal from .ewsdatetime import EWSDateTime from .properties import EWSElement from .util import create_element, add_xml_child, get_xml_attrs, get_xml_attr, set_xml_value, value_to_xml_text, \ xml_text_to_value, is_iterable, safe_b64decode, TNS log = logging.getLogger(__name__) class ExtendedProperty(EWSElement): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedproperty """ ELEMENT_NAME = 'ExtendedProperty' # Enum values: https://docs.microsoft.com/en-us/dotnet/api/exchangewebservices.distinguishedpropertysettype DISTINGUISHED_SETS = { 'Address', 'Appointment', 'CalendarAssistant', 'Common', 'InternetHeaders', 'Meeting', 'PublicStrings', 'Sharing', 'Task', 'UnifiedMessaging', } # Enum values: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedfielduri PROPERTY_TYPES = { 'ApplicationTime', 'Binary', 'BinaryArray', 'Boolean', 'CLSID', 'CLSIDArray', 'Currency', 'CurrencyArray', 'Double', 'DoubleArray', # 'Error', 'Float', 'FloatArray', 'Integer', 'IntegerArray', 'Long', 'LongArray', # 'Null', # 'Object', # 'ObjectArray', 'Short', 'ShortArray', 'SystemTime', 'SystemTimeArray', 'String', 'StringArray', } # The commented-out types cannot be used for setting or getting (see docs) and are thus not very useful here # Translation table between common distinguished_property_set_id and property_set_id values. See # https://docs.microsoft.com/en-us/office/client-developer/outlook/mapi/commonly-used-property-sets # ID values must be lowercase. DISTINGUISHED_SET_NAME_TO_ID_MAP = { 'Address': '00062004-0000-0000-c000-000000000046', 'AirSync': '71035549-0739-4dcb-9163-00f0580dbbdf', 'Appointment': '00062002-0000-0000-c000-000000000046', 'Common': '00062008-0000-0000-c000-000000000046', 'InternetHeaders': '00020386-0000-0000-c000-000000000046', 'Log': '0006200a-0000-0000-c000-000000000046', 'Mapi': '00020328-0000-0000-c000-000000000046', 'Meeting': '6ed8da90-450b-101b-98da-00aa003f1305', 'Messaging': '41f28f13-83f4-4114-a584-eedb5a6b0bff', 'Note': '0006200e-0000-0000-c000-000000000046', 'PostRss': '00062041-0000-0000-c000-000000000046', 'PublicStrings': '00020329-0000-0000-c000-000000000046', 'Remote': '00062014-0000-0000-c000-000000000046', 'Report': '00062013-0000-0000-c000-000000000046', 'Sharing': '00062040-0000-0000-c000-000000000046', 'Task': '00062003-0000-0000-c000-000000000046', 'UnifiedMessaging': '4442858e-a9e3-4e80-b900-317a210cc15b', } DISTINGUISHED_SET_ID_TO_NAME_MAP = {v: k for k, v in DISTINGUISHED_SET_NAME_TO_ID_MAP.items()} distinguished_property_set_id = None property_set_id = None property_tag = None # hex integer (e.g. 0x8000) or string ('0x8000') property_name = None property_id = None # integer as hex-formatted int (e.g. 0x8000) or normal int (32768) property_type = '' __slots__ = ('value',) def __init__(self, *args, **kwargs): if not kwargs: # Allow to set attributes without keyword kwargs = dict(zip(self._slots_keys(), args)) self.value = kwargs.pop('value') super().__init__(**kwargs) @classmethod def validate_cls(cls): # Validate values of class attributes and their inter-dependencies cls._validate_distinguished_property_set_id() cls._validate_property_set_id() cls._validate_property_tag() cls._validate_property_name() cls._validate_property_id() cls._validate_property_type() @classmethod def _validate_distinguished_property_set_id(cls): if cls.distinguished_property_set_id: if any([cls.property_set_id, cls.property_tag]): raise ValueError( "When 'distinguished_property_set_id' is set, 'property_set_id' and 'property_tag' must be None" ) if not any([cls.property_id, cls.property_name]): raise ValueError( "When 'distinguished_property_set_id' is set, 'property_id' or 'property_name' must also be set" ) if cls.distinguished_property_set_id not in cls.DISTINGUISHED_SETS: raise ValueError( "'distinguished_property_set_id' value '%s' must be one of %s" % (cls.distinguished_property_set_id, sorted(cls.DISTINGUISHED_SETS)) ) @classmethod def _validate_property_set_id(cls): if cls.property_set_id: if any([cls.distinguished_property_set_id, cls.property_tag]): raise ValueError( "When 'property_set_id' is set, 'distinguished_property_set_id' and 'property_tag' must be None" ) if not any([cls.property_id, cls.property_name]): raise ValueError( "When 'property_set_id' is set, 'property_id' or 'property_name' must also be set" ) @classmethod def _validate_property_tag(cls): if cls.property_tag: if any([ cls.distinguished_property_set_id, cls.property_set_id, cls.property_name, cls.property_id ]): raise ValueError("When 'property_tag' is set, only 'property_type' must be set") if 0x8000 <= cls.property_tag_as_int() <= 0xFFFE: raise ValueError( "'property_tag' value '%s' is reserved for custom properties" % cls.property_tag_as_hex() ) @classmethod def _validate_property_name(cls): if cls.property_name: if any([cls.property_id, cls.property_tag]): raise ValueError("When 'property_name' is set, 'property_id' and 'property_tag' must be None") if not any([cls.distinguished_property_set_id, cls.property_set_id]): raise ValueError( "When 'property_name' is set, 'distinguished_property_set_id' or 'property_set_id' must also be set" ) @classmethod def _validate_property_id(cls): if cls.property_id: if any([cls.property_name, cls.property_tag]): raise ValueError("When 'property_id' is set, 'property_name' and 'property_tag' must be None") if not any([cls.distinguished_property_set_id, cls.property_set_id]): raise ValueError( "When 'property_id' is set, 'distinguished_property_set_id' or 'property_set_id' must also be set" ) @classmethod def _validate_property_type(cls): if cls.property_type not in cls.PROPERTY_TYPES: raise ValueError( "'property_type' value '%s' must be one of %s" % (cls.property_type, sorted(cls.PROPERTY_TYPES)) ) def clean(self, version=None): self.validate_cls() python_type = self.python_type() if self.is_array_type(): if not is_iterable(self.value): raise ValueError("'%s' value %r must be a list" % (self.__class__.__name__, self.value)) for v in self.value: if not isinstance(v, python_type): raise TypeError( "'%s' value element %r must be an instance of %s" % (self.__class__.__name__, v, python_type)) else: if not isinstance(self.value, python_type): raise TypeError( "'%s' value %r must be an instance of %s" % (self.__class__.__name__, self.value, python_type)) @classmethod def is_property_instance(cls, elem): # Returns whether an 'ExtendedProperty' element matches the definition for this class. Extended property fields # do not have a name, so we must match on the cls.property_* attributes to match a field in the request with a # field in the response. extended_field_uri = elem.find('{%s}ExtendedFieldURI' % TNS) cls_props = cls.properties_map() elem_props = {k: extended_field_uri.get(k) for k in cls_props.keys()} # Sometimes, EWS will helpfully translate a 'distinguished_property_set_id' value to a 'property_set_id' value # and vice versa. Align these values. cls_set_id = cls.DISTINGUISHED_SET_NAME_TO_ID_MAP.get(cls_props.get('DistinguishedPropertySetId')) if cls_set_id: cls_props['PropertySetId'] = cls_set_id else: cls_set_name = cls.DISTINGUISHED_SET_ID_TO_NAME_MAP.get(cls_props.get('PropertySetId', '')) if cls_set_name: cls_props['DistinguishedPropertySetId'] = cls_set_name elem_set_id = cls.DISTINGUISHED_SET_NAME_TO_ID_MAP.get(elem_props.get('DistinguishedPropertySetId')) if elem_set_id: elem_props['PropertySetId'] = elem_set_id else: elem_set_name = cls.DISTINGUISHED_SET_ID_TO_NAME_MAP.get(elem_props.get('PropertySetId', '')) if elem_set_name: elem_props['DistinguishedPropertySetId'] = elem_set_name return cls_props == elem_props @classmethod def from_xml(cls, elem, account): # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements python_type = cls.python_type() if cls.is_array_type(): values = elem.find('{%s}Values' % TNS) if cls.is_binary_type(): return [safe_b64decode(val) for val in get_xml_attrs(values, '{%s}Value' % TNS)] return [ xml_text_to_value(value=val, value_type=python_type) for val in get_xml_attrs(values, '{%s}Value' % TNS) ] if cls.is_binary_type(): return safe_b64decode(get_xml_attr(elem, '{%s}Value' % TNS)) extended_field_value = xml_text_to_value(value=get_xml_attr(elem, '{%s}Value' % TNS), value_type=python_type) if python_type == str and not extended_field_value: # For string types, we want to return the empty string instead of None if the element was # actually found, but there was no XML value. For other types, it would be more problematic # to make that distinction, e.g. return False for bool, 0 for int, etc. return '' return extended_field_value def to_xml(self, version): if self.is_array_type(): values = create_element('t:Values') for v in self.value: if self.is_binary_type(): add_xml_child(values, 't:Value', base64.b64encode(v).decode('ascii')) else: add_xml_child(values, 't:Value', v) return values val = base64.b64encode(self.value).decode('ascii') if self.is_binary_type() else self.value return set_xml_value(create_element('t:Value'), val, version=version) @classmethod def is_array_type(cls): return cls.property_type.endswith('Array') @classmethod def is_binary_type(cls): # We can't just test python_type() == bytes, because str == bytes in Python2 return 'Binary' in cls.property_type @classmethod def property_tag_as_int(cls): if isinstance(cls.property_tag, str): return int(cls.property_tag, base=16) return cls.property_tag @classmethod def property_tag_as_hex(cls): return hex(cls.property_tag) if isinstance(cls.property_tag, int) else cls.property_tag @classmethod def python_type(cls): # Return the best equivalent for a Python type for the property type of this class base_type = cls.property_type[:-5] if cls.is_array_type() else cls.property_type return { 'ApplicationTime': Decimal, 'Binary': bytes, 'Boolean': bool, 'CLSID': str, 'Currency': int, 'Double': Decimal, 'Float': Decimal, 'Integer': int, 'Long': int, 'Short': int, 'SystemTime': EWSDateTime, 'String': str, }[base_type] @classmethod def properties_map(cls): # EWS returns PropertySetId values in lowercase in XML return { 'DistinguishedPropertySetId': cls.distinguished_property_set_id, 'PropertySetId': cls.property_set_id.lower() if cls.property_set_id else None, 'PropertyTag': cls.property_tag_as_hex(), 'PropertyName': cls.property_name, 'PropertyId': value_to_xml_text(cls.property_id) if cls.property_id else None, 'PropertyType': cls.property_type, } class ExternId(ExtendedProperty): """This is a custom extended property defined by us. It's useful for synchronization purposes, to attach a unique ID from an external system. """ property_set_id = 'c11ff724-aa03-4555-9952-8fa248a11c3e' # This is arbitrary. We just want a unique UUID. property_name = 'External ID' property_type = 'String' __slots__ = tuple() exchangelib-3.1.1/exchangelib/fields.py000066400000000000000000001376541361226005600200770ustar00rootroot00000000000000import abc import base64 import binascii from collections import OrderedDict import datetime from decimal import Decimal, InvalidOperation import logging from .errors import ErrorInvalidServerVersion from .ewsdatetime import EWSDateTime, EWSDate, EWSTimeZone, NaiveDateTimeNotAllowed, UnknownTimeZone from .util import create_element, get_xml_attrs, set_xml_value, value_to_xml_text, is_iterable, safe_b64decode, TNS from .version import Build, Version, EXCHANGE_2013 log = logging.getLogger(__name__) # DayOfWeekIndex enum. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dayofweekindex FIRST = 'First' SECOND = 'Second' THIRD = 'Third' FOURTH = 'Fourth' LAST = 'Last' WEEK_NUMBERS = (FIRST, SECOND, THIRD, FOURTH, LAST) # Month enum JANUARY = 'January' FEBRUARY = 'February' MARCH = 'March' APRIL = 'April' MAY = 'May' JUNE = 'June' JULY = 'July' AUGUST = 'August' SEPTEMBER = 'September' OCTOBER = 'October' NOVEMBER = 'November' DECEMBER = 'December' MONTHS = (JANUARY, FEBRUARY, MARCH, APRIL, MAY, JUNE, JULY, AUGUST, SEPTEMBER, OCTOBER, NOVEMBER, DECEMBER) # Weekday enum MONDAY = 'Monday' TUESDAY = 'Tuesday' WEDNESDAY = 'Wednesday' THURSDAY = 'Thursday' FRIDAY = 'Friday' SATURDAY = 'Saturday' SUNDAY = 'Sunday' WEEKDAY_NAMES = (MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY) # Used for weekday recurrences except weekly recurrences. E.g. for "First WeekendDay in March" DAY = 'Day' WEEK_DAY = 'Weekday' # Non-weekend day WEEKEND_DAY = 'WeekendDay' EXTRA_WEEKDAY_OPTIONS = (DAY, WEEK_DAY, WEEKEND_DAY) # DaysOfWeek enum: See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/daysofweek-daysofweektype WEEKDAYS = WEEKDAY_NAMES + EXTRA_WEEKDAY_OPTIONS def split_field_path(field_path): """Return the individual parts of a field path that may, apart from the fieldname, have label and subfield parts. Examples: 'start' -> ('start', None, None) 'phone_numbers__PrimaryPhone' -> ('phone_numbers', 'PrimaryPhone', None) 'physical_addresses__Home__street' -> ('physical_addresses', 'Home', 'street') """ if not isinstance(field_path, str): raise ValueError("Field path %r must be a string" % field_path) search_parts = field_path.split('__') field = search_parts[0] try: label = search_parts[1] except IndexError: label = None try: subfield = search_parts[2] except IndexError: subfield = None return field, label, subfield def resolve_field_path(field_path, folder, strict=True): # Takes the name of a field, or '__'-delimited path to a subfield, and returns the corresponding Field object, # label and SubField object from .indexed_properties import SingleFieldIndexedElement, MultiFieldIndexedElement fieldname, label, subfieldname = split_field_path(field_path) field = folder.get_item_field_by_fieldname(fieldname) subfield = None if isinstance(field, IndexedField): if strict and not label: raise ValueError( "IndexedField path '%s' must specify label, e.g. '%s__%s'" % (field_path, fieldname, field.value_cls.get_field_by_fieldname('label').default) ) valid_labels = field.value_cls.get_field_by_fieldname('label').supported_choices( version=folder.account.version ) if label and label not in valid_labels: raise ValueError( "Label '%s' on IndexedField path '%s' must be one of %s" % (label, field_path, ', '.join(valid_labels)) ) if issubclass(field.value_cls, MultiFieldIndexedElement): if strict and not subfieldname: raise ValueError( "IndexedField path '%s' must specify subfield, e.g. '%s__%s__%s'" % (field_path, fieldname, label, field.value_cls.FIELDS[1].name) ) if subfieldname: try: subfield = field.value_cls.get_field_by_fieldname(subfieldname) except ValueError: fnames = ', '.join(f.name for f in field.value_cls.supported_fields( version=folder.account.version )) raise ValueError( "Subfield '%s' on IndexedField path '%s' must be one of %s" % (subfieldname, field_path, fnames) ) else: if not issubclass(field.value_cls, SingleFieldIndexedElement): raise ValueError("'field.value_cls' %r must be an SingleFieldIndexedElement instance" % field.value_cls) if subfieldname: raise ValueError( "IndexedField path '%s' must not specify subfield, e.g. just '%s__%s'" % (field_path, fieldname, label) ) subfield = field.value_cls.value_field(version=folder.account.version) else: if label or subfieldname: raise ValueError( "Field path '%s' must not specify label or subfield, e.g. just '%s'" % (field_path, fieldname) ) return field, label, subfield class FieldPath: """ Holds values needed to point to a single field. For indexed properties, we allow setting either field, field and label, or field, label and subfield. This allows pointing to either the full indexed property set, a property with a specific label, or a particular subfield field on that property. """ def __init__(self, field, label=None, subfield=None): # 'label' and 'subfield' are only used for IndexedField fields if not isinstance(field, (FieldURIField, ExtendedPropertyField)): raise ValueError("'field' %r must be an FieldURIField, of ExtendedPropertyField instance" % field) if label and not isinstance(label, str): raise ValueError("'label' %r must be a %s instance" % (label, str)) if subfield and not isinstance(subfield, SubField): raise ValueError("'subfield' %r must be a SubField instance" % subfield) self.field = field self.label = label self.subfield = subfield @classmethod def from_string(cls, field_path, folder, strict=False): field, label, subfield = resolve_field_path(field_path, folder=folder, strict=strict) return cls(field=field, label=label, subfield=subfield) def get_value(self, item): # For indexed properties, get either the full property set, the property with matching label, or a particular # subfield. if self.label: for subitem in getattr(item, self.field.name): if subitem.label == self.label: if self.subfield: return getattr(subitem, self.subfield.name) return subitem return None # No item with this label return getattr(item, self.field.name) def to_xml(self): if isinstance(self.field, IndexedField): if not self.label or not self.subfield: raise ValueError("Field path for indexed field '%s' is missing label and/or subfield" % self.field.name) return self.subfield.field_uri_xml(field_uri=self.field.field_uri, label=self.label) else: return self.field.field_uri_xml() def expand(self, version): # If this path does not point to a specific subfield on an indexed property, return all the possible path # combinations for this field path. if isinstance(self.field, IndexedField): labels = [self.label] if self.label \ else self.field.value_cls.get_field_by_fieldname('label').supported_choices(version=version) subfields = [self.subfield] if self.subfield else self.field.value_cls.supported_fields(version=version) for label in labels: for subfield in subfields: yield FieldPath(field=self.field, label=label, subfield=subfield) else: yield self @property def path(self): if self.label: from .indexed_properties import SingleFieldIndexedElement if issubclass(self.field.value_cls, SingleFieldIndexedElement) or not self.subfield: return '%s__%s' % (self.field.name, self.label) return '%s__%s__%s' % (self.field.name, self.label, self.subfield.name) return self.field.name def __eq__(self, other): return hash(self) == hash(other) def __str__(self): return self.path def __repr__(self): return self.__class__.__name__ + repr((self.field, self.label, self.subfield)) def __hash__(self): return hash((self.field, self.label, self.subfield)) class FieldOrder: """ Holds values needed to call server-side sorting on a single field path """ def __init__(self, field_path, reverse=False): if not isinstance(field_path, FieldPath): raise ValueError("'field_path' %r must be a FieldPath instance" % field_path) if not isinstance(reverse, bool): raise ValueError("'reverse' %r must be a boolean" % reverse) self.field_path = field_path self.reverse = reverse @classmethod def from_string(cls, field_path, folder): return cls( field_path=FieldPath.from_string(field_path=field_path.lstrip('-'), folder=folder, strict=True), reverse=field_path.startswith('-') ) def to_xml(self): field_order = create_element('t:FieldOrder', attrs=dict(Order='Descending' if self.reverse else 'Ascending')) field_order.append(self.field_path.to_xml()) return field_order class Field(metaclass=abc.ABCMeta): """ Holds information related to an item field """ value_cls = None is_list = False # Is the field a complex EWS type? Quoting the EWS FindItem docs: # # The FindItem operation returns only the first 512 bytes of any streamable property. For Unicode, it returns # the first 255 characters by using a null-terminated Unicode string. It does not return any of the message # body formats or the recipient lists. # is_complex = False def __init__(self, name, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, supported_from=None, deprecated_from=None): self.name = name self.default = default # Default value if none is given self.is_required = is_required # Some fields cannot be deleted on update. Default to True if 'is_required' is set self.is_required_after_save = is_required or is_required_after_save self.is_read_only = is_read_only # Set this for fields that raise ErrorInvalidPropertyUpdateSentMessage on update after send. Default to True # if 'is_read_only' is set self.is_read_only_after_send = is_read_only or is_read_only_after_send # Define whether the field can be used in a QuerySet. For some reason, EWS disallows searching on some fields, # instead throwing ErrorInvalidValueForProperty self.is_searchable = is_searchable # When true, this field is treated as an XML attribute instead of an element self.is_attribute = is_attribute # The Exchange build when this field was introduced. When talking with versions prior to this version, # we will ignore this field. if supported_from is not None and not isinstance(supported_from, Build): raise ValueError("'supported_from' %r must be a Build instance" % supported_from) self.supported_from = supported_from # The Exchange build when this field was deprecated. When talking with versions at or later than this version, # we will ignore this field. if deprecated_from is not None and not isinstance(deprecated_from, Build): raise ValueError("'deprecated_from' %r must be a Build instance" % deprecated_from) self.deprecated_from = deprecated_from def clean(self, value, version=None): if version and not self.supports_version(version): raise ErrorInvalidServerVersion("Field '%s' does not support EWS builds prior to %s (server has %s)" % ( self.name, self.supported_from, version)) if value is None: if self.is_required and self.default is None: raise ValueError("'%s' is a required field with no default" % self.name) return self.default if self.is_list: if not is_iterable(value): raise ValueError("Field '%s' value %r must be a list" % (self.name, value)) for v in value: if not isinstance(v, self.value_cls): raise TypeError("Field '%s' value %r must be of type %s" % (self.name, v, self.value_cls)) if hasattr(v, 'clean'): v.clean(version=version) else: if not isinstance(value, self.value_cls): raise TypeError("Field '%s' value %r must be of type %s" % (self.name, value, self.value_cls)) if hasattr(value, 'clean'): value.clean(version=version) return value @abc.abstractmethod def from_xml(self, elem, account): raise NotImplementedError() @abc.abstractmethod def to_xml(self, value, version): raise NotImplementedError() def supports_version(self, version): # 'version' is a Version instance, for convenience by callers if not isinstance(version, Version): raise ValueError("'version' %r must be a Version instance" % version) if self.supported_from and version.build < self.supported_from: return False if self.deprecated_from and version.build >= self.deprecated_from: return False return True def __eq__(self, other): return hash(self) == hash(other) @abc.abstractmethod def __hash__(self): raise NotImplementedError() def __repr__(self): return self.__class__.__name__ + '(%s)' % ', '.join('%s=%r' % (f, getattr(self, f)) for f in ( 'name', 'value_cls', 'is_list', 'is_complex', 'default')) class FieldURIField(Field): def __init__(self, *args, **kwargs): self.field_uri = kwargs.pop('field_uri', None) self.namespace = kwargs.pop('namespace', TNS) super().__init__(*args, **kwargs) # See all valid FieldURI values at # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fielduri # The field_uri has a prefix when the FieldURI points to an Item field. if self.field_uri is None: self.field_uri_postfix = None elif ':' in self.field_uri: self.field_uri_postfix = self.field_uri.split(':')[1] else: self.field_uri_postfix = self.field_uri def _get_val_from_elem(self, elem): if self.is_attribute: return elem.get(self.field_uri) field_elem = elem.find(self.response_tag()) return None if field_elem is None else field_elem.text or None def from_xml(self, elem, account): raise NotImplementedError() def to_xml(self, value, version): field_elem = create_element(self.request_tag()) return set_xml_value(field_elem, value, version=version) def field_uri_xml(self): if not self.field_uri: raise ValueError("'field_uri' value is missing") return create_element('t:FieldURI', attrs=dict(FieldURI=self.field_uri)) def request_tag(self): if not self.field_uri_postfix: raise ValueError("'field_uri_postfix' value is missing") return 't:%s' % self.field_uri_postfix def response_tag(self): if not self.field_uri_postfix: raise ValueError("'field_uri_postfix' value is missing") return '{%s}%s' % (self.namespace, self.field_uri_postfix) def __hash__(self): return hash(self.field_uri) class BooleanField(FieldURIField): value_cls = bool def __init__(self, *args, **kwargs): self.true_val = kwargs.pop('true_val', 'true') self.false_val = kwargs.pop('false_val', 'false') super().__init__(*args, **kwargs) def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: try: return { self.true_val: True, self.false_val: False, }[val.lower()] except KeyError: log.warning("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None return self.default class OnOffField(BooleanField): def __init__(self, *args, **kwargs): kwargs['true_val'] = 'on' kwargs['false_val'] = 'off' super().__init__(*args, **kwargs) class IntegerField(FieldURIField): value_cls = int def __init__(self, *args, **kwargs): self.min = kwargs.pop('min', None) self.max = kwargs.pop('max', None) super().__init__(*args, **kwargs) def clean(self, value, version=None): value = super().clean(value, version=version) if value is not None: if self.is_list: for v in value: if self.min is not None and v < self.min: raise ValueError( "Value %r on field '%s' must be greater than %s" % (v, self.name, self.min)) if self.max is not None and v > self.max: raise ValueError("Value %r on field '%s' must be less than %s" % (v, self.name, self.max)) else: if self.min is not None and value < self.min: raise ValueError("Value %r on field '%s' must be greater than %s" % (value, self.name, self.min)) if self.max is not None and value > self.max: raise ValueError("Value %r on field '%s' must be less than %s" % (value, self.name, self.max)) return value def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: try: return self.value_cls(val) except (ValueError, InvalidOperation): log.warning("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None return self.default class DecimalField(IntegerField): value_cls = Decimal class EnumField(IntegerField): """A field type where you can enter either the 1-based index in an enum (tuple), or the enum value. Values will be stored internally as integers but output in XML as strings. """ def __init__(self, *args, **kwargs): self.enum = kwargs.pop('enum') # Set different min/max defaults than IntegerField if 'max' in kwargs: raise AttributeError("EnumField does not support the 'max' attribute") kwargs['min'] = kwargs.pop('min', 1) kwargs['max'] = kwargs['min'] + len(self.enum) - 1 super().__init__(*args, **kwargs) def clean(self, value, version=None): if self.is_list: value = list(value) # Convert to something we can index for i, v in enumerate(value): if isinstance(v, str): if v not in self.enum: raise ValueError( "List value '%s' on field '%s' must be one of %s" % (v, self.name, self.enum)) value[i] = self.enum.index(v) + 1 if not value: raise ValueError("Value '%s' on field '%s' must not be empty" % (value, self.name)) if len(value) > len(set(value)): raise ValueError("List entries '%s' on field '%s' must be unique" % (value, self.name)) else: if isinstance(value, str): if value not in self.enum: raise ValueError( "Value '%s' on field '%s' must be one of %s" % (value, self.name, self.enum)) value = self.enum.index(value) + 1 return super().clean(value, version=version) def as_string(self, value): # Converts an integer in the enum to its equivalent string if isinstance(value, str): return value if self.is_list: return [self.enum[v - 1] for v in sorted(value)] return self.enum[value - 1] def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: try: if self.is_list: return [self.enum.index(v) + 1 for v in val.split(' ')] return self.enum.index(val) + 1 except ValueError: log.warning("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None return self.default def to_xml(self, value, version): field_elem = create_element(self.request_tag()) if self.is_list: return set_xml_value(field_elem, ' '.join(self.as_string(value)), version=version) return set_xml_value(field_elem, self.as_string(value), version=version) class EnumListField(EnumField): is_list = True class EnumAsIntField(EnumField): """Like EnumField, but communicates values with EWS in integers""" def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: try: return int(val) except ValueError: log.warning("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None return self.default def to_xml(self, value, version): field_elem = create_element(self.request_tag()) return set_xml_value(field_elem, value, version=version) class Base64Field(FieldURIField): value_cls = bytes is_complex = True def __init__(self, *args, **kwargs): if 'is_searchable' not in kwargs: kwargs['is_searchable'] = False super().__init__(*args, **kwargs) def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: try: return safe_b64decode(val) except (TypeError, binascii.Error): log.warning("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None return self.default def to_xml(self, value, version): field_elem = create_element(self.request_tag()) return set_xml_value(field_elem, base64.b64encode(value).decode('ascii'), version=version) class MimeContentField(Base64Field): # This element has an optional 'CharacterSet' attribute, but it specifies the encoding of the base64-encoded # string (which doesn't make sense since base64 encoded strings are always ASCII). We ignore it here because # the decoded data could be in some other encoding, specified in the "Content-Type:" header. pass class DateField(FieldURIField): value_cls = EWSDate def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: try: return self.value_cls.from_string(val) except ValueError: log.warning("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None return self.default class TimeField(FieldURIField): value_cls = datetime.time def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: try: if ':' in val: # Assume a string of the form HH:MM:SS return datetime.datetime.strptime(val, '%H:%M:%S').time() else: # Assume an integer in minutes since midnight return (datetime.datetime(2000, 1, 1) + datetime.timedelta(minutes=int(val))).time() except ValueError: pass return self.default class DateTimeField(FieldURIField): value_cls = EWSDateTime def clean(self, value, version=None): if value is not None and isinstance(value, self.value_cls) and not value.tzinfo: raise ValueError("Value '%s' on field '%s' must be timezone aware" % (value, self.name)) return super().clean(value, version=version) def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: try: return self.value_cls.from_string(val) except ValueError as e: if isinstance(e, NaiveDateTimeNotAllowed): # We encountered a naive datetime local_dt = e.args[0] if account: # Convert to timezone-aware datetime using the default timezone of the account tz = account.default_timezone log.info('Found naive datetime %s on field %s. Assuming timezone %s', local_dt, self.name, tz) return tz.localize(local_dt) # There's nothing we can do but return the naive date. It's better than assuming e.g. UTC. log.warning('Returning naive datetime %s on field %s', local_dt, self.name) return local_dt log.info("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None return self.default class TimeZoneField(FieldURIField): value_cls = EWSTimeZone def from_xml(self, elem, account): field_elem = elem.find(self.response_tag()) if field_elem is not None: ms_id = field_elem.get('Id') ms_name = field_elem.get('Name') try: return self.value_cls.from_ms_id(ms_id or ms_name) except UnknownTimeZone: log.warning( "Cannot convert value '%s' on field '%s' to type %s (unknown timezone ID)", (ms_id or ms_name), self.name, self.value_cls ) return None return self.default def to_xml(self, value, version): return create_element( 't:%s' % self.field_uri_postfix, attrs=OrderedDict([ ('Id', value.ms_id), ('Name', value.ms_name), ]) ) class TextField(FieldURIField): """A field that stores a string value with no length limit""" value_cls = str is_complex = True def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: return val return self.default class TextListField(TextField): is_list = True def from_xml(self, elem, account): iter_elem = elem.find(self.response_tag()) if iter_elem is not None: return get_xml_attrs(iter_elem, '{%s}String' % TNS) return self.default class MessageField(TextField): INNER_ELEMENT_NAME = 'Message' def from_xml(self, elem, account): reply = elem.find(self.response_tag()) if reply is None: return None message = reply.find('{%s}%s' % (TNS, self.INNER_ELEMENT_NAME)) if message is None: return None return message.text def to_xml(self, value, version): field_elem = create_element(self.request_tag()) message = create_element('t:%s' % self.INNER_ELEMENT_NAME) message.text = value return set_xml_value(field_elem, message, version=version) class CharField(TextField): """A field that stores a string value with a limited length""" is_complex = False def __init__(self, *args, **kwargs): self.max_length = kwargs.pop('max_length', 255) if not 1 <= self.max_length <= 255: # A field supporting messages longer than 255 chars should be TextField raise ValueError("'max_length' must be in the range 1-255") super().__init__(*args, **kwargs) def clean(self, value, version=None): value = super().clean(value, version=version) if value is not None: if self.is_list: for v in value: if len(v) > self.max_length: raise ValueError("'%s' value '%s' exceeds length %s" % (self.name, v, self.max_length)) else: if len(value) > self.max_length: raise ValueError("'%s' value '%s' exceeds length %s" % (self.name, value, self.max_length)) return value class IdField(CharField): """A field to hold the 'Id' and 'Changekey' attributes on 'ItemId' type items. There is no guaranteed max length, but we can assume 512 bytes in practice. See https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/ews-identifiers-in-exchange """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.max_length = 512 # This is above the normal 255 limit, but this is actually an attribute, not a field self.is_searchable = False self.is_attribute = True class CharListField(CharField): is_list = True def list_elem_tag(self): return '{%s}String' % self.namespace def from_xml(self, elem, account): iter_elem = elem.find(self.response_tag()) if iter_elem is not None: return get_xml_attrs(iter_elem, self.list_elem_tag()) return self.default class URIField(TextField): """Helper to mark strings that must conform to xsd:anyURI If we want an URI validator, see http://stackoverflow.com/questions/14466585/is-this-regex-correct-for-xsdanyuri """ pass class EmailAddressField(CharField): """A helper class used for email address string that we can use for email validation""" pass class CultureField(CharField): """Helper to mark strings that are # RFC 1766 culture values.""" pass class Choice: """Implements versioned choices for the ChoiceField field""" def __init__(self, value, supported_from=None): self.value = value self.supported_from = supported_from def supports_version(self, version): # 'version' is a Version instance, for convenience by callers if not isinstance(version, Version): raise ValueError("'version' %r must be a Version instance" % version) if not self.supported_from: return True return version.build >= self.supported_from class ChoiceField(CharField): def __init__(self, *args, **kwargs): self.choices = kwargs.pop('choices') super().__init__(*args, **kwargs) def clean(self, value, version=None): value = super().clean(value, version=version) if value is None: return None valid_choices = list(c.value for c in self.choices) if version: valid_choices_for_version = self.supported_choices(version=version) if value in valid_choices_for_version: return value if value in valid_choices: raise ErrorInvalidServerVersion("Choice '%s' only supports EWS builds from %s to %s (server has %s)" % ( self.name, self.supported_from or '*', self.deprecated_from or '*', version)) else: if value in valid_choices: return value raise ValueError("Invalid choice '%s' for field '%s'. Valid choices are: %s" % ( value, self.name, ', '.join(valid_choices) )) def supported_choices(self, version): return list(c.value for c in self.choices if c.supports_version(version)) FREE_BUSY_CHOICES = [Choice('Free'), Choice('Tentative'), Choice('Busy'), Choice('OOF'), Choice('NoData'), Choice('WorkingElsewhere', supported_from=EXCHANGE_2013)] class FreeBusyStatusField(ChoiceField): def __init__(self, *args, **kwargs): kwargs['choices'] = set(FREE_BUSY_CHOICES) super().__init__(*args, **kwargs) class BodyField(TextField): def __init__(self, *args, **kwargs): from .properties import Body self.value_cls = Body super().__init__(*args, **kwargs) def clean(self, value, version=None): if value is not None and not isinstance(value, self.value_cls): value = self.value_cls(value) return super().clean(value, version=version) def from_xml(self, elem, account): from .properties import Body, HTMLBody field_elem = elem.find(self.response_tag()) val = None if field_elem is None else field_elem.text or None if val is not None: body_type = field_elem.get('BodyType') return { Body.body_type: Body, HTMLBody.body_type: HTMLBody, }[body_type](val) return self.default def to_xml(self, value, version): from .properties import Body, HTMLBody field_elem = create_element(self.request_tag()) body_type = { Body: Body.body_type, HTMLBody: HTMLBody.body_type, }[type(value)] field_elem.set('BodyType', body_type) return set_xml_value(field_elem, value, version=version) class EWSElementField(FieldURIField): def __init__(self, *args, **kwargs): self.value_cls = kwargs.pop('value_cls') kwargs['namespace'] = kwargs.get('namespace', self.value_cls.NAMESPACE) super().__init__(*args, **kwargs) def from_xml(self, elem, account): if self.is_list: iter_elem = elem.find(self.response_tag()) if iter_elem is not None: return [self.value_cls.from_xml(elem=e, account=account) for e in iter_elem.findall(self.value_cls.response_tag())] else: if self.field_uri is None: sub_elem = elem.find(self.value_cls.response_tag()) else: sub_elem = elem.find(self.response_tag()) if sub_elem is not None: return self.value_cls.from_xml(elem=sub_elem, account=account) return self.default def to_xml(self, value, version): if self.field_uri is None: return value.to_xml(version=version) field_elem = create_element(self.request_tag()) return set_xml_value(field_elem, value, version=version) class EWSElementListField(EWSElementField): is_list = True is_complex = True class AssociatedCalendarItemIdField(EWSElementField): is_complex = True def __init__(self, *args, **kwargs): from .properties import AssociatedCalendarItemId kwargs['value_cls'] = AssociatedCalendarItemId super().__init__(*args, **kwargs) def to_xml(self, value, version): return value.to_xml(version=version) class RecurrenceField(EWSElementField): is_complex = True def __init__(self, *args, **kwargs): from .recurrence import Recurrence kwargs['value_cls'] = Recurrence super().__init__(*args, **kwargs) def to_xml(self, value, version): return value.to_xml(version=version) class ReferenceItemIdField(EWSElementField): is_complex = True def __init__(self, *args, **kwargs): from .properties import ReferenceItemId kwargs['value_cls'] = ReferenceItemId super().__init__(*args, **kwargs) def to_xml(self, value, version): return value.to_xml(version=version) class OccurrenceField(EWSElementField): is_complex = True class OccurrenceListField(OccurrenceField): is_list = True class MessageHeaderField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import MessageHeader kwargs['value_cls'] = MessageHeader super().__init__(*args, **kwargs) class BaseEmailField(EWSElementField): """A base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for""" is_complex = True # FindItem only returns the name, not the email address def clean(self, value, version=None): if isinstance(value, str): value = self.value_cls(email_address=value) return super().clean(value, version=version) def from_xml(self, elem, account): if self.field_uri is None: sub_elem = elem.find(self.value_cls.response_tag()) else: sub_elem = elem.find(self.response_tag()) if sub_elem is not None: if self.field_uri is not None: # We want the nested Mailbox, not the wrapper element nested_elem = sub_elem.find(self.value_cls.response_tag()) if nested_elem is None: raise ValueError( 'Expected XML element %r missing on field %r' % (self.value_cls.response_tag(), self.name) ) return self.value_cls.from_xml(elem=nested_elem, account=account) return self.value_cls.from_xml(elem=sub_elem, account=account) return self.default class EmailField(BaseEmailField): def __init__(self, *args, **kwargs): from .properties import Email kwargs['value_cls'] = Email super().__init__(*args, **kwargs) class RecipientAddressField(BaseEmailField): def __init__(self, *args, **kwargs): from .properties import RecipientAddress kwargs['value_cls'] = RecipientAddress super().__init__(*args, **kwargs) class MailboxField(BaseEmailField): def __init__(self, *args, **kwargs): from .properties import Mailbox kwargs['value_cls'] = Mailbox super().__init__(*args, **kwargs) class MailboxListField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import Mailbox kwargs['value_cls'] = Mailbox super().__init__(*args, **kwargs) def clean(self, value, version=None): if value is not None: value = [self.value_cls(email_address=s) if isinstance(s, str) else s for s in value] return super().clean(value, version=version) class MemberListField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import Member kwargs['value_cls'] = Member super().__init__(*args, **kwargs) def clean(self, value, version=None): if value is not None: from .properties import Mailbox value = [ self.value_cls(mailbox=Mailbox(email_address=s)) if isinstance(s, str) else s for s in value ] return super().clean(value, version=version) class AttendeesField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import Attendee kwargs['value_cls'] = Attendee super().__init__(*args, **kwargs) def clean(self, value, version=None): from .properties import Mailbox if value is not None: value = [self.value_cls(mailbox=Mailbox(email_address=s), response_type='Accept') if isinstance(s, str) else s for s in value] return super().clean(value, version=version) class AttachmentField(EWSElementListField): def __init__(self, *args, **kwargs): from .attachments import Attachment kwargs['value_cls'] = Attachment super().__init__(*args, **kwargs) def from_xml(self, elem, account): from .attachments import FileAttachment, ItemAttachment iter_elem = elem.find(self.response_tag()) # Look for both FileAttachment and ItemAttachment if iter_elem is not None: attachments = [] for att_type in (ItemAttachment, FileAttachment): attachments.extend( [att_type.from_xml(elem=e, account=account) for e in iter_elem.findall(att_type.response_tag())] ) return attachments return self.default class LabelField(ChoiceField): """A field to hold the label on an IndexedElement""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.is_attribute = True def from_xml(self, elem, account): return elem.get(self.field_uri) class SubField(Field): namespace = TNS # A field to hold the value on an SingleFieldIndexedElement value_cls = str def from_xml(self, elem, account): return elem.text def to_xml(self, value, version): return value @staticmethod def field_uri_xml(field_uri, label): return create_element( 't:IndexedFieldURI', attrs=OrderedDict([ ('FieldURI', field_uri), ('FieldIndex', label), ]) ) def __hash__(self): return hash(self.name) class EmailSubField(SubField): """A field to hold the value on an SingleFieldIndexedElement""" value_cls = str def from_xml(self, elem, account): return elem.text or elem.get('Name') # Sometimes elem.text is empty. Exchange saves the same in 'Name' attr class NamedSubField(SubField): """A field to hold the value on an MultiFieldIndexedElement""" value_cls = str def __init__(self, *args, **kwargs): self.field_uri = kwargs.pop('field_uri') if ':' in self.field_uri: raise ValueError("'field_uri' value must not contain a colon") super().__init__(*args, **kwargs) def from_xml(self, elem, account): field_elem = elem.find(self.response_tag()) val = None if field_elem is None else field_elem.text or None if val is not None: return val return self.default def to_xml(self, value, version): field_elem = create_element(self.request_tag()) return set_xml_value(field_elem, value, version=version) def field_uri_xml(self, field_uri, label): return create_element( 't:IndexedFieldURI', attrs=OrderedDict([ ('FieldURI', '%s:%s' % (field_uri, self.field_uri)), ('FieldIndex', label), ]) ) def request_tag(self): return 't:%s' % self.field_uri def response_tag(self): return '{%s}%s' % (self.namespace, self.field_uri) class IndexedField(EWSElementField): PARENT_ELEMENT_NAME = None def to_xml(self, value, version): return set_xml_value(create_element('t:%s' % self.PARENT_ELEMENT_NAME), value, version) def field_uri_xml(self): # Callers must call field_uri_xml() on the subfield raise NotImplementedError() def response_tag(self): return '{%s}%s' % (self.namespace, self.PARENT_ELEMENT_NAME) def __hash__(self): return hash(self.field_uri) class EmailAddressesField(IndexedField): is_list = True PARENT_ELEMENT_NAME = 'EmailAddresses' def __init__(self, *args, **kwargs): from .indexed_properties import EmailAddress kwargs['value_cls'] = EmailAddress super().__init__(*args, **kwargs) def field_uri_xml(self): raise NotImplementedError() class PhoneNumberField(IndexedField): is_list = True PARENT_ELEMENT_NAME = 'PhoneNumbers' def __init__(self, *args, **kwargs): from .indexed_properties import PhoneNumber kwargs['value_cls'] = PhoneNumber super().__init__(*args, **kwargs) def field_uri_xml(self): raise NotImplementedError() class PhysicalAddressField(IndexedField): is_list = True PARENT_ELEMENT_NAME = 'PhysicalAddresses' def __init__(self, *args, **kwargs): from .indexed_properties import PhysicalAddress kwargs['value_cls'] = PhysicalAddress super().__init__(*args, **kwargs) def field_uri_xml(self): raise NotImplementedError() class ExtendedPropertyField(Field): def __init__(self, *args, **kwargs): self.value_cls = kwargs.pop('value_cls') super().__init__(*args, **kwargs) def clean(self, value, version=None): if value is None: if self.is_required: raise ValueError("'%s' is a required field" % self.name) return self.default elif not isinstance(value, self.value_cls): # Allow keeping ExtendedProperty field values as their simple Python type, but run clean() anyway tmp = self.value_cls(value) tmp.clean(version=version) return value value.clean(version=version) return value def field_uri_xml(self): elem = create_element('t:ExtendedFieldURI') cls = self.value_cls if cls.distinguished_property_set_id: elem.set('DistinguishedPropertySetId', cls.distinguished_property_set_id) if cls.property_set_id: elem.set('PropertySetId', cls.property_set_id) if cls.property_tag: elem.set('PropertyTag', cls.property_tag_as_hex()) if cls.property_name: elem.set('PropertyName', cls.property_name) if cls.property_id: elem.set('PropertyId', value_to_xml_text(cls.property_id)) elem.set('PropertyType', cls.property_type) return elem def from_xml(self, elem, account): extended_properties = elem.findall(self.value_cls.response_tag()) for extended_property in extended_properties: if self.value_cls.is_property_instance(extended_property): return self.value_cls.from_xml(elem=extended_property, account=account) return self.default def to_xml(self, value, version): extended_property = create_element(self.value_cls.request_tag()) set_xml_value(extended_property, self.field_uri_xml(), version=version) if isinstance(value, self.value_cls): set_xml_value(extended_property, value, version=version) else: # Allow keeping ExtendedProperty field values as their simple Python type set_xml_value(extended_property, self.value_cls(value), version=version) return extended_property def __hash__(self): return hash(self.name) class ItemField(FieldURIField): @property def value_cls(self): # This is a workaround for circular imports. Item from .items import Item return Item def from_xml(self, elem, account): from .items import ITEM_CLASSES for item_cls in ITEM_CLASSES: item_elem = elem.find(item_cls.response_tag()) if item_elem is not None: return item_cls.from_xml(elem=item_elem, account=account) return None def to_xml(self, value, version): # We don't want to wrap in an Item element return value.to_xml(version=version) class UnknownEntriesField(CharListField): def list_elem_tag(self): return '{%s}UnknownEntry' % self.namespace class PermissionSetField(EWSElementField): is_complex = True def __init__(self, *args, **kwargs): from .properties import PermissionSet kwargs['value_cls'] = PermissionSet super().__init__(*args, **kwargs) class EffectiveRightsField(EWSElementField): def __init__(self, *args, **kwargs): from .properties import EffectiveRights kwargs['value_cls'] = EffectiveRights super().__init__(*args, **kwargs) class BuildField(CharField): def __init__(self, *args, **kwargs): from .version import Build super().__init__(*args, **kwargs) self.value_cls = Build def from_xml(self, elem, account): val = super().from_xml(elem=elem, account=account) if val: try: return self.value_cls.from_hex_string(val) except (TypeError, ValueError): log.warning('Invalid server version string: %r', val) return val class ProtocolListField(EWSElementListField): # There is not containing element for this field. Just multiple 'Protocol' elements on the 'Account' element. def __init__(self, *args, **kwargs): from .autodiscover.properties import Protocol kwargs['value_cls'] = Protocol super().__init__(*args, **kwargs) def from_xml(self, elem, account): return [self.value_cls.from_xml(elem=e, account=account) for e in elem.findall(self.value_cls.response_tag())] exchangelib-3.1.1/exchangelib/folders/000077500000000000000000000000001361226005600176755ustar00rootroot00000000000000exchangelib-3.1.1/exchangelib/folders/__init__.py000066400000000000000000000062071361226005600220130ustar00rootroot00000000000000from ..properties import FolderId, DistinguishedFolderId from .base import BaseFolder, Folder from .collections import FolderCollection from .known_folders import AdminAuditLogs, AllContacts, AllItems, ArchiveDeletedItems, ArchiveInbox, \ ArchiveMsgFolderRoot, ArchiveRecoverableItemsDeletions, ArchiveRecoverableItemsPurges, \ ArchiveRecoverableItemsRoot, ArchiveRecoverableItemsVersions, Audits, Calendar, CalendarLogging, CommonViews, \ Conflicts, Contacts, ConversationHistory, ConversationSettings, DefaultFoldersChangeHistory, DeferredAction, \ DeletedItems, Directory, Drafts, ExchangeSyncData, Favorites, Files, FreebusyData, Friends, GALContacts, \ GraphAnalytics, IMContactList, Inbox, Journal, JunkEmail, LocalFailures, Location, MailboxAssociations, Messages, \ MsgFolderRoot, MyContacts, MyContactsExtended, NonDeleteableFolderMixin, Notes, Outbox, ParkedMessages, \ PassThroughSearchResults, PdpProfileV2Secured, PeopleConnect, QuickContacts, RSSFeeds, RecipientCache, \ RecoverableItemsDeletions, RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsVersions, Reminders, \ Schedule, SearchFolders, SentItems, ServerFailures, Sharing, Shortcuts, Signal, SmsAndChatsSync, SpoolerQueue, \ SyncIssues, System, Tasks, TemporarySaves, ToDoSearch, Views, VoiceMail, WellknownFolder, WorkingSet, \ NON_DELETEABLE_FOLDERS from .queryset import FolderQuerySet, SingleFolderQuerySet, FOLDER_TRAVERSAL_CHOICES, SHALLOW, DEEP, SOFT_DELETED from .roots import Root, ArchiveRoot, PublicFoldersRoot, RootOfHierarchy __all__ = [ 'FolderId', 'DistinguishedFolderId', 'FolderCollection', 'BaseFolder', 'Folder', 'AdminAuditLogs', 'AllContacts', 'AllItems', 'ArchiveDeletedItems', 'ArchiveInbox', 'ArchiveMsgFolderRoot', 'ArchiveRecoverableItemsDeletions', 'ArchiveRecoverableItemsPurges', 'ArchiveRecoverableItemsRoot', 'ArchiveRecoverableItemsVersions', 'Audits', 'Calendar', 'CalendarLogging', 'CommonViews', 'Conflicts', 'Contacts', 'ConversationHistory', 'ConversationSettings', 'DefaultFoldersChangeHistory', 'DeferredAction', 'DeletedItems', 'Directory', 'Drafts', 'ExchangeSyncData', 'Favorites', 'Files', 'FreebusyData', 'Friends', 'GALContacts', 'GraphAnalytics', 'IMContactList', 'Inbox', 'Journal', 'JunkEmail', 'LocalFailures', 'Location', 'MailboxAssociations', 'Messages', 'MsgFolderRoot', 'MyContacts', 'MyContactsExtended', 'NonDeleteableFolderMixin', 'Notes', 'Outbox', 'ParkedMessages', 'PassThroughSearchResults', 'PdpProfileV2Secured', 'PeopleConnect', 'QuickContacts', 'RSSFeeds', 'RecipientCache', 'RecoverableItemsDeletions', 'RecoverableItemsPurges', 'RecoverableItemsRoot', 'RecoverableItemsVersions', 'Reminders', 'Schedule', 'SearchFolders', 'SentItems', 'ServerFailures', 'Sharing', 'Shortcuts', 'Signal', 'SmsAndChatsSync', 'SpoolerQueue', 'SyncIssues', 'System', 'Tasks', 'TemporarySaves', 'ToDoSearch', 'Views', 'VoiceMail', 'WellknownFolder', 'WorkingSet', 'NON_DELETEABLE_FOLDERS', 'FolderQuerySet', 'SingleFolderQuerySet', 'FOLDER_TRAVERSAL_CHOICES', 'SHALLOW', 'DEEP', 'SOFT_DELETED', 'Root', 'ArchiveRoot', 'PublicFoldersRoot', 'RootOfHierarchy', ] exchangelib-3.1.1/exchangelib/folders/base.py000066400000000000000000000751071361226005600211730ustar00rootroot00000000000000from fnmatch import fnmatch import logging from operator import attrgetter from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorCannotEmptyFolder, ErrorCannotDeleteObject, \ ErrorDeleteDistinguishedFolder from ..fields import IntegerField, CharField, FieldPath, EffectiveRightsField, PermissionSetField, EWSElementField, \ Field from ..items import CalendarItem, RegisterMixIn, Persona, ITEM_CLASSES, ITEM_TRAVERSAL_CHOICES, SHAPE_CHOICES, \ ID_ONLY, DELETE_TYPE_CHOICES, HARD_DELETE, SHALLOW as SHALLOW_ITEMS from ..properties import Mailbox, FolderId, ParentFolderId, InvalidField, DistinguishedFolderId from ..queryset import QuerySet, SearchableMixIn, DoesNotExist from ..restriction import Restriction from ..services import CreateFolder, UpdateFolder, DeleteFolder, EmptyFolder, FindPeople from ..util import TNS from ..version import Version, EXCHANGE_2007_SP1, EXCHANGE_2010 from .collections import FolderCollection from .queryset import SingleFolderQuerySet, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS log = logging.getLogger(__name__) class BaseFolder(RegisterMixIn, SearchableMixIn): """Base class for all classes that implement a folder""" ELEMENT_NAME = 'Folder' NAMESPACE = TNS # See https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid DISTINGUISHED_FOLDER_ID = None # Default item type for this folder. See # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxosfld/68a85898-84fe-43c4-b166-4711c13cdd61 CONTAINER_CLASS = None supported_item_models = ITEM_CLASSES # The Item types that this folder can contain. Default is all # Marks the version from which a distinguished folder was introduced. A possibly authoritative source is: # https://github.com/OfficeDev/ews-managed-api/blob/master/Enumerations/WellKnownFolderName.cs supported_from = None # Whether this folder type is allowed with the GetFolder service get_folder_allowed = True DEFAULT_FOLDER_TRAVERSAL_DEPTH = DEEP_FOLDERS DEFAULT_ITEM_TRAVERSAL_DEPTH = SHALLOW_ITEMS LOCALIZED_NAMES = dict() # A map of (str)locale: (tuple)localized_folder_names ITEM_MODEL_MAP = {cls.response_tag(): cls for cls in ITEM_CLASSES} ID_ELEMENT_CLS = FolderId LOCAL_FIELDS = [ EWSElementField('parent_folder_id', field_uri='folder:ParentFolderId', value_cls=ParentFolderId, is_read_only=True), CharField('folder_class', field_uri='folder:FolderClass', is_required_after_save=True), CharField('name', field_uri='folder:DisplayName'), IntegerField('total_count', field_uri='folder:TotalCount', is_read_only=True), IntegerField('child_folder_count', field_uri='folder:ChildFolderCount', is_read_only=True), IntegerField('unread_count', field_uri='folder:UnreadCount', is_read_only=True), ] FIELDS = RegisterMixIn.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) + ('is_distinguished',) # Used to register extended properties INSERT_AFTER_FIELD = 'child_folder_count' def __init__(self, **kwargs): self.is_distinguished = kwargs.pop('is_distinguished', False) super().__init__(**kwargs) @property def account(self): raise NotImplementedError() @property def root(self): raise NotImplementedError() @property def parent(self): raise NotImplementedError() @property def is_deleteable(self): return not self.is_distinguished def clean(self, version=None): # pylint: disable=access-member-before-definition super().clean(version=version) # Set a default folder class for new folders. A folder class cannot be changed after saving. if self.id is None and self.folder_class is None: self.folder_class = self.CONTAINER_CLASS @property def children(self): # It's dangerous to return a generator here because we may then call methods on a child that result in the # cache being updated while it's iterated. return FolderCollection(account=self.account, folders=self.root.get_children(self)) @property def parts(self): parts = [self] f = self.parent while f: parts.insert(0, f) f = f.parent return parts @property def absolute(self): return ''.join('/%s' % p.name for p in self.parts) def _walk(self): for c in self.children: yield c for f in c.walk(): yield f def walk(self): return FolderCollection(account=self.account, folders=self._walk()) def _glob(self, pattern): split_pattern = pattern.rsplit('/', 1) head, tail = (split_pattern[0], None) if len(split_pattern) == 1 else split_pattern if head == '': # We got an absolute path. Restart globbing at root for f in self.root.glob(tail or '*'): yield f elif head == '..': # Relative path with reference to parent. Restart globbing at parent if not self.parent: raise ValueError('Already at top') for f in self.parent.glob(tail or '*'): yield f elif head == '**': # Match anything here or in any subfolder at arbitrary depth for c in self.walk(): if fnmatch(c.name, tail or '*'): yield c else: # Regular pattern for c in self.children: if not fnmatch(c.name, head): continue if tail is None: yield c continue for f in c.glob(tail): yield f def glob(self, pattern): return FolderCollection(account=self.account, folders=self._glob(pattern)) def tree(self): """ Returns a string representation of the folder structure of this folder. Example: root ├── inbox │ └── todos └── archive ├── Last Job ├── exchangelib issues └── Mom """ tree = '%s\n' % self.name children = list(self.children) for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): nodes = c.tree().split('\n') for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child tree += '├── %s\n' % node elif i != len(children) and j > 1: # Not the last child, and not name of child tree += '│ %s\n' % node elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child tree += '└── %s\n' % node else: # Last child, and not name of child tree += ' %s\n' % node return tree.strip() @classmethod def supports_version(cls, version): # 'version' is a Version instance, for convenience by callers if not isinstance(version, Version): raise ValueError("'version' %r must be a Version instance" % version) if not cls.supported_from: return True return version.build >= cls.supported_from @property def has_distinguished_name(self): return self.name and self.DISTINGUISHED_FOLDER_ID and self.name.lower() == self.DISTINGUISHED_FOLDER_ID.lower() @classmethod def localized_names(cls, locale): # Return localized names for a specific locale. If no locale-specific names exist, return the default names, # if any. return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, []))) @staticmethod def folder_cls_from_container_class(container_class): """Returns a reasonable folder class given a container class, e.g. 'IPF.Note'. Don't iterate WELLKNOWN_FOLDERS because many folder classes have the same CONTAINER_CLASS. """ from .known_folders import Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, \ RecipientCache, RSSFeeds for folder_cls in ( Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, RecipientCache, RSSFeeds): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls raise KeyError() @classmethod def item_model_from_tag(cls, tag): try: return cls.ITEM_MODEL_MAP[tag] except KeyError: raise ValueError('Item type %s was unexpected in a %s folder' % (tag, cls.__name__)) @classmethod def allowed_item_fields(cls, version): # Return non-ID fields of all item classes allowed in this folder type fields = set() for item_model in cls.supported_item_models: fields.update( set(item_model.supported_fields(version=version)) ) return fields def validate_item_field(self, field, version): # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid # for the item types supported by this folder. # For each field, check if the field is valid for any of the item models supported by this folder for item_model in self.supported_item_models: try: item_model.validate_field(field=field, version=version) break except InvalidField: continue else: raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models)) def normalize_fields(self, fields): # Takes a list of fieldnames, Field or FieldPath objects pointing to item fields. Turns them into FieldPath # objects and adds internal timezone fields if necessary. Assume fields are already validated. fields = list(fields) has_start, has_end = False, False for i, field_path in enumerate(fields): # Allow both Field and FieldPath instances and string field paths as input if isinstance(field_path, str): field_path = FieldPath.from_string(field_path=field_path, folder=self) fields[i] = field_path elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path if not isinstance(field_path, FieldPath): raise ValueError("Field %r must be a string or FieldPath instance" % field_path) if field_path.field.name == 'start': has_start = True elif field_path.field.name == 'end': has_end = True # For CalendarItem items, we want to inject internal timezone fields. See also CalendarItem.clean() if CalendarItem in self.supported_item_models: meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.account.version.build < EXCHANGE_2010: if has_start or has_end: fields.append(FieldPath(field=meeting_tz_field)) else: if has_start: fields.append(FieldPath(field=start_tz_field)) if has_end: fields.append(FieldPath(field=end_tz_field)) return fields @classmethod def get_item_field_by_fieldname(cls, fieldname): for item_model in cls.supported_item_models: try: return item_model.get_field_by_fieldname(fieldname) except InvalidField: pass raise InvalidField("%r is not a valid field name on %s" % (fieldname, cls.supported_item_models)) def get(self, *args, **kwargs): return FolderCollection(account=self.account, folders=[self]).get(*args, **kwargs) def all(self): return FolderCollection(account=self.account, folders=[self]).all() def none(self): return FolderCollection(account=self.account, folders=[self]).none() def filter(self, *args, **kwargs): return FolderCollection(account=self.account, folders=[self]).filter(*args, **kwargs) def exclude(self, *args, **kwargs): return FolderCollection(account=self.account, folders=[self]).exclude(*args, **kwargs) def people(self): return QuerySet( folder_collection=FolderCollection(account=self.account, folders=[self]), request_type=QuerySet.PERSONA, ) def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, page_size=None, max_items=None, offset=0): """ Private method to call the FindPeople service :param q: a Q instance containing any restrictions :param shape: controls whether to return (id, chanegkey) tuples or Persona objects. If additional_fields is non-null, we always return Persona objects. :param depth: controls the whether to return soft-deleted items or not. :param additional_fields: the extra properties we want on the return objects. Default is no properties. :param order_fields: the SortOrder fields, if any :param page_size: the requested number of items per page :param max_items: the max number of items to return :param offset: the offset relative to the first item in the item collection :return: a generator for the returned personas """ if shape not in SHAPE_CHOICES: raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) if depth is None: depth = self.DEFAULT_ITEM_TRAVERSAL_DEPTH if depth not in ITEM_TRAVERSAL_CHOICES: raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) if additional_fields: for f in additional_fields: Persona.validate_field(field=f, version=self.account.version) if f.field.is_complex: raise ValueError("find_people() does not support field '%s'" % f.field.name) # Build up any restrictions if q.is_empty(): restriction = None query_string = None elif q.query_string: restriction = None query_string = Restriction(q, folders=[self], applies_to=Restriction.ITEMS) else: restriction = Restriction(q, folders=[self], applies_to=Restriction.ITEMS) query_string = None personas = FindPeople(account=self.account, chunk_size=page_size).call( folder=self, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, shape=shape, query_string=query_string, depth=depth, max_items=max_items, offset=offset, ) for p in personas: if isinstance(p, Exception): raise p yield p def bulk_create(self, items, *args, **kwargs): return self.account.bulk_create(folder=self, items=items, *args, **kwargs) def save(self, update_fields=None): if self.id is None: # New folder if update_fields: raise ValueError("'update_fields' is only valid for updates") res = list(CreateFolder(account=self.account).call(parent_folder=self.parent, folders=[self])) if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] self.id, self.changekey = res[0].id, res[0].changekey self.root.add_folder(self) # Add this folder to the cache return self # Update folder if not update_fields: # The fields to update was not specified explicitly. Update all fields where update is possible update_fields = [] for f in self.supported_fields(version=self.account.version): if f.is_read_only: # These cannot be changed continue if f.is_required or f.is_required_after_save: if getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)): # These are required and cannot be deleted continue update_fields.append(f.name) res = list(UpdateFolder(account=self.account).call(folders=[(self, update_fields)])) if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] folder_id, changekey = res[0].id, res[0].changekey if self.id != folder_id: raise ValueError('ID mismatch') # Don't check changekey value. It may not change on no-op updates self.changekey = changekey self.root.update_folder(self) # Update the folder in the cache return None def delete(self, delete_type=HARD_DELETE): if delete_type not in DELETE_TYPE_CHOICES: raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES)) res = list(DeleteFolder(account=self.account).call(folders=[self], delete_type=delete_type)) if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] self.root.remove_folder(self) # Remove the updated folder from the cache self.id, self.changekey = None, None def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): if delete_type not in DELETE_TYPE_CHOICES: raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES)) res = list(EmptyFolder(account=self.account).call( folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders) ) if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] if delete_sub_folders: # We don't know exactly what was deleted, so invalidate the entire folder cache to be safe self.root.clear_cache() def wipe(self, page_size=None): # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! log.warning('Wiping %s', self) delete_kwargs = {} if page_size: delete_kwargs['page_size'] = page_size has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: self.empty(delete_sub_folders=False) else: self.empty(delete_sub_folders=True) except (ErrorAccessDenied, ErrorCannotEmptyFolder): try: if has_distinguished_subfolders: raise # We already tried this self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder): log.warning('Not allowed to empty %s. Trying to delete items instead', self) try: self.all().delete(**delete_kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject): log.warning('Not allowed to delete items in %s', self) for f in self.children: f.wipe(page_size=page_size) # Remove non-distinguished children that are empty and have no subfolders if f.is_deleteable and not f.children: log.warning('Deleting folder %s', f) try: f.delete() except ErrorDeleteDistinguishedFolder: log.warning('Tried to delete a distinguished folder (%s)', f) def test_access(self): """ Does a simple FindItem to test (read) access to the folder. Maybe the account doesn't exist, maybe the service user doesn't have access to the calendar. This will throw the most common errors. """ self.all().exists() return True @classmethod def _kwargs_from_elem(cls, elem, account): folder_id, changekey = cls.id_from_xml(elem) kwargs = dict(id=folder_id, changekey=changekey) # Check for 'DisplayName' element before collecting kwargs because because that clears the elements has_name_elem = elem.find(cls.get_field_by_fieldname('name').response_tag()) is not None kwargs.update({ f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS if f.name not in ('id', 'changekey') }) if has_name_elem and not kwargs['name']: # When we request the 'DisplayName' property, some folders may still be returned with an empty value. # Assign a default name to these folders. kwargs['name'] = cls.DISTINGUISHED_FOLDER_ID return kwargs def to_xml(self, version): if self.is_distinguished: # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed # the folder content since we fetched the changekey. if self.account: return DistinguishedFolderId( id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) ).to_xml(version=version) return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID).to_xml(version=version) if self.id: return FolderId(id=self.id, changekey=self.changekey).to_xml(version=version) return super().to_xml(version=version) @classmethod def resolve(cls, account, folder): # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: raise ErrorFolderNotFound('Could not find folder %r' % folder) if len(folders) != 1: raise ValueError('Expected result length 1, but got %s' % folders) f = folders[0] if isinstance(f, Exception): raise f if f.__class__ != cls: raise ValueError("Expected folder %r to be a %s instance" % (f, cls)) return f def refresh(self): if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) if not self.id: raise ValueError('%s must have an ID' % self.__class__.__name__) fresh_folder = self.resolve(account=self.account, folder=self) if self.id != fresh_folder.id: raise ValueError('ID mismatch') # Apparently, the changekey may get updated for f in self.FIELDS: setattr(self, f.name, getattr(fresh_folder, f.name)) def __floordiv__(self, other): """Same as __truediv__ but does not touch the folder cache. This is useful if the folder hierarchy contains a huge number of folders and you don't want to fetch them all""" if other == '..': raise ValueError('Cannot get parent without a folder cache') if other == '.': return self # Assume an exact match on the folder name in a shallow search will only return at most one folder try: return SingleFolderQuerySet(account=self.account, folder=self).depth(SHALLOW_FOLDERS).get(name=other) except DoesNotExist: raise ErrorFolderNotFound("No subfolder with name '%s'" % other) def __truediv__(self, other): # Support the some_folder / 'child_folder' / 'child_of_child_folder' navigation syntax if other == '..': if not self.parent: raise ValueError('Already at top') return self.parent if other == '.': return self for c in self.children: if c.name == other: return c raise ErrorFolderNotFound("No subfolder with name '%s'" % other) def __repr__(self): return self.__class__.__name__ + \ repr((self.root, self.name, self.total_count, self.unread_count, self.child_folder_count, self.folder_class, self.id, self.changekey)) def __str__(self): return '%s (%s)' % (self.__class__.__name__, self.name) class Folder(BaseFolder): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folder""" LOCAL_FIELDS = [ PermissionSetField('permission_set', field_uri='folder:PermissionSet', supported_from=EXCHANGE_2007_SP1), EffectiveRightsField('effective_rights', field_uri='folder:EffectiveRights', is_read_only=True, supported_from=EXCHANGE_2007_SP1), ] FIELDS = BaseFolder.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) + ('_root',) def __init__(self, **kwargs): self._root = kwargs.pop('root', None) # This is a pointer to the root of the folder hierarchy parent = kwargs.pop('parent', None) if parent: if self.root: if parent.root != self.root: raise ValueError("'parent.root' must match 'root'") else: self.root = parent.root if 'parent_folder_id' in kwargs: if parent.id != kwargs['parent_folder_id']: raise ValueError("'parent_folder_id' must match 'parent' ID") kwargs['parent_folder_id'] = ParentFolderId(id=parent.id, changekey=parent.changekey) super().__init__(**kwargs) @property def account(self): if self.root is None: return None return self.root.account @property def root(self): return self._root @root.setter def root(self, value): self._root = value @classmethod def register(cls, *args, **kwargs): if cls is not Folder: raise TypeError('For folders, custom fields must be registered on the Folder class') return super().register(*args, **kwargs) @classmethod def deregister(cls, *args, **kwargs): if cls is not Folder: raise TypeError('For folders, custom fields must be registered on the Folder class') return super().deregister(*args, **kwargs) @classmethod def get_distinguished(cls, root): """Gets the distinguished folder for this folder class""" try: return cls.resolve( account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except ErrorFolderNotFound: raise ErrorFolderNotFound('Could not find distinguished folder %r' % cls.DISTINGUISHED_FOLDER_ID) @property def parent(self): if not self.parent_folder_id: return None if self.parent_folder_id.id == self.id: # Some folders have a parent that references itself. Avoid circular references here return None return self.root.get_folder(self.parent_folder_id.id) @parent.setter def parent(self, value): if value is None: self.parent_folder_id = None else: if not isinstance(value, BaseFolder): raise ValueError("'value' %r must be a Folder instance" % value) self.root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) def clean(self, version=None): # pylint: disable=access-member-before-definition from .roots import RootOfHierarchy super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): raise ValueError("'root' %r must be a RootOfHierarchy instance" % self.root) @classmethod def from_xml(cls, elem, account): raise NotImplementedError('Use from_xml_with_root() instead') @classmethod def from_xml_with_root(cls, elem, root): kwargs = cls._kwargs_from_elem(elem=elem, account=root.account) cls._clear(elem) folder_cls = cls if cls == Folder: # We were called on the generic Folder class. Try to find a more specific class to return objects as. # # The "FolderClass" element value is the only indication we have in the FindFolder response of which # folder class we should create the folder with. And many folders share the same 'FolderClass' value, e.g. # Inbox and DeletedItems. We want to distinguish between these because otherwise we can't locate the right # folders types for e.g. Account.inbox and Account.trash. # # We should be able to just use the name, but apparently default folder names can be renamed to a set of # localized names using a PowerShell command: # https://docs.microsoft.com/en-us/powershell/module/exchange/client-access/Set-MailboxRegionalConfiguration # # Instead, search for a folder class using the localized name. If none are found, fall back to getting the # folder class by the "FolderClass" value. # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. if kwargs['name']: try: # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name(folder_name=kwargs['name'], locale=root.account.locale) log.debug('Folder class %s matches localized folder name %s', folder_cls, kwargs['name']) except KeyError: pass if kwargs['folder_class'] and folder_cls == Folder: try: folder_cls = cls.folder_cls_from_container_class(container_class=kwargs['folder_class']) log.debug('Folder class %s matches container class %s (%s)', folder_cls, kwargs['folder_class'], kwargs['name']) except KeyError: pass if folder_cls == Folder: log.debug('Fallback to class Folder (folder_class %s, name %s)', kwargs['folder_class'], kwargs['name']) return folder_cls(root=root, **kwargs) exchangelib-3.1.1/exchangelib/folders/collections.py000066400000000000000000000345771361226005600226050ustar00rootroot00000000000000import logging from cached_property import threaded_cached_property from ..fields import FieldPath from ..items import Item, ITEM_TRAVERSAL_CHOICES, SHAPE_CHOICES, ID_ONLY from ..properties import CalendarView, InvalidField from ..queryset import QuerySet, SearchableMixIn from ..restriction import Restriction from ..services import FindFolder, GetFolder, FindItem from .queryset import FOLDER_TRAVERSAL_CHOICES log = logging.getLogger(__name__) class FolderCollection(SearchableMixIn): """A class that implements an API for searching folders""" # These fields are required in a FindFolder or GetFolder call to properly identify folder types REQUIRED_FOLDER_FIELDS = ('name', 'folder_class') def __init__(self, account, folders): """ Implements a search API on a collection of folders :param account: An Account object :param folders: An iterable of folders, e.g. Folder.walk(), Folder.glob(), or [a.calendar, a.inbox] """ self.account = account self._folders = folders @threaded_cached_property def folders(self): # Resolve the list of folders, in case it's a generator return list(self._folders) def __len__(self): return len(self.folders) def __iter__(self): for f in self.folders: yield f def get(self, *args, **kwargs): return QuerySet(self).get(*args, **kwargs) def all(self): return QuerySet(self).all() def none(self): return QuerySet(self).none() def filter(self, *args, **kwargs): """ Finds items in the folder(s). Non-keyword args may be a list of Q instances. Optional extra keyword arguments follow a Django-like QuerySet filter syntax (see https://docs.djangoproject.com/en/1.10/ref/models/querysets/#field-lookups). We don't support '__year' and other date-related lookups. We also don't support '__endswith' or '__iendswith'. We support the additional '__not' lookup in place of Django's exclude() for simple cases. For more complicated cases you need to create a Q object and use ~Q(). Examples: my_account.inbox.filter(datetime_received__gt=EWSDateTime(2016, 1, 1)) my_account.calendar.filter(start__range=(EWSDateTime(2016, 1, 1), EWSDateTime(2017, 1, 1))) my_account.tasks.filter(subject='Hi mom') my_account.tasks.filter(subject__not='Hi mom') my_account.tasks.filter(subject__contains='Foo') my_account.tasks.filter(subject__icontains='foo') 'endswith' and 'iendswith' could be emulated by searching with 'contains' or 'icontains' and then post-processing items. Fetch the field in question with additional_fields and remove items where the search string is not a postfix. """ return QuerySet(self).filter(*args, **kwargs) def exclude(self, *args, **kwargs): return QuerySet(self).exclude(*args, **kwargs) def view(self, start, end, max_items=None, *args, **kwargs): """ Implements the CalendarView option to FindItem. The difference between filter() and view() is that filter() only returns the master CalendarItem for recurring items, while view() unfolds recurring items and returns all CalendarItem occurrences as one would normally expect when presenting a calendar. Supports the same semantics as filter, except for 'start' and 'end' keyword attributes which are both required and behave differently than filter. Here, they denote the start and end of the timespan of the view. All items the overlap the timespan are returned (items that end exactly on 'start' are also returned, for some reason). EWS does not allow combining CalendarView with search restrictions (filter and exclude). 'max_items' defines the maximum number of items returned in this view. Optional. """ qs = QuerySet(self).filter(*args, **kwargs) qs.calendar_view = CalendarView(start=start, end=end, max_items=max_items) return qs def allowed_item_fields(self): # Return non-ID fields of all item classes allowed in this folder type fields = set() for item_model in self.supported_item_models: fields.update(set(item_model.supported_fields(version=self.account.version))) return fields @property def supported_item_models(self): return tuple(item_model for folder in self.folders for item_model in folder.supported_item_models) def validate_item_field(self, field, version): # For each field, check if the field is valid for any of the item models supported by this folder for item_model in self.supported_item_models: try: item_model.validate_field(field=field, version=version) break except InvalidField: continue else: raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models)) def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, calendar_view=None, page_size=None, max_items=None, offset=0): """ Private method to call the FindItem service :param q: a Q instance containing any restrictions :param shape: controls whether to return (id, chanegkey) tuples or Item objects. If additional_fields is non-null, we always return Item objects. :param depth: controls the whether to return soft-deleted items or not. :param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware that complex fields can only be fetched with fetch() (i.e. the GetItem service). :param order_fields: the SortOrder fields, if any :param calendar_view: a CalendarView instance, if any :param page_size: the requested number of items per page :param max_items: the max number of items to return :param offset: the offset relative to the first item in the item collection :return: a generator for the returned item IDs or items """ from .base import BaseFolder if not self.folders: log.debug('Folder list is empty') return if shape not in SHAPE_CHOICES: raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) if depth is None: depth = self._get_default_item_traversal_depth() if depth not in ITEM_TRAVERSAL_CHOICES: raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) if additional_fields: for f in additional_fields: self.validate_item_field(field=f, version=self.account.version) if f.field.is_complex: raise ValueError("find_items() does not support field '%s'. Use fetch() instead" % f.field.name) if calendar_view is not None and not isinstance(calendar_view, CalendarView): raise ValueError("'calendar_view' %s must be a CalendarView instance" % calendar_view) # Build up any restrictions if q.is_empty(): restriction = None query_string = None elif q.query_string: restriction = None query_string = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) else: restriction = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', self.folders, self.account, shape, depth, additional_fields, restriction.q if restriction else None, ) items = FindItem(account=self.account, folders=self.folders, chunk_size=page_size).call( additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, shape=shape, query_string=query_string, depth=depth, calendar_view=calendar_view, max_items=calendar_view.max_items if calendar_view else max_items, offset=offset, ) if shape == ID_ONLY and additional_fields is None: for i in items: yield i if isinstance(i, Exception) else Item.id_from_xml(i) else: for i in items: if isinstance(i, Exception): yield i else: yield BaseFolder.item_model_from_tag(i.tag).from_xml(elem=i, account=self.account) def get_folder_fields(self, target_cls, is_complex=None): return { FieldPath(field=f) for f in target_cls.supported_fields(version=self.account.version) if is_complex is None or f.is_complex is is_complex } def _get_target_cls(self): # We may have root folders that don't support the same set of fields as normal folders. If there is a mix of # both folder types in self.folders, raise an error so we don't risk losing some fields in the query. from .base import Folder from .roots import RootOfHierarchy has_roots = False has_non_roots = False for f in self.folders: if isinstance(f, RootOfHierarchy): if has_non_roots: raise ValueError('Cannot call GetFolder on a mix of folder types: {}'.format(self.folders)) has_roots = True else: if has_roots: raise ValueError('Cannot call GetFolder on a mix of folder types: {}'.format(self.folders)) has_non_roots = True return RootOfHierarchy if has_roots else Folder def _get_default_item_traversal_depth(self): # When searching folders, some folders require 'Shallow' and others 'Associated' traversal depth. unique_depths = set(f.DEFAULT_ITEM_TRAVERSAL_DEPTH for f in self.folders) if len(unique_depths) == 1: return unique_depths.pop() raise ValueError( 'Folders in this collection do not have a common DEFAULT_ITEM_TRAVERSAL_DEPTH value. You need to ' 'define an explicit traversal depth with QuerySet.depth() (values: %s)' % unique_depths ) def _get_default_folder_traversal_depth(self): # When searching folders, some folders require 'Shallow' and others 'Deep' traversal depth. unique_depths = set(f.DEFAULT_FOLDER_TRAVERSAL_DEPTH for f in self.folders) if len(unique_depths) == 1: return unique_depths.pop() raise ValueError( 'Folders in this collection do not have a common DEFAULT_FOLDER_TRAVERSAL_DEPTH value. You need to ' 'define an explicit traversal depth with FolderQuerySet.depth() (values: %s)' % unique_depths ) def resolve(self): # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set. resolveable_folders = [] for f in self.folders: if not f.get_folder_allowed: log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) yield f else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) for f in self.__class__(account=self.account, folders=resolveable_folders).get_folders( additional_fields=additional_fields ): yield f def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0): # 'depth' controls whether to return direct children or recurse into sub-folders from .base import BaseFolder, Folder if not self.folders: log.debug('Folder list is empty') return if not self.account: raise ValueError('Folder must have an account') if q is None or q.is_empty(): restriction = None else: restriction = Restriction(q, folders=self.folders, applies_to=Restriction.FOLDERS) if shape not in SHAPE_CHOICES: raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) if depth is None: depth = self._get_default_folder_traversal_depth() if depth not in FOLDER_TRAVERSAL_CHOICES: raise ValueError("'depth' %s must be one of %s" % (depth, FOLDER_TRAVERSAL_CHOICES)) if additional_fields is None: # Default to all non-complex properties. Subfolders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: if f.field.is_complex: raise ValueError("find_folders() does not support field '%s'. Use get_folders()." % f.field.name) # Add required fields additional_fields.update( (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS) ) for f in FindFolder(account=self.account, folders=self.folders, chunk_size=page_size).call( additional_fields=additional_fields, restriction=restriction, shape=shape, depth=depth, max_items=max_items, offset=offset, ): yield f def get_folders(self, additional_fields=None): # Expand folders with their full set of properties from .base import BaseFolder if not self.folders: log.debug('Folder list is empty') return if additional_fields is None: # Default to all complex properties additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=True) # Add required fields additional_fields.update( (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS) ) for f in GetFolder(account=self.account).call( folders=self.folders, additional_fields=additional_fields, shape=ID_ONLY, ): yield f exchangelib-3.1.1/exchangelib/folders/known_folders.py000066400000000000000000000422041361226005600231230ustar00rootroot00000000000000from ..items import CalendarItem, Contact, Message, Task, DistributionList, MeetingRequest, MeetingResponse, \ MeetingCancellation, ITEM_CLASSES, ASSOCIATED from ..version import EXCHANGE_2010_SP1, EXCHANGE_2013, EXCHANGE_2013_SP1 from .base import Folder from .collections import FolderCollection class Calendar(Folder): """An interface for the Exchange calendar""" DISTINGUISHED_FOLDER_ID = 'calendar' CONTAINER_CLASS = 'IPF.Appointment' supported_item_models = (CalendarItem,) LOCALIZED_NAMES = { 'da_DK': ('Kalender',), 'de_DE': ('Kalender',), 'en_US': ('Calendar',), 'es_ES': ('Calendario',), 'fr_CA': ('Calendrier',), 'nl_NL': ('Agenda',), 'ru_RU': ('Календарь',), 'sv_SE': ('Kalender',), 'zh_CN': ('日历',), } __slots__ = tuple() def view(self, *args, **kwargs): return FolderCollection(account=self.account, folders=[self]).view(*args, **kwargs) class DeletedItems(Folder): DISTINGUISHED_FOLDER_ID = 'deleteditems' CONTAINER_CLASS = 'IPF.Note' supported_item_models = ITEM_CLASSES LOCALIZED_NAMES = { 'da_DK': ('Slettet post',), 'de_DE': ('Gelöschte Elemente',), 'en_US': ('Deleted Items',), 'es_ES': ('Elementos eliminados',), 'fr_CA': ('Éléments supprimés',), 'nl_NL': ('Verwijderde items',), 'ru_RU': ('Удаленные',), 'sv_SE': ('Borttaget',), 'zh_CN': ('已删除邮件',), } __slots__ = tuple() class Messages(Folder): CONTAINER_CLASS = 'IPF.Note' supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation) __slots__ = tuple() class Drafts(Messages): DISTINGUISHED_FOLDER_ID = 'drafts' LOCALIZED_NAMES = { 'da_DK': ('Kladder',), 'de_DE': ('Entwürfe',), 'en_US': ('Drafts',), 'es_ES': ('Borradores',), 'fr_CA': ('Brouillons',), 'nl_NL': ('Concepten',), 'ru_RU': ('Черновики',), 'sv_SE': ('Utkast',), 'zh_CN': ('草稿',), } __slots__ = tuple() class Inbox(Messages): DISTINGUISHED_FOLDER_ID = 'inbox' LOCALIZED_NAMES = { 'da_DK': ('Indbakke',), 'de_DE': ('Posteingang',), 'en_US': ('Inbox',), 'es_ES': ('Bandeja de entrada',), 'fr_CA': ('Boîte de réception',), 'nl_NL': ('Postvak IN',), 'ru_RU': ('Входящие',), 'sv_SE': ('Inkorgen',), 'zh_CN': ('收件箱',), } __slots__ = tuple() class Outbox(Messages): DISTINGUISHED_FOLDER_ID = 'outbox' LOCALIZED_NAMES = { 'da_DK': ('Udbakke',), 'de_DE': ('Postausgang',), 'en_US': ('Outbox',), 'es_ES': ('Bandeja de salida',), 'fr_CA': (u"Boîte d'envoi",), 'nl_NL': ('Postvak UIT',), 'ru_RU': ('Исходящие',), 'sv_SE': ('Utkorgen',), 'zh_CN': ('发件箱',), } __slots__ = tuple() class SentItems(Messages): DISTINGUISHED_FOLDER_ID = 'sentitems' LOCALIZED_NAMES = { 'da_DK': ('Sendt post',), 'de_DE': ('Gesendete Elemente',), 'en_US': ('Sent Items',), 'es_ES': ('Elementos enviados',), 'fr_CA': ('Éléments envoyés',), 'nl_NL': ('Verzonden items',), 'ru_RU': ('Отправленные',), 'sv_SE': ('Skickat',), 'zh_CN': ('已发送邮件',), } __slots__ = tuple() class JunkEmail(Messages): DISTINGUISHED_FOLDER_ID = 'junkemail' LOCALIZED_NAMES = { 'da_DK': ('Uønsket e-mail',), 'de_DE': ('Junk-E-Mail',), 'en_US': ('Junk E-mail',), 'es_ES': ('Correo no deseado',), 'fr_CA': ('Courrier indésirables',), 'nl_NL': ('Ongewenste e-mail',), 'ru_RU': ('Нежелательная почта',), 'sv_SE': ('Skräppost',), 'zh_CN': ('垃圾邮件',), } __slots__ = tuple() class Tasks(Folder): DISTINGUISHED_FOLDER_ID = 'tasks' CONTAINER_CLASS = 'IPF.Task' supported_item_models = (Task,) LOCALIZED_NAMES = { 'da_DK': ('Opgaver',), 'de_DE': ('Aufgaben',), 'en_US': ('Tasks',), 'es_ES': ('Tareas',), 'fr_CA': ('Tâches',), 'nl_NL': ('Taken',), 'ru_RU': ('Задачи',), 'sv_SE': ('Uppgifter',), 'zh_CN': ('任务',), } __slots__ = tuple() class Contacts(Folder): DISTINGUISHED_FOLDER_ID = 'contacts' CONTAINER_CLASS = 'IPF.Contact' supported_item_models = (Contact, DistributionList) LOCALIZED_NAMES = { 'da_DK': ('Kontaktpersoner',), 'de_DE': ('Kontakte',), 'en_US': ('Contacts',), 'es_ES': ('Contactos',), 'fr_CA': ('Contacts',), 'nl_NL': ('Contactpersonen',), 'ru_RU': ('Контакты',), 'sv_SE': ('Kontakter',), 'zh_CN': ('联系人',), } __slots__ = tuple() class WellknownFolder(Folder): """A base class to use until we have a more specific folder implementation for this folder""" supported_item_models = ITEM_CLASSES __slots__ = tuple() class AdminAuditLogs(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'adminauditlogs' supported_from = EXCHANGE_2013 get_folder_allowed = False __slots__ = tuple() class ArchiveDeletedItems(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'archivedeleteditems' supported_from = EXCHANGE_2010_SP1 __slots__ = tuple() class ArchiveInbox(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'archiveinbox' supported_from = EXCHANGE_2013_SP1 __slots__ = tuple() class ArchiveMsgFolderRoot(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'archivemsgfolderroot' supported_from = EXCHANGE_2010_SP1 class ArchiveRecoverableItemsDeletions(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsdeletions' supported_from = EXCHANGE_2010_SP1 __slots__ = tuple() class ArchiveRecoverableItemsPurges(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemspurges' supported_from = EXCHANGE_2010_SP1 __slots__ = tuple() class ArchiveRecoverableItemsRoot(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsroot' supported_from = EXCHANGE_2010_SP1 __slots__ = tuple() class ArchiveRecoverableItemsVersions(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsversions' supported_from = EXCHANGE_2010_SP1 __slots__ = tuple() class Conflicts(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'conflicts' supported_from = EXCHANGE_2013 __slots__ = tuple() class ConversationHistory(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'conversationhistory' supported_from = EXCHANGE_2013 __slots__ = tuple() class Directory(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'directory' supported_from = EXCHANGE_2013_SP1 __slots__ = tuple() class Favorites(WellknownFolder): CONTAINER_CLASS = 'IPF.Note' DISTINGUISHED_FOLDER_ID = 'favorites' supported_from = EXCHANGE_2013 __slots__ = tuple() class IMContactList(WellknownFolder): CONTAINER_CLASS = 'IPF.Contact.MOC.ImContactList' DISTINGUISHED_FOLDER_ID = 'imcontactlist' supported_from = EXCHANGE_2013 __slots__ = tuple() class Journal(WellknownFolder): CONTAINER_CLASS = 'IPF.Journal' DISTINGUISHED_FOLDER_ID = 'journal' __slots__ = tuple() class LocalFailures(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'localfailures' supported_from = EXCHANGE_2013 __slots__ = tuple() class MsgFolderRoot(WellknownFolder): """Also known as the 'Top of Information Store' folder""" DISTINGUISHED_FOLDER_ID = 'msgfolderroot' LOCALIZED_NAMES = { 'zh_CN': ('信息存储顶部',), } __slots__ = tuple() class MyContacts(WellknownFolder): CONTAINER_CLASS = 'IPF.Note' DISTINGUISHED_FOLDER_ID = 'mycontacts' supported_from = EXCHANGE_2013 __slots__ = tuple() class Notes(WellknownFolder): CONTAINER_CLASS = 'IPF.StickyNote' DISTINGUISHED_FOLDER_ID = 'notes' LOCALIZED_NAMES = { 'da_DK': ('Noter',), } __slots__ = tuple() class PeopleConnect(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'peopleconnect' supported_from = EXCHANGE_2013 __slots__ = tuple() class QuickContacts(WellknownFolder): CONTAINER_CLASS = 'IPF.Contact.MOC.QuickContacts' DISTINGUISHED_FOLDER_ID = 'quickcontacts' supported_from = EXCHANGE_2013 __slots__ = tuple() class RecipientCache(Contacts): DISTINGUISHED_FOLDER_ID = 'recipientcache' CONTAINER_CLASS = 'IPF.Contact.RecipientCache' supported_from = EXCHANGE_2013 LOCALIZED_NAMES = {} __slots__ = tuple() class RecoverableItemsDeletions(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'recoverableitemsdeletions' supported_from = EXCHANGE_2010_SP1 __slots__ = tuple() class RecoverableItemsPurges(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'recoverableitemspurges' supported_from = EXCHANGE_2010_SP1 __slots__ = tuple() class RecoverableItemsRoot(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'recoverableitemsroot' supported_from = EXCHANGE_2010_SP1 __slots__ = tuple() class RecoverableItemsVersions(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'recoverableitemsversions' supported_from = EXCHANGE_2010_SP1 __slots__ = tuple() class SearchFolders(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'searchfolders' __slots__ = tuple() class ServerFailures(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'serverfailures' supported_from = EXCHANGE_2013 __slots__ = tuple() class SyncIssues(WellknownFolder): CONTAINER_CLASS = 'IPF.Note' DISTINGUISHED_FOLDER_ID = 'syncissues' supported_from = EXCHANGE_2013 __slots__ = tuple() class ToDoSearch(WellknownFolder): CONTAINER_CLASS = 'IPF.Task' DISTINGUISHED_FOLDER_ID = 'todosearch' supported_from = EXCHANGE_2013 LOCALIZED_NAMES = { None: ('To-Do Search',), } __slots__ = tuple() class VoiceMail(WellknownFolder): DISTINGUISHED_FOLDER_ID = 'voicemail' CONTAINER_CLASS = 'IPF.Note.Microsoft.Voicemail' LOCALIZED_NAMES = { None: ('Voice Mail',), } __slots__ = tuple() class NonDeleteableFolderMixin: @property def is_deleteable(self): return False class AllContacts(NonDeleteableFolderMixin, Contacts): CONTAINER_CLASS = 'IPF.Note' LOCALIZED_NAMES = { None: ('AllContacts',), } __slots__ = tuple() class AllItems(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = 'IPF' LOCALIZED_NAMES = { None: ('AllItems',), } __slots__ = tuple() class Audits(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('Audits',), } get_folder_allowed = False __slots__ = tuple() class CalendarLogging(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('Calendar Logging',), } __slots__ = tuple() class CommonViews(NonDeleteableFolderMixin, Folder): DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED LOCALIZED_NAMES = { None: ('Common Views',), } __slots__ = tuple() class ConversationSettings(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = 'IPF.Configuration' LOCALIZED_NAMES = { 'da_DK': ('Indstillinger for samtalehandlinger',), } __slots__ = tuple() class DefaultFoldersChangeHistory(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = 'IPM.DefaultFolderHistoryItem' LOCALIZED_NAMES = { None: ('DefaultFoldersChangeHistory',), } __slots__ = tuple() class DeferredAction(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('Deferred Action',), } __slots__ = tuple() class ExchangeSyncData(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('ExchangeSyncData',), } __slots__ = tuple() class Files(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = 'IPF.Files' LOCALIZED_NAMES = { 'da_DK': ('Filer',), } __slots__ = tuple() class FreebusyData(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('Freebusy Data',), } __slots__ = tuple() class Friends(NonDeleteableFolderMixin, Contacts): CONTAINER_CLASS = 'IPF.Note' LOCALIZED_NAMES = { 'de_DE': ('Bekannte',), } __slots__ = tuple() class GALContacts(NonDeleteableFolderMixin, Contacts): DISTINGUISHED_FOLDER_ID = None CONTAINER_CLASS = 'IPF.Contact.GalContacts' LOCALIZED_NAMES = { None: ('GAL Contacts',), } __slots__ = tuple() class GraphAnalytics(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = 'IPF.StoreItem.GraphAnalytics' LOCALIZED_NAMES = { None: ('GraphAnalytics',), } __slots__ = tuple() class Location(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('Location',), } __slots__ = tuple() class MailboxAssociations(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('MailboxAssociations',), } __slots__ = tuple() class MyContactsExtended(NonDeleteableFolderMixin, Contacts): CONTAINER_CLASS = 'IPF.Note' LOCALIZED_NAMES = { None: ('MyContactsExtended',), } __slots__ = tuple() class ParkedMessages(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = None LOCALIZED_NAMES = { None: ('ParkedMessages',), } __slots__ = tuple() class PassThroughSearchResults(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = 'IPF.StoreItem.PassThroughSearchResults' LOCALIZED_NAMES = { None: ('Pass-Through Search Results',), } __slots__ = tuple() class PdpProfileV2Secured(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = 'IPF.StoreItem.PdpProfileSecured' LOCALIZED_NAMES = { None: ('PdpProfileV2Secured',), } __slots__ = tuple() class Reminders(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = 'Outlook.Reminder' LOCALIZED_NAMES = { 'da_DK': ('Påmindelser',), } __slots__ = tuple() class RSSFeeds(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = 'IPF.Note.OutlookHomepage' LOCALIZED_NAMES = { None: ('RSS Feeds',), } __slots__ = tuple() class Schedule(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('Schedule',), } __slots__ = tuple() class Sharing(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = 'IPF.Note' LOCALIZED_NAMES = { None: ('Sharing',), } __slots__ = tuple() class Shortcuts(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('Shortcuts',), } __slots__ = tuple() class Signal(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = 'IPF.StoreItem.Signal' LOCALIZED_NAMES = { None: ('Signal',), } __slots__ = tuple() class SmsAndChatsSync(NonDeleteableFolderMixin, Folder): CONTAINER_CLASS = 'IPF.SmsAndChatsSync' LOCALIZED_NAMES = { None: ('SmsAndChatsSync',), } __slots__ = tuple() class SpoolerQueue(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('Spooler Queue',), } __slots__ = tuple() class System(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('System',), } get_folder_allowed = False __slots__ = tuple() class TemporarySaves(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('TemporarySaves',), } __slots__ = tuple() class Views(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('Views',), } __slots__ = tuple() class WorkingSet(NonDeleteableFolderMixin, Folder): LOCALIZED_NAMES = { None: ('Working Set',), } __slots__ = tuple() # Folders that return 'ErrorDeleteDistinguishedFolder' when we try to delete them. I can't find any official docs # listing these folders. NON_DELETEABLE_FOLDERS = [ AllContacts, AllItems, Audits, CalendarLogging, CommonViews, ConversationSettings, DefaultFoldersChangeHistory, DeferredAction, ExchangeSyncData, FreebusyData, Files, Friends, GALContacts, GraphAnalytics, Location, MailboxAssociations, MyContactsExtended, ParkedMessages, PassThroughSearchResults, PdpProfileV2Secured, Reminders, RSSFeeds, Schedule, Sharing, Shortcuts, Signal, SmsAndChatsSync, SpoolerQueue, System, TemporarySaves, Views, WorkingSet, ] WELLKNOWN_FOLDERS_IN_ROOT = [ AdminAuditLogs, Calendar, Conflicts, Contacts, ConversationHistory, DeletedItems, Directory, Drafts, Favorites, IMContactList, Inbox, Journal, JunkEmail, LocalFailures, MsgFolderRoot, MyContacts, Notes, Outbox, PeopleConnect, QuickContacts, RecipientCache, RecoverableItemsDeletions, RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsVersions, SearchFolders, SentItems, ServerFailures, SyncIssues, Tasks, ToDoSearch, VoiceMail, ] WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT = [ ArchiveDeletedItems, ArchiveInbox, ArchiveMsgFolderRoot, ArchiveRecoverableItemsDeletions, ArchiveRecoverableItemsPurges, ArchiveRecoverableItemsRoot, ArchiveRecoverableItemsVersions, ] exchangelib-3.1.1/exchangelib/folders/queryset.py000066400000000000000000000136651361226005600221430ustar00rootroot00000000000000from copy import deepcopy import logging from ..properties import InvalidField from ..queryset import DoesNotExist, MultipleObjectsReturned from ..restriction import Q # Traversal enums SHALLOW = 'Shallow' SOFT_DELETED = 'SoftDeleted' DEEP = 'Deep' FOLDER_TRAVERSAL_CHOICES = (SHALLOW, DEEP, SOFT_DELETED) log = logging.getLogger(__name__) class FolderQuerySet: """A QuerySet-like class for finding subfolders of a folder collection """ def __init__(self, folder_collection): from .collections import FolderCollection if not isinstance(folder_collection, FolderCollection): raise ValueError("'folder_collection' %r must be a FolderCollection instance" % folder_collection) self.folder_collection = folder_collection self.only_fields = None self._depth = None self.q = None def _copy_cls(self): return self.__class__(folder_collection=self.folder_collection) def _copy_self(self): """Chaining operations must make a copy of self before making any modifications """ new_qs = self._copy_cls() new_qs.only_fields = self.only_fields new_qs._depth = self._depth new_qs.q = None if self.q is None else deepcopy(self.q) return new_qs def only(self, *args): """Restrict the fields returned. 'name' and 'folder_class' are always returned. """ from .base import Folder # Subfolders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) only_fields = [] for arg in args: for field_path in all_fields: if field_path.field.name == arg: only_fields.append(field_path) break else: raise InvalidField("Unknown field %r on folders %s" % (arg, self.folder_collection.folders)) new_qs = self._copy_self() new_qs.only_fields = only_fields return new_qs def depth(self, depth): """Specify the search depth (SHALLOW or DEEP) """ new_qs = self._copy_self() new_qs._depth = depth return new_qs def get(self, *args, **kwargs): """Return the single folder matching the specified filter """ if args or kwargs: folders = list(self.filter(*args, **kwargs)) else: folders = list(self.all()) if not folders: raise DoesNotExist('Could not find a child folder matching the query') if len(folders) != 1: raise MultipleObjectsReturned('Expected result length 1, but got %s' % folders) f = folders[0] if isinstance(f, Exception): raise f return f def all(self): """Return all child folders at the depth specified """ new_qs = self._copy_self() return new_qs def filter(self, *args, **kwargs): """Add restrictions to the folder search """ new_qs = self._copy_self() q = Q(*args, **kwargs) new_qs.q = q if new_qs.q is None else new_qs.q & q return new_qs def __iter__(self): return self._query() def _query(self): from .base import Folder from .collections import FolderCollection if self.only_fields is None: # Subfolders will always be of class Folder non_complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=False) complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=True) else: non_complex_fields = set(f for f in self.only_fields if not f.field.is_complex) complex_fields = set(f for f in self.only_fields if f.field.is_complex) # First, fetch all non-complex fields using FindFolder. We do this because some folders do not support # GetFolder but we still want to get as much information as possible. folders = self.folder_collection.find_folders(q=self.q, depth=self._depth, additional_fields=non_complex_fields) if not complex_fields: for f in folders: yield f return # Fetch all properties for the found folders resolveable_folders = [] for f in folders: if not f.get_folder_allowed: log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) yield f else: resolveable_folders.append(f) # Get the complex fields using GetFolder, for the folders that support it, and add the extra field values complex_folders = FolderCollection( account=self.folder_collection.account, folders=resolveable_folders ).get_folders(additional_fields=complex_fields) for f, complex_f in zip(resolveable_folders, complex_folders): if isinstance(f, Exception): yield f continue if isinstance(complex_f, Exception): yield complex_f continue # Add the extra field values to the folders we fetched with find_folders() if f.__class__ != complex_f.__class__: raise ValueError('Type mismatch: %s vs %s' % (f, complex_f)) for complex_field in complex_fields: field_name = complex_field.field.name setattr(f, field_name, getattr(complex_f, field_name)) yield f class SingleFolderQuerySet(FolderQuerySet): """A helper class with simpler argument types """ def __init__(self, account, folder): from .collections import FolderCollection folder_collection = FolderCollection(account=account, folders=[folder]) super().__init__(folder_collection=folder_collection) def _copy_cls(self): return self.__class__(account=self.folder_collection.account, folder=self.folder_collection.folders[0]) exchangelib-3.1.1/exchangelib/folders/roots.py000066400000000000000000000341331361226005600214210ustar00rootroot00000000000000import logging from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound, \ ErrorInvalidOperation from ..fields import EffectiveRightsField from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010_SP1 from .collections import FolderCollection from .base import BaseFolder from .known_folders import MsgFolderRoot, NON_DELETEABLE_FOLDERS, WELLKNOWN_FOLDERS_IN_ROOT, \ WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT from .queryset import SingleFolderQuerySet, SHALLOW log = logging.getLogger(__name__) class RootOfHierarchy(BaseFolder): """Base class for folders that implement the root of a folder hierarchy""" # A list of wellknown, or "distinguished", folders that are belong in this folder hierarchy. See # https://docs.microsoft.com/en-us/dotnet/api/microsoft.exchange.webservices.data.wellknownfoldername # and https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid # 'RootOfHierarchy' subclasses must not be in this list. WELLKNOWN_FOLDERS = [] LOCAL_FIELDS = [ # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is # deemed minimal at best. EffectiveRightsField('effective_rights', field_uri='folder:EffectiveRights', is_read_only=True, supported_from=EXCHANGE_2007_SP1), ] FIELDS = BaseFolder.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) + ('_account', '_subfolders') # A special folder that acts as the top of a folder hierarchy. Finds and caches subfolders at arbitrary depth. def __init__(self, **kwargs): self._account = kwargs.pop('account', None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) self._subfolders = None # See self._folders_map() @property def account(self): return self._account @property def root(self): return self @property def parent(self): return None def refresh(self): self._subfolders = None super().refresh() @classmethod def register(cls, *args, **kwargs): if cls is not RootOfHierarchy: raise TypeError('For folder roots, custom fields must be registered on the RootOfHierarchy class') return super().register(*args, **kwargs) @classmethod def deregister(cls, *args, **kwargs): if cls is not RootOfHierarchy: raise TypeError('For folder roots, custom fields must be registered on the RootOfHierarchy class') return super().deregister(*args, **kwargs) def get_folder(self, folder_id): return self._folders_map.get(folder_id, None) def add_folder(self, folder): if not folder.id: raise ValueError("'folder' must have an ID") self._folders_map[folder.id] = folder def update_folder(self, folder): if not folder.id: raise ValueError("'folder' must have an ID") self._folders_map[folder.id] = folder def remove_folder(self, folder): if not folder.id: raise ValueError("'folder' must have an ID") try: del self._folders_map[folder.id] except KeyError: pass def clear_cache(self): self._subfolders = None def get_children(self, folder): for f in self._folders_map.values(): if not f.parent: continue if f.parent.id == folder.id: yield f @classmethod def get_distinguished(cls, account): """Gets the distinguished folder for this folder class""" if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError('Class %s must have a DISTINGUISHED_FOLDER_ID value' % cls) try: return cls.resolve( account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except ErrorFolderNotFound: raise ErrorFolderNotFound('Could not find distinguished folder %s' % cls.DISTINGUISHED_FOLDER_ID) def get_default_folder(self, folder_cls): # Returns the distinguished folder instance of type folder_cls belonging to this account. If no distinguished # folder was found, try as best we can to return the default folder of type 'folder_cls' if not folder_cls.DISTINGUISHED_FOLDER_ID: raise ValueError("'folder_cls' %s must have a DISTINGUISHED_FOLDER_ID value" % folder_cls) # Use cached distinguished folder instance, but only if cache has already been prepped. This is an optimization # for accessing e.g. 'account.contacts' without fetching all folders of the account. if self._subfolders: for f in self._folders_map.values(): # Require exact class, to not match subclasses, e.g. RecipientCache instead of Contacts if f.__class__ == folder_cls and f.is_distinguished: log.debug('Found cached distinguished %s folder', folder_cls) return f try: log.debug('Requesting distinguished %s folder explicitly', folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItems instead log.debug('Testing default %s folder with FindItem', folder_cls) fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except ErrorFolderNotFound: # The Exchange server does not return a distinguished folder of this type pass raise ErrorFolderNotFound('No useable default %s folders' % folder_cls) @property def _folders_map(self): if self._subfolders is not None: return self._subfolders # Map root, and all subfolders of root, at arbitrary depth by folder ID. First get distinguished folders, so we # are sure to apply the correct Folder class, then fetch all subfolders of this root. folders_map = {self.id: self} distinguished_folders = [ cls(root=self, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) ] for f in FolderCollection(account=self.account, folders=distinguished_folders).resolve(): if isinstance(f, (ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable)): # This is just a distinguished folder the server does not have continue if isinstance(f, ErrorInvalidOperation): # This is probably a distinguished folder the server does not have. We previously tested the exact # error message (f.value), but some Exchange servers return localized error messages, so that's not # possible to do reliably. continue if isinstance(f, ErrorItemNotFound): # Another way of telling us that this is a distinguished folder the server does not have continue if isinstance(f, ErrorAccessDenied): # We may not have GetFolder access, either to this folder or at all continue if isinstance(f, Exception): raise f folders_map[f.id] = f for f in SingleFolderQuerySet(account=self.account, folder=self).depth( self.DEFAULT_FOLDER_TRAVERSAL_DEPTH ).all(): if isinstance(f, ErrorAccessDenied): # We may not have FindFolder access, or GetFolder access, either to this folder or at all continue if isinstance(f, Exception): raise f if f.id in folders_map: # Already exists. Probably a distinguished folder continue folders_map[f.id] = f self._subfolders = folders_map return folders_map @classmethod def from_xml(cls, elem, account): kwargs = cls._kwargs_from_elem(elem=elem, account=account) cls._clear(elem) return cls(account=account, **kwargs) @classmethod def folder_cls_from_folder_name(cls, folder_name, locale): """Returns the folder class that matches a localized folder name. locale is a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETEABLE_FOLDERS: if folder_name.lower() in folder_cls.localized_names(locale): return folder_cls raise KeyError() def __repr__(self): # Let's not create an infinite loop when printing self.root return self.__class__.__name__ + \ repr((self.account, '[self]', self.name, self.total_count, self.unread_count, self.child_folder_count, self.folder_class, self.id, self.changekey)) class Root(RootOfHierarchy): """The root of the standard folder hierarchy""" DISTINGUISHED_FOLDER_ID = 'root' WELLKNOWN_FOLDERS = WELLKNOWN_FOLDERS_IN_ROOT __slots__ = tuple() @property def tois(self): # 'Top of Information Store' is a folder available in some Exchange accounts. It usually contains the # distinguished folders belonging to the account (inbox, calendar, trash etc.). return self.get_default_folder(MsgFolderRoot) def get_default_folder(self, folder_cls): try: return super().get_default_folder(folder_cls) except ErrorFolderNotFound: pass # Try to pick a suitable default folder. we do this by: # 1. Searching the full folder list for a folder with the distinguished folder name # 2. Searching TOIS for a direct child folder of the same type that is marked as distinguished # 3. Searching TOIS for a direct child folder of the same type that is has a localized name # 4. Searching root for a direct child folder of the same type that is marked as distinguished # 5. Searching root for a direct child folder of the same type that is has a localized name log.debug('Searching default %s folder in full folder list', folder_cls) for f in self._folders_map.values(): # Require exact class to not match e.g. RecipientCache instead of Contacts if f.__class__ == folder_cls and f.has_distinguished_name: log.debug('Found cached %s folder with default distinguished name', folder_cls) return f # Try direct children of TOIS first. TOIS might not exist. try: return self._get_candidate(folder_cls=folder_cls, folder_coll=self.tois.children) except ErrorFolderNotFound: # No candidates, or TOIS does ot exist pass # No candidates in TOIS. Try direct children of root. return self._get_candidate(folder_cls=folder_cls, folder_coll=self.children) def _get_candidate(self, folder_cls, folder_coll): # Get a single the folder of the same type in folder_coll same_type = [f for f in folder_coll if f.__class__ == folder_cls] are_distinguished = [f for f in same_type if f.is_distinguished] if are_distinguished: candidates = are_distinguished else: candidates = [f for f in same_type if f.name.lower() in folder_cls.localized_names(self.account.locale)] if candidates: if len(candidates) > 1: raise ValueError( 'Multiple possible default %s folders: %s' % (folder_cls, [f.name for f in candidates]) ) if candidates[0].is_distinguished: log.debug('Found cached distinguished %s folder', folder_cls) else: log.debug('Found cached %s folder with localized name', folder_cls) return candidates[0] raise ErrorFolderNotFound('No useable default %s folders' % folder_cls) class PublicFoldersRoot(RootOfHierarchy): """The root of the public folders hierarchy. Not available on all mailboxes""" DISTINGUISHED_FOLDER_ID = 'publicfoldersroot' DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW supported_from = EXCHANGE_2007_SP1 __slots__ = tuple() def get_children(self, folder): # EWS does not allow deep traversal of public folders, so self._folders_map will only populate the top-level # subfolders. To traverse public folders at arbitrary depth, we need to get child folders on demand. # Let's check if this folder already has any cached children. If so, assume we can just return those. children = list(super().get_children(folder=folder)) if children: # Return a generator like our parent does for f in children: yield f return # Also return early if the server told us that there are no child folders. if folder.child_folder_count == 0: return children_map = {} try: for f in SingleFolderQuerySet(account=self.account, folder=folder).depth( self.DEFAULT_FOLDER_TRAVERSAL_DEPTH ).all(): if isinstance(f, Exception): raise f children_map[f.id] = f except ErrorAccessDenied: # No access to this folder pass # Let's update the cache atomically, to avoid partial reads of the cache. self._subfolders.update(children_map) # Child folders have been cached now. Try super().get_children() again. for f in super().get_children(folder=folder): yield f class ArchiveRoot(RootOfHierarchy): """The root of the archive folders hierarchy. Not available on all mailboxes""" DISTINGUISHED_FOLDER_ID = 'archiveroot' supported_from = EXCHANGE_2010_SP1 WELLKNOWN_FOLDERS = WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT __slots__ = tuple() exchangelib-3.1.1/exchangelib/indexed_properties.py000066400000000000000000000061351361226005600225120ustar00rootroot00000000000000import logging from .fields import EmailSubField, LabelField, SubField, NamedSubField, Choice from .properties import EWSElement log = logging.getLogger(__name__) class IndexedElement(EWSElement): """Base class for all classes that implement an indexed element""" LABELS = set() __slots__ = tuple() class SingleFieldIndexedElement(IndexedElement): """Base class for all classes that implement an indexed element with a single field""" __slots__ = tuple() @classmethod def value_field(cls, version=None): fields = cls.supported_fields(version=version) if len(fields) != 1: raise ValueError('This class must have only one field (found %s)' % (fields,)) return fields[0] class EmailAddress(SingleFieldIndexedElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-emailaddress""" ELEMENT_NAME = 'Entry' FIELDS = [ LabelField('label', field_uri='Key', choices={ Choice('EmailAddress1'), Choice('EmailAddress2'), Choice('EmailAddress3') }, default='EmailAddress1'), EmailSubField('email'), ] __slots__ = tuple(f.name for f in FIELDS) class PhoneNumber(SingleFieldIndexedElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-phonenumber""" ELEMENT_NAME = 'Entry' FIELDS = [ LabelField('label', field_uri='Key', choices={ Choice('AssistantPhone'), Choice('BusinessFax'), Choice('BusinessPhone'), Choice('BusinessPhone2'), Choice('Callback'), Choice('CarPhone'), Choice('CompanyMainPhone'), Choice('HomeFax'), Choice('HomePhone'), Choice('HomePhone2'), Choice('Isdn'), Choice('MobilePhone'), Choice('OtherFax'), Choice('OtherTelephone'), Choice('Pager'), Choice('PrimaryPhone'), Choice('RadioPhone'), Choice('Telex'), Choice('TtyTddPhone'), }, default='PrimaryPhone'), SubField('phone_number'), ] __slots__ = tuple(f.name for f in FIELDS) class MultiFieldIndexedElement(IndexedElement): """Base class for all classes that implement an indexed element with multiple fields""" __slots__ = tuple() class PhysicalAddress(MultiFieldIndexedElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-physicaladdress""" ELEMENT_NAME = 'Entry' FIELDS = [ LabelField('label', field_uri='Key', choices={ Choice('Business'), Choice('Home'), Choice('Other') }, default='Business'), NamedSubField('street', field_uri='Street'), # Street, house number, etc. NamedSubField('city', field_uri='City'), NamedSubField('state', field_uri='State'), NamedSubField('country', field_uri='CountryOrRegion'), NamedSubField('zipcode', field_uri='PostalCode'), ] __slots__ = tuple(f.name for f in FIELDS) def clean(self, version=None): # pylint: disable=access-member-before-definition if isinstance(self.zipcode, int): self.zipcode = str(self.zipcode) super().clean(version=version) exchangelib-3.1.1/exchangelib/items/000077500000000000000000000000001361226005600173605ustar00rootroot00000000000000exchangelib-3.1.1/exchangelib/items/__init__.py000066400000000000000000000057311361226005600214770ustar00rootroot00000000000000from .base import RegisterMixIn, MESSAGE_DISPOSITION_CHOICES, SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY from .calendar_item import CalendarItem, AcceptItem, TentativelyAcceptItem, DeclineItem, CancelCalendarItem, \ MeetingRequest, MeetingResponse, MeetingCancellation, CONFERENCE_TYPES from .contact import Contact, Persona, DistributionList from .item import SEND_MEETING_INVITATIONS_CHOICES, SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_TO_ALL_AND_SAVE_COPY, \ SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY_TO_CHANGED, SEND_TO_CHANGED_AND_SAVE_COPY, \ SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES, ALL_OCCURRENCIES, \ SPECIFIED_OCCURRENCE_ONLY, CONFLICT_RESOLUTION_CHOICES, NEVER_OVERWRITE, AUTO_RESOLVE, ALWAYS_OVERWRITE, \ DELETE_TYPE_CHOICES, HARD_DELETE, SOFT_DELETE, MOVE_TO_DELETED_ITEMS, BaseItem, Item, BulkCreateResult from .message import Message, ReplyToItem, ReplyAllToItem, ForwardItem from .post import PostItem, PostReplyItem from .task import Task __all__ = [ 'RegisterMixIn', 'MESSAGE_DISPOSITION_CHOICES', 'SAVE_ONLY', 'SEND_ONLY', 'SEND_AND_SAVE_COPY', 'CalendarItem', 'AcceptItem', 'TentativelyAcceptItem', 'DeclineItem', 'CancelCalendarItem', 'MeetingRequest', 'MeetingResponse', 'MeetingCancellation', 'CONFERENCE_TYPES', 'Contact', 'Persona', 'DistributionList', 'SEND_MEETING_INVITATIONS_CHOICES', 'SEND_TO_NONE', 'SEND_ONLY_TO_ALL', 'SEND_TO_ALL_AND_SAVE_COPY', 'SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES', 'SEND_ONLY_TO_CHANGED', 'SEND_TO_CHANGED_AND_SAVE_COPY', 'SEND_MEETING_CANCELLATIONS_CHOICES', 'AFFECTED_TASK_OCCURRENCES_CHOICES', 'ALL_OCCURRENCIES', 'SPECIFIED_OCCURRENCE_ONLY', 'CONFLICT_RESOLUTION_CHOICES', 'NEVER_OVERWRITE', 'AUTO_RESOLVE', 'ALWAYS_OVERWRITE', 'DELETE_TYPE_CHOICES', 'HARD_DELETE', 'SOFT_DELETE', 'MOVE_TO_DELETED_ITEMS', 'BaseItem', 'Item', 'BulkCreateResult', 'Message', 'ReplyToItem', 'ReplyAllToItem', 'ForwardItem', 'PostItem', 'PostReplyItem', 'Task', ] # Traversal enums SHALLOW = 'Shallow' SOFT_DELETED = 'SoftDeleted' ASSOCIATED = 'Associated' ITEM_TRAVERSAL_CHOICES = (SHALLOW, SOFT_DELETED, ASSOCIATED) # Shape enums ID_ONLY = 'IdOnly' DEFAULT = 'Default' # AllProperties doesn't actually get all properties in FindItem, just the "first-class" ones. See # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/email-properties-and-elements-in-ews-in-exchange ALL_PROPERTIES = 'AllProperties' SHAPE_CHOICES = (ID_ONLY, DEFAULT, ALL_PROPERTIES) # Contacts search (ResolveNames) scope enums ACTIVE_DIRECTORY = 'ActiveDirectory' ACTIVE_DIRECTORY_CONTACTS = 'ActiveDirectoryContacts' CONTACTS = 'Contacts' CONTACTS_ACTIVE_DIRECTORY = 'ContactsActiveDirectory' SEARCH_SCOPE_CHOICES = (ACTIVE_DIRECTORY, ACTIVE_DIRECTORY_CONTACTS, CONTACTS, CONTACTS_ACTIVE_DIRECTORY) ITEM_CLASSES = (Item, CalendarItem, Contact, DistributionList, Message, PostItem, Task, MeetingRequest, MeetingResponse, MeetingCancellation) exchangelib-3.1.1/exchangelib/items/base.py000066400000000000000000000153771361226005600206610ustar00rootroot00000000000000import logging from ..extended_properties import ExtendedProperty from ..fields import BooleanField, ExtendedPropertyField, BodyField, MailboxField, MailboxListField, EWSElementField, \ CharField from ..properties import InvalidField, IdChangeKeyMixIn, EWSElement, ReferenceItemId from ..version import EXCHANGE_2007_SP1 log = logging.getLogger(__name__) # MessageDisposition values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem SAVE_ONLY = 'SaveOnly' SEND_ONLY = 'SendOnly' SEND_AND_SAVE_COPY = 'SendAndSaveCopy' MESSAGE_DISPOSITION_CHOICES = (SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY) class RegisterMixIn(IdChangeKeyMixIn): """Base class for classes that can change their list of supported fields dynamically""" # This class implements dynamic fields on an element class, so we need to include __dict__ in __slots__ __slots__ = ('__dict__',) INSERT_AFTER_FIELD = None @classmethod def register(cls, attr_name, attr_cls): """ Register a custom extended property in this item class so they can be accessed just like any other attribute """ if not cls.INSERT_AFTER_FIELD: raise ValueError('Class %s is missing INSERT_AFTER_FIELD value' % cls) try: cls.get_field_by_fieldname(attr_name) except InvalidField: pass else: raise ValueError("'%s' is already registered" % attr_name) if not issubclass(attr_cls, ExtendedProperty): raise ValueError("%r must be a subclass of ExtendedProperty" % attr_cls) # Check if class attributes are properly defined attr_cls.validate_cls() # ExtendedProperty is not a real field, but a placeholder in the fields list. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/item # # Find the correct index for the new extended property, and insert. field = ExtendedPropertyField(attr_name, value_cls=attr_cls) cls.add_field(field, insert_after=cls.INSERT_AFTER_FIELD) @classmethod def deregister(cls, attr_name): """ De-register an extended property that has been registered with register() """ try: field = cls.get_field_by_fieldname(attr_name) except InvalidField: raise ValueError("'%s' is not registered" % attr_name) if not isinstance(field, ExtendedPropertyField): raise ValueError("'%s' is not registered as an ExtendedProperty" % attr_name) cls.remove_field(field) class BaseItem(RegisterMixIn): """Base class for all other classes that implement EWS items""" __slots__ = ('account', 'folder') def __init__(self, **kwargs): # 'account' is optional but allows calling 'send()' and 'delete()' # 'folder' is optional but allows calling 'save()'. If 'folder' has an account, and 'account' is not set, # we use folder.account. from ..folders import BaseFolder from ..account import Account self.account = kwargs.pop('account', None) if self.account is not None and not isinstance(self.account, Account): raise ValueError("'account' %r must be an Account instance" % self.account) self.folder = kwargs.pop('folder', None) if self.folder is not None: if not isinstance(self.folder, BaseFolder): raise ValueError("'folder' %r must be a Folder instance" % self.folder) if self.folder.account is not None: if self.account is not None: # Make sure the account from kwargs matches the folder account if self.account != self.folder.account: raise ValueError("'account' does not match 'folder.account'") self.account = self.folder.account super().__init__(**kwargs) @classmethod def from_xml(cls, elem, account): item = super().from_xml(elem=elem, account=account) item.account = account return item class BaseReplyItem(EWSElement): """Base class for reply/forward elements that share the same fields""" FIELDS = [ CharField('subject', field_uri='Subject'), BodyField('body', field_uri='Body'), # Accepts and returns Body or HTMLBody instances MailboxListField('to_recipients', field_uri='ToRecipients'), MailboxListField('cc_recipients', field_uri='CcRecipients'), MailboxListField('bcc_recipients', field_uri='BccRecipients'), BooleanField('is_read_receipt_requested', field_uri='IsReadReceiptRequested'), BooleanField('is_delivery_receipt_requested', field_uri='IsDeliveryReceiptRequested'), MailboxField('author', field_uri='From'), EWSElementField('reference_item_id', value_cls=ReferenceItemId), BodyField('new_body', field_uri='NewBodyContent'), # Accepts and returns Body or HTMLBody instances MailboxField('received_by', field_uri='ReceivedBy', supported_from=EXCHANGE_2007_SP1), MailboxField('received_by_representing', field_uri='ReceivedRepresenting', supported_from=EXCHANGE_2007_SP1), ] __slots__ = tuple(f.name for f in FIELDS) + ('account',) def __init__(self, **kwargs): # 'account' is optional but allows calling 'send()' and 'save()' from ..account import Account self.account = kwargs.pop('account', None) if self.account is not None and not isinstance(self.account, Account): raise ValueError("'account' %r must be an Account instance" % self.account) super().__init__(**kwargs) def send(self, save_copy=True, copy_to_folder=None): if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) if copy_to_folder: if not save_copy: raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set") message_disposition = SEND_AND_SAVE_COPY if save_copy else SEND_ONLY res = self.account.bulk_create(items=[self], folder=copy_to_folder, message_disposition=message_disposition) if res and isinstance(res[0], Exception): raise res[0] def save(self, folder): """ save reply/forward and retrieve the item result for further modification, you may want to use account.drafts as the folder. """ if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) res = self.account.bulk_create(items=[self], folder=folder, message_disposition=SAVE_ONLY) if res and isinstance(res[0], Exception): raise res[0] res = list(self.account.fetch(res)) # retrieve result if res and isinstance(res[0], Exception): raise res[0] return res[0] exchangelib-3.1.1/exchangelib/items/calendar_item.py000066400000000000000000000375171361226005600225360ustar00rootroot00000000000000import logging from ..fields import BooleanField, IntegerField, TextField, ChoiceField, URIField, BodyField, DateTimeField, \ MessageHeaderField, AttachmentField, RecurrenceField, MailboxField, AttendeesField, Choice, OccurrenceField, \ OccurrenceListField, TimeZoneField, CharField, EnumAsIntField, FreeBusyStatusField, ReferenceItemIdField, \ AssociatedCalendarItemIdField from ..properties import Attendee, ReferenceItemId, AssociatedCalendarItemId from ..recurrence import FirstOccurrence, LastOccurrence, Occurrence, DeletedOccurrence from ..version import EXCHANGE_2010, EXCHANGE_2013 from .base import BaseItem, BaseReplyItem, SEND_AND_SAVE_COPY from .item import Item from .message import Message log = logging.getLogger(__name__) # Conference Type values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/conferencetype CONFERENCE_TYPES = ('NetMeeting', 'NetShow', 'Chat') # CalendarItemType enums SINGLE = 'Single' OCCURRENCE = 'Occurrence' EXCEPTION = 'Exception' RECURRING_MASTER = 'RecurringMaster' CALENDAR_ITEM_CHOICES = (SINGLE, OCCURRENCE, EXCEPTION, RECURRING_MASTER) class AcceptDeclineMixIn: def accept(self, **kwargs): return AcceptItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send() def decline(self, **kwargs): return DeclineItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send() def tentatively_accept(self, **kwargs): return TentativelyAcceptItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send() class CalendarItem(Item, AcceptDeclineMixIn): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendaritem """ ELEMENT_NAME = 'CalendarItem' LOCAL_FIELDS = [ TextField('uid', field_uri='calendar:UID', is_required_after_save=True, is_searchable=False), DateTimeField('start', field_uri='calendar:Start', is_required=True), DateTimeField('end', field_uri='calendar:End', is_required=True), DateTimeField('original_start', field_uri='calendar:OriginalStart', is_read_only=True), BooleanField('is_all_day', field_uri='calendar:IsAllDayEvent', is_required=True, default=False), FreeBusyStatusField('legacy_free_busy_status', field_uri='calendar:LegacyFreeBusyStatus', is_required=True, default='Busy'), TextField('location', field_uri='calendar:Location'), TextField('when', field_uri='calendar:When'), BooleanField('is_meeting', field_uri='calendar:IsMeeting', is_read_only=True), BooleanField('is_cancelled', field_uri='calendar:IsCancelled', is_read_only=True), BooleanField('is_recurring', field_uri='calendar:IsRecurring', is_read_only=True), BooleanField('meeting_request_was_sent', field_uri='calendar:MeetingRequestWasSent', is_read_only=True), BooleanField('is_response_requested', field_uri='calendar:IsResponseRequested', default=None, is_required_after_save=True, is_searchable=False), ChoiceField('type', field_uri='calendar:CalendarItemType', choices={Choice(c) for c in CALENDAR_ITEM_CHOICES}, is_read_only=True), ChoiceField('my_response_type', field_uri='calendar:MyResponseType', choices={ Choice(c) for c in Attendee.RESPONSE_TYPES }, is_read_only=True), MailboxField('organizer', field_uri='calendar:Organizer', is_read_only=True), AttendeesField('required_attendees', field_uri='calendar:RequiredAttendees', is_searchable=False), AttendeesField('optional_attendees', field_uri='calendar:OptionalAttendees', is_searchable=False), AttendeesField('resources', field_uri='calendar:Resources', is_searchable=False), IntegerField('conflicting_meeting_count', field_uri='calendar:ConflictingMeetingCount', is_read_only=True), IntegerField('adjacent_meeting_count', field_uri='calendar:AdjacentMeetingCount', is_read_only=True), # Placeholder for ConflictingMeetings # Placeholder for AdjacentMeetings CharField('duration', field_uri='calendar:Duration', is_read_only=True), DateTimeField('appointment_reply_time', field_uri='calendar:AppointmentReplyTime', is_read_only=True), IntegerField('appointment_sequence_number', field_uri='calendar:AppointmentSequenceNumber', is_read_only=True), # Placeholder for AppointmentState # AppointmentState is an EnumListField-like field, but with bitmask values: # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/appointmentstate # We could probably subclass EnumListField to implement this field. RecurrenceField('recurrence', field_uri='calendar:Recurrence', is_searchable=False), OccurrenceField('first_occurrence', field_uri='calendar:FirstOccurrence', value_cls=FirstOccurrence, is_read_only=True), OccurrenceField('last_occurrence', field_uri='calendar:LastOccurrence', value_cls=LastOccurrence, is_read_only=True), OccurrenceListField('modified_occurrences', field_uri='calendar:ModifiedOccurrences', value_cls=Occurrence, is_read_only=True), OccurrenceListField('deleted_occurrences', field_uri='calendar:DeletedOccurrences', value_cls=DeletedOccurrence, is_read_only=True), TimeZoneField('_meeting_timezone', field_uri='calendar:MeetingTimeZone', deprecated_from=EXCHANGE_2010, is_searchable=False), TimeZoneField('_start_timezone', field_uri='calendar:StartTimeZone', supported_from=EXCHANGE_2010, is_searchable=False), TimeZoneField('_end_timezone', field_uri='calendar:EndTimeZone', supported_from=EXCHANGE_2010, is_searchable=False), EnumAsIntField('conference_type', field_uri='calendar:ConferenceType', enum=CONFERENCE_TYPES, min=0, default=None, is_required_after_save=True), BooleanField('allow_new_time_proposal', field_uri='calendar:AllowNewTimeProposal', default=None, is_required_after_save=True, is_searchable=False), BooleanField('is_online_meeting', field_uri='calendar:IsOnlineMeeting', default=None, is_read_only=True), URIField('meeting_workspace_url', field_uri='calendar:MeetingWorkspaceUrl'), URIField('net_show_url', field_uri='calendar:NetShowUrl'), ] FIELDS = Item.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) @classmethod def timezone_fields(cls): return [f for f in cls.FIELDS if isinstance(f, TimeZoneField)] def clean_timezone_fields(self, version): # pylint: disable=access-member-before-definition # Sets proper values on the timezone fields if they are not already set if version.build < EXCHANGE_2010: if self._meeting_timezone is None and self.start is not None: self._meeting_timezone = self.start.tzinfo self._start_timezone = None self._end_timezone = None else: self._meeting_timezone = None if self._start_timezone is None and self.start is not None: self._start_timezone = self.start.tzinfo if self._end_timezone is None and self.end is not None: self._end_timezone = self.end.tzinfo def clean(self, version=None): # pylint: disable=access-member-before-definition super().clean(version=version) if self.start and self.end and self.end < self.start: raise ValueError("'end' must be greater than 'start' (%s -> %s)" % (self.start, self.end)) if version: self.clean_timezone_fields(version=version) def cancel(self, **kwargs): return CancelCalendarItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send() def _update_fieldnames(self): update_fields = super()._update_fieldnames() if self.type == OCCURRENCE: # Some CalendarItem fields cannot be updated when the item is an occurrence. The values are empty when we # receive them so would have been updated because they are set to None. update_fields.remove('recurrence') update_fields.remove('uid') return update_fields class BaseMeetingItem(Item): """ A base class for meeting requests that share the same fields (Message, Request, Response, Cancellation) MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode Certain types are created as a side effect of doing something else. Meeting messages, for example, are created when you send a calendar item to attendees; they are not explicitly created. Therefore BaseMeetingItem inherits from EWSElement has no save() or send() method """ LOCAL_FIELDS = Message.LOCAL_FIELDS[:-2] + [ AssociatedCalendarItemIdField('associated_calendar_item_id', field_uri='meeting:AssociatedCalendarItemId', value_cls=AssociatedCalendarItemId), BooleanField('is_delegated', field_uri='meeting:IsDelegated', is_read_only=True, default=False), BooleanField('is_out_of_date', field_uri='meeting:IsOutOfDate', is_read_only=True, default=False), BooleanField('has_been_processed', field_uri='meeting:HasBeenProcessed', is_read_only=True, default=False), ChoiceField('response_type', field_uri='meeting:ResponseType', choices={Choice('Unknown'), Choice('Organizer'), Choice('Tentative'), Choice('Accept'), Choice('Decline'), Choice('NoResponseReceived')}, is_required=True, default='Unknown'), ] FIELDS = Item.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) class MeetingRequest(BaseMeetingItem, AcceptDeclineMixIn): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingrequest """ ELEMENT_NAME = 'MeetingRequest' LOCAL_FIELDS = [ ChoiceField('meeting_request_type', field_uri='meetingRequest:MeetingRequestType', choices={Choice('FullUpdate'), Choice('InformationalUpdate'), Choice('NewMeetingRequest'), Choice('None'), Choice('Outdated'), Choice('PrincipalWantsCopy'), Choice('SilentUpdate')}, default='None'), ChoiceField('intended_free_busy_status', field_uri='meetingRequest:IntendedFreeBusyStatus', choices={ Choice('Free'), Choice('Tentative'), Choice('Busy'), Choice('OOF'), Choice('NoData')}, is_required=True, default='Busy'), ] + [f for f in CalendarItem.LOCAL_FIELDS[1:] if f.name != 'is_response_requested'] # FIELDS on this element are shuffled compared to other elements culture_idx = None for i, field in enumerate(Item.FIELDS): if field.name == 'culture': culture_idx = i break FIELDS = Item.FIELDS[:culture_idx + 1] + BaseMeetingItem.LOCAL_FIELDS + LOCAL_FIELDS + Item.FIELDS[culture_idx + 1:] __slots__ = tuple(f.name for f in LOCAL_FIELDS) class MeetingMessage(BaseMeetingItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingmessage""" # TODO: Untested - not sure if this is ever used ELEMENT_NAME = 'MeetingMessage' # FIELDS on this element are shuffled compared to other elements culture_idx = None for i, field in enumerate(Item.FIELDS): if field.name == 'culture': culture_idx = i break FIELDS = Item.FIELDS[:culture_idx + 1] + BaseMeetingItem.LOCAL_FIELDS + Item.FIELDS[culture_idx + 1:] __slots__ = tuple() class MeetingResponse(BaseMeetingItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingresponse""" ELEMENT_NAME = 'MeetingResponse' LOCAL_FIELDS = [ MailboxField('received_by', field_uri='message:ReceivedBy', is_read_only=True), MailboxField('received_representing', field_uri='message:ReceivedRepresenting', is_read_only=True), ] # FIELDS on this element are shuffled compared to other elements culture_idx = None for i, field in enumerate(Item.FIELDS): if field.name == 'culture': culture_idx = i effective_rights_idx = culture_idx + 1 FIELDS = Item.FIELDS[:culture_idx + 1] \ + BaseMeetingItem.LOCAL_FIELDS \ + Item.FIELDS[effective_rights_idx:effective_rights_idx + 1] \ + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) class MeetingCancellation(BaseMeetingItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingcancellation""" ELEMENT_NAME = 'MeetingCancellation' __slots__ = tuple() class BaseMeetingReplyItem(BaseItem): """Base class for meeting request reply items that share the same fields (Accept, TentativelyAccept, Decline)""" FIELDS = [ CharField('item_class', field_uri='item:ItemClass', is_read_only=True), ChoiceField('sensitivity', field_uri='item:Sensitivity', choices={ Choice('Normal'), Choice('Personal'), Choice('Private'), Choice('Confidential') }, is_required=True, default='Normal'), BodyField('body', field_uri='item:Body'), # Accepts and returns Body or HTMLBody instances AttachmentField('attachments', field_uri='item:Attachments'), # ItemAttachment or FileAttachment MessageHeaderField('headers', field_uri='item:InternetMessageHeaders', is_read_only=True), ] + Message.LOCAL_FIELDS[:6] + [ ReferenceItemIdField('reference_item_id', field_uri='item:ReferenceItemId', value_cls=ReferenceItemId), MailboxField('received_by', field_uri='message:ReceivedBy', is_read_only=True), MailboxField('received_representing', field_uri='message:ReceivedRepresenting', is_read_only=True), DateTimeField('proposed_start', field_uri='meeting:ProposedStart', supported_from=EXCHANGE_2013), DateTimeField('proposed_end', field_uri='meeting:ProposedEnd', supported_from=EXCHANGE_2013), ] __slots__ = tuple(f.name for f in FIELDS) def send(self, message_disposition=SEND_AND_SAVE_COPY): if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) res = self.account.bulk_create(items=[self], folder=self.folder, message_disposition=message_disposition) for r_item in res: if isinstance(r_item, Exception): raise r_item return res class AcceptItem(BaseMeetingReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/acceptitem""" ELEMENT_NAME = 'AcceptItem' __slots__ = tuple() class TentativelyAcceptItem(BaseMeetingReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/tentativelyacceptitem""" ELEMENT_NAME = 'TentativelyAcceptItem' __slots__ = tuple() class DeclineItem(BaseMeetingReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/declineitem""" ELEMENT_NAME = 'DeclineItem' __slots__ = tuple() class CancelCalendarItem(BaseReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/cancelcalendaritem""" ELEMENT_NAME = 'CancelCalendarItem' FIELDS = [f for f in BaseReplyItem.FIELDS if f.name != 'author'] __slots__ = tuple() exchangelib-3.1.1/exchangelib/items/contact.py000066400000000000000000000155421361226005600213740ustar00rootroot00000000000000import logging from ..fields import BooleanField, Base64Field, TextField, ChoiceField, URIField, DateTimeField, PhoneNumberField, \ EmailAddressesField, PhysicalAddressField, Choice, MemberListField, CharField, TextListField, EmailAddressField from ..properties import PersonaId, IdChangeKeyMixIn from ..version import EXCHANGE_2010, EXCHANGE_2013 from .item import Item log = logging.getLogger(__name__) class Contact(Item): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contact """ ELEMENT_NAME = 'Contact' LOCAL_FIELDS = [ TextField('file_as', field_uri='contacts:FileAs'), ChoiceField('file_as_mapping', field_uri='contacts:FileAsMapping', choices={ Choice('None'), Choice('LastCommaFirst'), Choice('FirstSpaceLast'), Choice('Company'), Choice('LastCommaFirstCompany'), Choice('CompanyLastFirst'), Choice('LastFirst'), Choice('LastFirstCompany'), Choice('CompanyLastCommaFirst'), Choice('LastFirstSuffix'), Choice('LastSpaceFirstCompany'), Choice('CompanyLastSpaceFirst'), Choice('LastSpaceFirst'), Choice('DisplayName'), Choice('FirstName'), Choice('LastFirstMiddleSuffix'), Choice('LastName'), Choice('Empty'), }), TextField('display_name', field_uri='contacts:DisplayName', is_required=True), CharField('given_name', field_uri='contacts:GivenName'), TextField('initials', field_uri='contacts:Initials'), CharField('middle_name', field_uri='contacts:MiddleName'), TextField('nickname', field_uri='contacts:Nickname'), # Placeholder for CompleteName TextField('company_name', field_uri='contacts:CompanyName'), EmailAddressesField('email_addresses', field_uri='contacts:EmailAddress'), PhysicalAddressField('physical_addresses', field_uri='contacts:PhysicalAddress'), PhoneNumberField('phone_numbers', field_uri='contacts:PhoneNumber'), TextField('assistant_name', field_uri='contacts:AssistantName'), DateTimeField('birthday', field_uri='contacts:Birthday'), URIField('business_homepage', field_uri='contacts:BusinessHomePage'), TextListField('children', field_uri='contacts:Children'), TextListField('companies', field_uri='contacts:Companies', is_searchable=False), ChoiceField('contact_source', field_uri='contacts:ContactSource', choices={ Choice('Store'), Choice('ActiveDirectory') }, is_read_only=True), TextField('department', field_uri='contacts:Department'), TextField('generation', field_uri='contacts:Generation'), CharField('im_addresses', field_uri='contacts:ImAddresses', is_read_only=True), TextField('job_title', field_uri='contacts:JobTitle'), TextField('manager', field_uri='contacts:Manager'), TextField('mileage', field_uri='contacts:Mileage'), TextField('office', field_uri='contacts:OfficeLocation'), ChoiceField('postal_address_index', field_uri='contacts:PostalAddressIndex', choices={ Choice('Business'), Choice('Home'), Choice('Other'), Choice('None') }, default='None', is_required_after_save=True), TextField('profession', field_uri='contacts:Profession'), TextField('spouse_name', field_uri='contacts:SpouseName'), CharField('surname', field_uri='contacts:Surname'), DateTimeField('wedding_anniversary', field_uri='contacts:WeddingAnniversary'), BooleanField('has_picture', field_uri='contacts:HasPicture', supported_from=EXCHANGE_2010, is_read_only=True), TextField('phonetic_full_name', field_uri='contacts:PhoneticFullName', supported_from=EXCHANGE_2013, is_read_only=True), TextField('phonetic_first_name', field_uri='contacts:PhoneticFirstName', supported_from=EXCHANGE_2013, is_read_only=True), TextField('phonetic_last_name', field_uri='contacts:PhoneticLastName', supported_from=EXCHANGE_2013, is_read_only=True), EmailAddressField('email_alias', field_uri='contacts:Alias', is_read_only=True), # 'notes' is documented in MSDN but apparently unused. Writing to it raises ErrorInvalidPropertyRequest. OWA # put entries into the 'notes' form field into the 'body' field. CharField('notes', field_uri='contacts:Notes', supported_from=EXCHANGE_2013, is_read_only=True), # 'photo' is documented in MSDN but apparently unused. Writing to it raises ErrorInvalidPropertyRequest. OWA # adds photos as FileAttachments on the contact item (with 'is_contact_photo=True'), which automatically flips # the 'has_picture' field. Base64Field('photo', field_uri='contacts:Photo', is_read_only=True), # Placeholder for UserSMIMECertificate # Placeholder for MSExchangeCertificate TextField('directory_id', field_uri='contacts:DirectoryId', supported_from=EXCHANGE_2013, is_read_only=True), # Placeholder for ManagerMailbox # Placeholder for DirectReports ] FIELDS = Item.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) class Persona(IdChangeKeyMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/persona""" ELEMENT_NAME = 'Persona' ID_ELEMENT_CLS = PersonaId LOCAL_FIELDS = [ CharField('file_as', field_uri='persona:FileAs'), CharField('display_name', field_uri='persona:DisplayName'), CharField('given_name', field_uri='persona:GivenName'), TextField('middle_name', field_uri='persona:MiddleName'), CharField('surname', field_uri='persona:Surname'), TextField('generation', field_uri='persona:Generation'), TextField('nickname', field_uri='persona:Nickname'), CharField('title', field_uri='persona:Title'), TextField('department', field_uri='persona:Department'), CharField('company_name', field_uri='persona:CompanyName'), CharField('im_address', field_uri='persona:ImAddress'), TextField('initials', field_uri='persona:Initials'), ] FIELDS = IdChangeKeyMixIn.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) class DistributionList(Item): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distributionlist """ ELEMENT_NAME = 'DistributionList' LOCAL_FIELDS = [ CharField('display_name', field_uri='contacts:DisplayName', is_required=True), CharField('file_as', field_uri='contacts:FileAs', is_read_only=True), ChoiceField('contact_source', field_uri='contacts:ContactSource', choices={ Choice('Store'), Choice('ActiveDirectory') }, is_read_only=True), MemberListField('members', field_uri='distributionlist:Members'), ] FIELDS = Item.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) exchangelib-3.1.1/exchangelib/items/item.py000066400000000000000000000515311361226005600206750ustar00rootroot00000000000000import logging from ..fields import BooleanField, IntegerField, TextField, CharListField, ChoiceField, URIField, BodyField, \ DateTimeField, MessageHeaderField, AttachmentField, Choice, EWSElementField, EffectiveRightsField, CultureField, \ CharField, MimeContentField from ..properties import ConversationId, ParentFolderId, ReferenceItemId from ..util import is_iterable from ..version import EXCHANGE_2010, EXCHANGE_2013 from .base import BaseItem, SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY log = logging.getLogger(__name__) # SendMeetingInvitations values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem # SendMeetingInvitationsOrCancellations. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem # SendMeetingCancellations values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem SEND_TO_NONE = 'SendToNone' SEND_ONLY_TO_ALL = 'SendOnlyToAll' SEND_ONLY_TO_CHANGED = 'SendOnlyToChanged' SEND_TO_ALL_AND_SAVE_COPY = 'SendToAllAndSaveCopy' SEND_TO_CHANGED_AND_SAVE_COPY = 'SendToChangedAndSaveCopy' SEND_MEETING_INVITATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_TO_ALL_AND_SAVE_COPY) SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_ONLY_TO_CHANGED, SEND_TO_ALL_AND_SAVE_COPY, SEND_TO_CHANGED_AND_SAVE_COPY) SEND_MEETING_CANCELLATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_TO_ALL_AND_SAVE_COPY) # AffectedTaskOccurrences values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem ALL_OCCURRENCIES = 'AllOccurrences' SPECIFIED_OCCURRENCE_ONLY = 'SpecifiedOccurrenceOnly' AFFECTED_TASK_OCCURRENCES_CHOICES = (ALL_OCCURRENCIES, SPECIFIED_OCCURRENCE_ONLY) # ConflictResolution values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem NEVER_OVERWRITE = 'NeverOverwrite' AUTO_RESOLVE = 'AutoResolve' ALWAYS_OVERWRITE = 'AlwaysOverwrite' CONFLICT_RESOLUTION_CHOICES = (NEVER_OVERWRITE, AUTO_RESOLVE, ALWAYS_OVERWRITE) # DeleteType values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem HARD_DELETE = 'HardDelete' SOFT_DELETE = 'SoftDelete' MOVE_TO_DELETED_ITEMS = 'MoveToDeletedItems' DELETE_TYPE_CHOICES = (HARD_DELETE, SOFT_DELETE, MOVE_TO_DELETED_ITEMS) class Item(BaseItem): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/item """ ELEMENT_NAME = 'Item' LOCAL_FIELDS = [ MimeContentField('mime_content', field_uri='item:MimeContent', is_read_only_after_send=True), EWSElementField('parent_folder_id', field_uri='item:ParentFolderId', value_cls=ParentFolderId, is_read_only=True), CharField('item_class', field_uri='item:ItemClass', is_read_only=True), CharField('subject', field_uri='item:Subject'), ChoiceField('sensitivity', field_uri='item:Sensitivity', choices={ Choice('Normal'), Choice('Personal'), Choice('Private'), Choice('Confidential') }, is_required=True, default='Normal'), TextField('text_body', field_uri='item:TextBody', is_read_only=True, supported_from=EXCHANGE_2013), BodyField('body', field_uri='item:Body'), # Accepts and returns Body or HTMLBody instances AttachmentField('attachments', field_uri='item:Attachments'), # ItemAttachment or FileAttachment DateTimeField('datetime_received', field_uri='item:DateTimeReceived', is_read_only=True), IntegerField('size', field_uri='item:Size', is_read_only=True), # Item size in bytes CharListField('categories', field_uri='item:Categories'), ChoiceField('importance', field_uri='item:Importance', choices={ Choice('Low'), Choice('Normal'), Choice('High') }, is_required=True, default='Normal'), TextField('in_reply_to', field_uri='item:InReplyTo'), BooleanField('is_submitted', field_uri='item:IsSubmitted', is_read_only=True), BooleanField('is_draft', field_uri='item:IsDraft', is_read_only=True), BooleanField('is_from_me', field_uri='item:IsFromMe', is_read_only=True), BooleanField('is_resend', field_uri='item:IsResend', is_read_only=True), BooleanField('is_unmodified', field_uri='item:IsUnmodified', is_read_only=True), MessageHeaderField('headers', field_uri='item:InternetMessageHeaders', is_read_only=True), DateTimeField('datetime_sent', field_uri='item:DateTimeSent', is_read_only=True), DateTimeField('datetime_created', field_uri='item:DateTimeCreated', is_read_only=True), # Placeholder for ResponseObjects DateTimeField('reminder_due_by', field_uri='item:ReminderDueBy', is_required_after_save=True, is_searchable=False), BooleanField('reminder_is_set', field_uri='item:ReminderIsSet', is_required=True, default=False), IntegerField('reminder_minutes_before_start', field_uri='item:ReminderMinutesBeforeStart', is_required_after_save=True, min=0, default=0), CharField('display_cc', field_uri='item:DisplayCc', is_read_only=True), CharField('display_to', field_uri='item:DisplayTo', is_read_only=True), BooleanField('has_attachments', field_uri='item:HasAttachments', is_read_only=True), # ExtendedProperty fields go here CultureField('culture', field_uri='item:Culture', is_required_after_save=True, is_searchable=False), EffectiveRightsField('effective_rights', field_uri='item:EffectiveRights', is_read_only=True), CharField('last_modified_name', field_uri='item:LastModifiedName', is_read_only=True), DateTimeField('last_modified_time', field_uri='item:LastModifiedTime', is_read_only=True), BooleanField('is_associated', field_uri='item:IsAssociated', is_read_only=True, supported_from=EXCHANGE_2010), URIField('web_client_read_form_query_string', field_uri='item:WebClientReadFormQueryString', is_read_only=True, supported_from=EXCHANGE_2010), URIField('web_client_edit_form_query_string', field_uri='item:WebClientEditFormQueryString', is_read_only=True, supported_from=EXCHANGE_2010), EWSElementField('conversation_id', field_uri='item:ConversationId', value_cls=ConversationId, is_read_only=True, supported_from=EXCHANGE_2010), BodyField('unique_body', field_uri='item:UniqueBody', is_read_only=True, supported_from=EXCHANGE_2010), ] FIELDS = LOCAL_FIELDS[0:1] + BaseItem.FIELDS + LOCAL_FIELDS[1:] __slots__ = tuple(f.name for f in LOCAL_FIELDS) # Used to register extended properties INSERT_AFTER_FIELD = 'has_attachments' def __init__(self, **kwargs): super().__init__(**kwargs) # pylint: disable=access-member-before-definition if self.attachments: for a in self.attachments: if a.parent_item: if a.parent_item is not self: raise ValueError("'parent_item' of attachment %s must point to this item" % a) else: a.parent_item = self self.attach(self.attachments) else: self.attachments = [] def save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE): if self.id: item_id, changekey = self._update( update_fieldnames=update_fields, message_disposition=SAVE_ONLY, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations ) if self.id != item_id: raise ValueError("'id' mismatch in returned update response") # Don't check that changekeys are different. No-op saves will sometimes leave the changekey intact self.changekey = changekey else: if update_fields: raise ValueError("'update_fields' is only valid for updates") tmp_attachments = None if self.account and self.account.version.build < EXCHANGE_2010 and self.attachments: # Exchange 2007 can't save attachments immediately. You need to first save, then attach. Store # the attachment of this item temporarily and attach later. tmp_attachments, self.attachments = self.attachments, [] item = self._create(message_disposition=SAVE_ONLY, send_meeting_invitations=send_meeting_invitations) self.id, self.changekey = item.id, item.changekey for old_att, new_att in zip(self.attachments, item.attachments): if old_att.attachment_id is not None: raise ValueError("Old 'attachment_id' is not empty") if new_att.attachment_id is None: raise ValueError("New 'attachment_id' is empty") old_att.attachment_id = new_att.attachment_id if tmp_attachments: # Exchange 2007 workaround. See above self.attach(tmp_attachments) return self def _create(self, message_disposition, send_meeting_invitations): if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) # bulk_create() returns an Item because we want to return the ID of both the main item *and* attachments res = self.account.bulk_create( items=[self], folder=self.folder, message_disposition=message_disposition, send_meeting_invitations=send_meeting_invitations) if message_disposition in (SEND_ONLY, SEND_AND_SAVE_COPY): if res: raise ValueError('Got a response in non-save mode') return None if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] return res[0] def _update_fieldnames(self): from .contact import Contact, DistributionList # Return the list of fields we are allowed to update update_fieldnames = [] for f in self.supported_fields(version=self.account.version): if f.name == 'attachments': # Attachments are handled separately after item creation continue if f.is_read_only: # These cannot be changed continue if f.is_required or f.is_required_after_save: if getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)): # These are required and cannot be deleted continue if not self.is_draft and f.is_read_only_after_send: # These cannot be changed when the item is no longer a draft continue if f.name == 'message_id' and f.is_read_only_after_send: # 'message_id' doesn't support updating, no matter the draft status continue if f.name == 'mime_content' and isinstance(self, (Contact, DistributionList)): # Contact and DistributionList don't support updating mime_content, no matter the draft status continue update_fieldnames.append(f.name) return update_fieldnames def _update(self, update_fieldnames, message_disposition, conflict_resolution, send_meeting_invitations): if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) if not self.changekey: raise ValueError('%s must have changekey' % self.__class__.__name__) if not update_fieldnames: # The fields to update was not specified explicitly. Update all fields where update is possible update_fieldnames = self._update_fieldnames() # bulk_update() returns a tuple res = self.account.bulk_update( items=[(self, update_fieldnames)], message_disposition=message_disposition, conflict_resolution=conflict_resolution, send_meeting_invitations_or_cancellations=send_meeting_invitations) if message_disposition == SEND_AND_SAVE_COPY: if res: raise ValueError('Got a response in non-save mode') return None if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] return res[0] def refresh(self): # Updates the item based on fresh data from EWS if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) if not self.id: raise ValueError('%s must have an ID' % self.__class__.__name__) res = list(self.account.fetch(ids=[self])) if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] fresh_item = res[0] if self.id != fresh_item.id: raise ValueError('Unexpected ID of fresh item') for f in self.FIELDS: setattr(self, f.name, getattr(fresh_item, f.name)) # 'parent_item' should point to 'self', not 'fresh_item'. That way, 'fresh_item' can be garbage collected. for a in self.attachments: a.parent_item = self del fresh_item def copy(self, to_folder): if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) if not self.id: raise ValueError('%s must have an ID' % self.__class__.__name__) res = self.account.bulk_copy(ids=[self], to_folder=to_folder) if not res: # Assume 'to_folder' is a public folder or a folder in a different mailbox return if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] return res[0] def move(self, to_folder): if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) if not self.id: raise ValueError('%s must have an ID' % self.__class__.__name__) res = self.account.bulk_move(ids=[self], to_folder=to_folder) if not res: # Assume 'to_folder' is a public folder or a folder in a different mailbox self.id, self.changekey = None, None return if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] self.id, self.changekey = res[0] self.folder = to_folder def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, suppress_read_receipts=True): # Delete and move to the trash folder. self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations, affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) self.id, self.changekey = None, None self.folder = self.account.trash def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, suppress_read_receipts=True): # Delete and move to the dumpster, if it is enabled. self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations, affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) self.id, self.changekey = None, None self.folder = self.account.recoverable_items_deletions def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, suppress_read_receipts=True): # Remove the item permanently. No copies are stored anywhere. self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations, affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) self.id, self.changekey, self.folder = None, None, None def _delete(self, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) if not self.id: raise ValueError('%s must have an ID' % self.__class__.__name__) res = self.account.bulk_delete( ids=[self], delete_type=delete_type, send_meeting_cancellations=send_meeting_cancellations, affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] def archive(self, to_folder): if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) if not self.id: raise ValueError('%s must have an ID' % self.__class__.__name__) res = self.account.bulk_archive(ids=[self], to_folder=to_folder) if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] return res[0] def attach(self, attachments): """Add an attachment, or a list of attachments, to this item. If the item has already been saved, the attachments will be created on the server immediately. If the item has not yet been saved, the attachments will be created on the server when the item is saved. Adding attachments to an existing item will update the changekey of the item. """ if not is_iterable(attachments, generators_allowed=True): attachments = [attachments] for a in attachments: if not a.parent_item: a.parent_item = self if self.id and not a.attachment_id: # Already saved object. Attach the attachment server-side now a.attach() if a not in self.attachments: self.attachments.append(a) def detach(self, attachments): """Remove an attachment, or a list of attachments, from this item. If the item has already been saved, the attachments will be deleted on the server immediately. If the item has not yet been saved, the attachments will simply not be created on the server the item is saved. Removing attachments from an existing item will update the changekey of the item. """ if not is_iterable(attachments, generators_allowed=True): attachments = [attachments] if attachments is self.attachments: # Don't remove from the same list we are iterating attachments = list(attachments) for a in attachments: if a.parent_item is not self: raise ValueError('Attachment does not belong to this item') if self.id: # Item is already created. Detach the attachment server-side now a.detach() if a in self.attachments: self.attachments.remove(a) def create_forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None): from .message import ForwardItem if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) if not self.id: raise ValueError('%s must have an ID' % self.__class__.__name__) return ForwardItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), subject=subject, new_body=body, to_recipients=to_recipients, cc_recipients=cc_recipients, bcc_recipients=bcc_recipients, ) def forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None): self.create_forward( subject, body, to_recipients, cc_recipients, bcc_recipients, ).send() class BulkCreateResult(BaseItem): """A dummy class to store return values from a CreateItem service call""" LOCAL_FIELDS = [ AttachmentField('attachments', field_uri='item:Attachments'), # ItemAttachment or FileAttachment ] FIELDS = BaseItem.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) def __init__(self, **kwargs): super().__init__(**kwargs) # pylint: disable=access-member-before-definition if self.attachments is None: self.attachments = [] exchangelib-3.1.1/exchangelib/items/message.py000066400000000000000000000210601361226005600213550ustar00rootroot00000000000000import logging from ..fields import BooleanField, Base64Field, TextField, MailboxField, MailboxListField, CharField from ..properties import ReferenceItemId from ..version import EXCHANGE_2010 from .base import BaseReplyItem from .item import Item, AUTO_RESOLVE, SEND_TO_NONE, SEND_ONLY, SEND_AND_SAVE_COPY log = logging.getLogger(__name__) class Message(Item): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/message-ex15websvcsotherref """ ELEMENT_NAME = 'Message' LOCAL_FIELDS = [ MailboxField('sender', field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True), MailboxListField('to_recipients', field_uri='message:ToRecipients', is_read_only_after_send=True, is_searchable=False), MailboxListField('cc_recipients', field_uri='message:CcRecipients', is_read_only_after_send=True, is_searchable=False), MailboxListField('bcc_recipients', field_uri='message:BccRecipients', is_read_only_after_send=True, is_searchable=False), BooleanField('is_read_receipt_requested', field_uri='message:IsReadReceiptRequested', is_required=True, default=False, is_read_only_after_send=True), BooleanField('is_delivery_receipt_requested', field_uri='message:IsDeliveryReceiptRequested', is_required=True, default=False, is_read_only_after_send=True), Base64Field('conversation_index', field_uri='message:ConversationIndex', is_read_only=True), CharField('conversation_topic', field_uri='message:ConversationTopic', is_read_only=True), # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. MailboxField('author', field_uri='message:From', is_read_only_after_send=True), CharField('message_id', field_uri='message:InternetMessageId', is_read_only_after_send=True), BooleanField('is_read', field_uri='message:IsRead', is_required=True, default=False), BooleanField('is_response_requested', field_uri='message:IsResponseRequested', default=False, is_required=True), TextField('references', field_uri='message:References'), MailboxListField('reply_to', field_uri='message:ReplyTo', is_read_only_after_send=True, is_searchable=False), MailboxField('received_by', field_uri='message:ReceivedBy', is_read_only=True), MailboxField('received_representing', field_uri='message:ReceivedRepresenting', is_read_only=True), # Placeholder for ReminderMessageData ] FIELDS = Item.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE): # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does # not yet exist in EWS. if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) if self.id: res = self.account.bulk_send(ids=[self], save_copy=save_copy, copy_to_folder=copy_to_folder) if len(res) != 1: raise ValueError('Expected result length 1, but got %s' % res) if isinstance(res[0], Exception): raise res[0] # The item will be deleted from the original folder self.id, self.changekey = None, None self.folder = copy_to_folder return None # New message if copy_to_folder: if not save_copy: raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set") # This would better be done via send_and_save() but lets just support it here self.folder = copy_to_folder return self.send_and_save(conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations) if self.account.version.build < EXCHANGE_2010 and self.attachments: # Exchange 2007 can't send attachments immediately. You need to first save, then attach, then send. # This is done in send_and_save(). send() will delete the item again. self.send_and_save(conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations) return None res = self._create(message_disposition=SEND_ONLY, send_meeting_invitations=send_meeting_invitations) if res: raise ValueError('Unexpected response in send-only mode') return None def send_and_save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations ) else: if self.account.version.build < EXCHANGE_2010 and self.attachments: # Exchange 2007 can't send-and-save attachments immediately. You need to first save, then attach, then # send. This is done in save(). self.save(update_fields=update_fields, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations) self.send(save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations) else: res = self._create( message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations ) if res: raise ValueError('Unexpected response in send-only mode') def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) if not self.id: raise ValueError('%s must have an ID' % self.__class__.__name__) if to_recipients is None: if not self.author: raise ValueError("'to_recipients' must be set when message has no 'author'") to_recipients = [self.author] return ReplyToItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), subject=subject, new_body=body, to_recipients=to_recipients, cc_recipients=cc_recipients, bcc_recipients=bcc_recipients, ) def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): self.create_reply( subject, body, to_recipients, cc_recipients, bcc_recipients ).send() def create_reply_all(self, subject, body): if not self.account: raise ValueError('%s must have an account' % self.__class__.__name__) if not self.id: raise ValueError('%s must have an ID' % self.__class__.__name__) to_recipients = list(self.to_recipients) if self.to_recipients else [] if self.author: to_recipients.append(self.author) return ReplyAllToItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), subject=subject, new_body=body, to_recipients=to_recipients, cc_recipients=self.cc_recipients, bcc_recipients=self.bcc_recipients, ) def reply_all(self, subject, body): self.create_reply_all(subject, body).send() class ReplyToItem(BaseReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/replytoitem""" ELEMENT_NAME = 'ReplyToItem' __slots__ = tuple() class ReplyAllToItem(BaseReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/replyalltoitem""" ELEMENT_NAME = 'ReplyAllToItem' __slots__ = tuple() class ForwardItem(BaseReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/forwarditem""" ELEMENT_NAME = 'ForwardItem' __slots__ = tuple() exchangelib-3.1.1/exchangelib/items/post.py000066400000000000000000000026711361226005600207250ustar00rootroot00000000000000import logging from ..fields import TextField, BodyField, DateTimeField, MailboxField from .item import Item from .message import Message log = logging.getLogger(__name__) class PostItem(Item): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postitem """ ELEMENT_NAME = 'PostItem' LOCAL_FIELDS = Message.LOCAL_FIELDS[6:11] + [ DateTimeField('posted_time', field_uri='postitem:PostedTime', is_read_only=True), TextField('references', field_uri='message:References'), MailboxField('sender', field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True), ] FIELDS = Item.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) class PostReplyItem(Item): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postreplyitem """ # TODO: Untested and unfinished. ELEMENT_NAME = 'PostReplyItem' LOCAL_FIELDS = Message.LOCAL_FIELDS + [ BodyField('new_body', field_uri='NewBodyContent'), # Accepts and returns Body or HTMLBody instances ] # FIELDS on this element only has Item fields up to 'culture' culture_idx = None for i, field in enumerate(Item.FIELDS): if field.name == 'culture': culture_idx = i break FIELDS = Item.FIELDS[:culture_idx + 1] + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) exchangelib-3.1.1/exchangelib/items/task.py000066400000000000000000000122741361226005600207020ustar00rootroot00000000000000from decimal import Decimal import logging from ..ewsdatetime import UTC_NOW from ..fields import BooleanField, IntegerField, DecimalField, TextField, ChoiceField, DateTimeField, Choice, \ CharField, TextListField from .item import Item log = logging.getLogger(__name__) class Task(Item): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/task """ ELEMENT_NAME = 'Task' NOT_STARTED = 'NotStarted' COMPLETED = 'Completed' LOCAL_FIELDS = [ IntegerField('actual_work', field_uri='task:ActualWork', min=0), DateTimeField('assigned_time', field_uri='task:AssignedTime', is_read_only=True), TextField('billing_information', field_uri='task:BillingInformation'), IntegerField('change_count', field_uri='task:ChangeCount', is_read_only=True, min=0), TextListField('companies', field_uri='task:Companies'), # 'complete_date' can be set, but is ignored by the server, which sets it to now() DateTimeField('complete_date', field_uri='task:CompleteDate', is_read_only=True), TextListField('contacts', field_uri='task:Contacts'), ChoiceField('delegation_state', field_uri='task:DelegationState', choices={ Choice('NoMatch'), Choice('OwnNew'), Choice('Owned'), Choice('Accepted'), Choice('Declined'), Choice('Max') }, is_read_only=True), CharField('delegator', field_uri='task:Delegator', is_read_only=True), DateTimeField('due_date', field_uri='task:DueDate'), BooleanField('is_editable', field_uri='task:IsAssignmentEditable', is_read_only=True), BooleanField('is_complete', field_uri='task:IsComplete', is_read_only=True), BooleanField('is_recurring', field_uri='task:IsRecurring', is_read_only=True), BooleanField('is_team_task', field_uri='task:IsTeamTask', is_read_only=True), TextField('mileage', field_uri='task:Mileage'), CharField('owner', field_uri='task:Owner', is_read_only=True), DecimalField('percent_complete', field_uri='task:PercentComplete', is_required=True, default=Decimal(0.0), min=Decimal(0), max=Decimal(100), is_searchable=False), # Placeholder for Recurrence DateTimeField('start_date', field_uri='task:StartDate'), ChoiceField('status', field_uri='task:Status', choices={ Choice(NOT_STARTED), Choice('InProgress'), Choice(COMPLETED), Choice('WaitingOnOthers'), Choice('Deferred') }, is_required=True, is_searchable=False, default=NOT_STARTED), CharField('status_description', field_uri='task:StatusDescription', is_read_only=True), IntegerField('total_work', field_uri='task:TotalWork', min=0), ] FIELDS = Item.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) def clean(self, version=None): # pylint: disable=access-member-before-definition super().clean(version=version) if self.due_date and self.start_date and self.due_date < self.start_date: log.warning("'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'", self.due_date, self.start_date) self.due_date = self.start_date if self.complete_date: if self.status != self.COMPLETED: log.warning("'status' must be '%s' when 'complete_date' is set (%s). Resetting", self.COMPLETED, self.status) self.status = self.COMPLETED now = UTC_NOW() if (self.complete_date - now).total_seconds() > 120: # Reset complete_date values that are in the future # 'complete_date' can be set automatically by the server. Allow some grace between local and server time log.warning("'complete_date' must be in the past (%s vs %s). Resetting", self.complete_date, now) self.complete_date = now if self.start_date and self.complete_date < self.start_date: log.warning("'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", self.complete_date, self.start_date) self.complete_date = self.start_date if self.percent_complete is not None: if self.status == self.COMPLETED and self.percent_complete != Decimal(100): # percent_complete must be 100% if task is complete log.warning("'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", self.COMPLETED, self.percent_complete) self.percent_complete = Decimal(100) elif self.status == self.NOT_STARTED and self.percent_complete != Decimal(0): # percent_complete must be 0% if task is not started log.warning("'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", self.NOT_STARTED, self.percent_complete) self.percent_complete = Decimal(0) def complete(self): # pylint: disable=access-member-before-definition # A helper method to mark a task as complete on the server self.status = Task.COMPLETED self.percent_complete = Decimal(100) self.save() exchangelib-3.1.1/exchangelib/properties.py000066400000000000000000001441641361226005600210170ustar00rootroot00000000000000import abc import binascii import codecs import datetime from inspect import getmro import logging import struct from threading import Lock from .fields import SubField, TextField, EmailAddressField, ChoiceField, DateTimeField, EWSElementField, MailboxField, \ Choice, BooleanField, IdField, ExtendedPropertyField, IntegerField, TimeField, EnumField, CharField, EmailField, \ EWSElementListField, EnumListField, FreeBusyStatusField, UnknownEntriesField, MessageField, RecipientAddressField, \ WEEKDAY_NAMES, FieldPath, Field from .util import get_xml_attr, create_element, set_xml_value, value_to_xml_text, MNS, TNS from .version import Version, EXCHANGE_2013 log = logging.getLogger(__name__) class InvalidField(ValueError): pass class InvalidFieldForVersion(ValueError): pass class Body(str): """Helper to mark the 'body' field as a complex attribute. MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/body """ body_type = 'Text' def __add__(self, other): # Make sure Body('') + 'foo' returns a Body type return self.__class__(super().__add__(other)) def __mod__(self, other): # Make sure Body('%s') % 'foo' returns a Body type return self.__class__(super().__mod__(other)) def format(self, *args, **kwargs): # Make sure Body('{}').format('foo') returns a Body type return self.__class__(super().format(*args, **kwargs)) class HTMLBody(Body): """Helper to mark the 'body' field as a complex attribute. MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/body """ body_type = 'HTML' class UID(bytes): """Helper class to encode Calendar UIDs. See issue #453. Example: class GlobalObjectId(ExtendedProperty): distinguished_property_set_id = 'Meeting' property_id = 3 property_type = 'Binary' CalendarItem.register('global_object_id', GlobalObjectId) account.calendar.filter(global_object_id=GlobalObjectId(UID('261cbc18-1f65-5a0a-bd11-23b1e224cc2f'))) """ _HEADER = binascii.hexlify(bytearray(( 0x04, 0x00, 0x00, 0x00, 0x82, 0x00, 0xE0, 0x00, 0x74, 0xC5, 0xB7, 0x10, 0x1A, 0x82, 0xE0, 0x08))) _EXCEPTION_REPLACEMENT_TIME = binascii.hexlify(bytearray(( 0, 0, 0, 0))) _CREATION_TIME = binascii.hexlify(bytearray(( 0, 0, 0, 0, 0, 0, 0, 0))) _RESERVED = binascii.hexlify(bytearray(( 0, 0, 0, 0, 0, 0, 0, 0))) # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxocal/1d3aac05-a7b9-45cc-a213-47f0a0a2c5c1 # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-asemail/e7424ddc-dd10-431e-a0b7-5c794863370e # https://stackoverflow.com/questions/42259122 # https://stackoverflow.com/questions/33757805 def __new__(cls, uid): payload = binascii.hexlify(bytearray('vCal-Uid\x01\x00\x00\x00{}\x00'.format(uid).encode('ascii'))) length = binascii.hexlify(bytearray(struct.pack(' for_year: break valid_period = period if valid_period is None: raise ValueError('No standard bias found in periods %s' % periods) return int(valid_period['bias'].total_seconds()) // 60 # Convert to minutes @staticmethod def _get_valid_transition_id(transitions, for_year): # Look through the transitions, and pick the relevant one according to the 'for_year' value valid_tg_id = None for tg_id, from_date in sorted(transitions.items()): if from_date and from_date.year > for_year: break valid_tg_id = tg_id if valid_tg_id is None: raise ValueError('No valid transition for year %s: %s' % (for_year, transitions)) return valid_tg_id @staticmethod def _get_std_and_dst(transitiongroup, periods, bias): # Return 'standard_time' and 'daylight_time' objects. We do unnecessary work here, but it keeps code simple. standard_time, daylight_time = None, None for transition in transitiongroup: period = periods[transition['to']] if len(transition.keys()) == 1: # This is a simple transition representing a timezone with no DST. Some servers don't accept TimeZone # elements without a STD and DST element (see issue #488). Return StandardTime and DaylightTime objects # with dummy values and 0 bias - this satisfies the broken servers and hopefully doesn't break the # well-behaving servers. standard_time = StandardTime(bias=0, time=datetime.time(0), occurrence=1, iso_month=1, weekday=1) daylight_time = DaylightTime(bias=0, time=datetime.time(0), occurrence=5, iso_month=12, weekday=7) continue # 'offset' is the time of day to transition, as timedelta since midnight. Must be a reasonable value if not datetime.timedelta(0) <= transition['offset'] < datetime.timedelta(days=1): raise ValueError("'offset' value %s must be be between 0 and 24 hours" % transition['offset']) transition_kwargs = dict( time=(datetime.datetime(2000, 1, 1) + transition['offset']).time(), occurrence=transition['occurrence'], iso_month=transition['iso_month'], weekday=transition['iso_weekday'], ) if period['name'] == 'Standard': transition_kwargs['bias'] = 0 standard_time = StandardTime(**transition_kwargs) continue if period['name'] == 'Daylight': dst_bias = int(period['bias'].total_seconds()) // 60 # Convert to minutes transition_kwargs['bias'] = dst_bias - bias daylight_time = DaylightTime(**transition_kwargs) continue raise ValueError('Unknown transition: %s' % transition) return standard_time, daylight_time class CalendarView(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarview""" ELEMENT_NAME = 'CalendarView' NAMESPACE = MNS FIELDS = [ DateTimeField('start', field_uri='StartDate', is_required=True, is_attribute=True), DateTimeField('end', field_uri='EndDate', is_required=True, is_attribute=True), IntegerField('max_items', field_uri='MaxEntriesReturned', min=1, is_attribute=True), ] __slots__ = tuple(f.name for f in FIELDS) def clean(self, version=None): super().clean(version=version) if self.end < self.start: raise ValueError("'start' must be before 'end'") class CalendarEventDetails(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendareventdetails""" ELEMENT_NAME = 'CalendarEventDetails' FIELDS = [ CharField('id', field_uri='ID'), CharField('subject', field_uri='Subject'), CharField('location', field_uri='Location'), BooleanField('is_meeting', field_uri='IsMeeting'), BooleanField('is_recurring', field_uri='IsRecurring'), BooleanField('is_exception', field_uri='IsException'), BooleanField('is_reminder_set', field_uri='IsReminderSet'), BooleanField('is_private', field_uri='IsPrivate'), ] __slots__ = tuple(f.name for f in FIELDS) class CalendarEvent(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarevent""" ELEMENT_NAME = 'CalendarEvent' FIELDS = [ DateTimeField('start', field_uri='StartTime'), DateTimeField('end', field_uri='EndTime'), FreeBusyStatusField('busy_type', field_uri='BusyType', is_required=True, default='Busy'), EWSElementField('details', field_uri='CalendarEventDetails', value_cls=CalendarEventDetails), ] __slots__ = tuple(f.name for f in FIELDS) class WorkingPeriod(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/workingperiod""" ELEMENT_NAME = 'WorkingPeriod' FIELDS = [ EnumListField('weekdays', field_uri='DayOfWeek', enum=WEEKDAY_NAMES, is_required=True), TimeField('start', field_uri='StartTimeInMinutes', is_required=True), TimeField('end', field_uri='EndTimeInMinutes', is_required=True), ] __slots__ = tuple(f.name for f in FIELDS) class FreeBusyView(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyview""" ELEMENT_NAME = 'FreeBusyView' NAMESPACE = MNS FIELDS = [ ChoiceField('view_type', field_uri='FreeBusyViewType', choices={ Choice('None'), Choice('MergedOnly'), Choice('FreeBusy'), Choice('FreeBusyMerged'), Choice('Detailed'), Choice('DetailedMerged'), }, is_required=True), # A string of digits. Each digit points to a position in .fields.FREE_BUSY_CHOICES CharField('merged', field_uri='MergedFreeBusy'), EWSElementListField('calendar_events', field_uri='CalendarEventArray', value_cls=CalendarEvent), # WorkingPeriod is located inside the WorkingPeriodArray element which is inside the WorkingHours element EWSElementListField('working_hours', field_uri='WorkingPeriodArray', value_cls=WorkingPeriod), # TimeZone is also inside the WorkingHours element. It contains information about the timezone which the # account is located in. EWSElementField('working_hours_timezone', field_uri='TimeZone', value_cls=TimeZone), ] __slots__ = tuple(f.name for f in FIELDS) @classmethod def from_xml(cls, elem, account): kwargs = {} working_hours_elem = elem.find('{%s}WorkingHours' % TNS) for f in cls.FIELDS: if f.name in ['working_hours', 'working_hours_timezone']: if working_hours_elem is None: continue kwargs[f.name] = f.from_xml(elem=working_hours_elem, account=account) continue kwargs[f.name] = f.from_xml(elem=elem, account=account) cls._clear(elem) return cls(**kwargs) class RoomList(Mailbox): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/roomlist""" ELEMENT_NAME = 'RoomList' NAMESPACE = MNS __slots__ = tuple() @classmethod def response_tag(cls): # In a GetRoomLists response, room lists are delivered as Address elements. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype return '{%s}Address' % TNS class Room(Mailbox): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/room""" ELEMENT_NAME = 'Room' __slots__ = tuple() @classmethod def from_xml(cls, elem, account): id_elem = elem.find('{%s}Id' % TNS) item_id_elem = id_elem.find(ItemId.response_tag()) kwargs = dict( name=get_xml_attr(id_elem, '{%s}Name' % TNS), email_address=get_xml_attr(id_elem, '{%s}EmailAddress' % TNS), mailbox_type=get_xml_attr(id_elem, '{%s}MailboxType' % TNS), item_id=ItemId.from_xml(elem=item_id_elem, account=account) if item_id_elem else None, ) cls._clear(elem) return cls(**kwargs) class Member(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/member-ex15websvcsotherref """ ELEMENT_NAME = 'Member' FIELDS = [ MailboxField('mailbox', is_required=True), ChoiceField('status', field_uri='Status', choices={ Choice('Unrecognized'), Choice('Normal'), Choice('Demoted') }, default='Normal'), ] __slots__ = tuple(f.name for f in FIELDS) def __hash__(self): # TODO: maybe take 'status' into account? return hash(self.mailbox) class UserId(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userid""" ELEMENT_NAME = 'UserId' FIELDS = [ CharField('sid', field_uri='SID'), EmailAddressField('primary_smtp_address', field_uri='PrimarySmtpAddress'), CharField('display_name', field_uri='DisplayName'), ChoiceField('distinguished_user', field_uri='DistinguishedUser', choices={ Choice('Default'), Choice('Anonymous') }), CharField('external_user_identity', field_uri='ExternalUserIdentity'), ] __slots__ = tuple(f.name for f in FIELDS) class Permission(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/permission""" ELEMENT_NAME = 'Permission' PERMISSION_ENUM = {Choice('None'), Choice('Owned'), Choice('All')} FIELDS = [ ChoiceField('permission_level', field_uri='PermissionLevel', choices={ Choice('None'), Choice('Owner'), Choice('PublishingEditor'), Choice('Editor'), Choice('PublishingAuthor'), Choice('Author'), Choice('NoneditingAuthor'), Choice('Reviewer'), Choice('Contributor'), Choice('Custom') }, default='None'), BooleanField('can_create_items', field_uri='CanCreateItems', default=False), BooleanField('can_create_subfolders', field_uri='CanCreateSubfolders', default=False), BooleanField('is_folder_owner', field_uri='IsFolderOwner', default=False), BooleanField('is_folder_visible', field_uri='IsFolderVisible', default=False), BooleanField('is_folder_contact', field_uri='IsFolderContact', default=False), ChoiceField('edit_items', field_uri='EditItems', choices=PERMISSION_ENUM, default='None'), ChoiceField('delete_items', field_uri='DeleteItems', choices=PERMISSION_ENUM, default='None'), ChoiceField('read_items', field_uri='ReadItems', choices={ Choice('None'), Choice('FullDetails') }, default='None'), EWSElementField('user_id', field_uri='UserId', value_cls=UserId, is_required=True) ] __slots__ = tuple(f.name for f in FIELDS) class CalendarPermission(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarpermission""" ELEMENT_NAME = 'Permission' PERMISSION_ENUM = {Choice('None'), Choice('Owned'), Choice('All')} FIELDS = [ ChoiceField('calendar_permission_level', field_uri='CalendarPermissionLevel', choices={ Choice('None'), Choice('Owner'), Choice('PublishingEditor'), Choice('Editor'), Choice('PublishingAuthor'), Choice('Author'), Choice('NoneditingAuthor'), Choice('Reviewer'), Choice('Contributor'), Choice('FreeBusyTimeOnly'), Choice('FreeBusyTimeAndSubjectAndLocation'), Choice('Custom') }, default='None'), ] + Permission.FIELDS[1:] __slots__ = tuple(f.name for f in FIELDS) class PermissionSet(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/permissionset-permissionsettype and https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/permissionset-calendarpermissionsettype """ # For simplicity, we implement the two distinct but equally names elements as one class. ELEMENT_NAME = 'PermissionSet' FIELDS = [ EWSElementListField('permissions', field_uri='Permissions', value_cls=Permission), EWSElementListField('calendar_permissions', field_uri='CalendarPermissions', value_cls=CalendarPermission), UnknownEntriesField('unknown_entries', field_uri='UnknownEntries'), ] __slots__ = tuple(f.name for f in FIELDS) class EffectiveRights(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/effectiverights""" ELEMENT_NAME = 'EffectiveRights' FIELDS = [ BooleanField('create_associated', field_uri='CreateAssociated', default=False), BooleanField('create_contents', field_uri='CreateContents', default=False), BooleanField('create_hierarchy', field_uri='CreateHierarchy', default=False), BooleanField('delete', field_uri='Delete', default=False), BooleanField('modify', field_uri='Modify', default=False), BooleanField('read', field_uri='Read', default=False), BooleanField('view_private_items', field_uri='ViewPrivateItems', default=False), ] __slots__ = tuple(f.name for f in FIELDS) def __contains__(self, item): return getattr(self, item, False) class DelegatePermissions(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/delegatepermissions""" PERMISSION_LEVEL_CHOICES = { Choice('None'), Choice('Editor'), Choice('Reviewer'), Choice('Author'), Choice('Custom'), } FIELDS = [ ChoiceField('calendar_folder_permission_level', field_uri='CalendarFolderPermissionLevel', choices=PERMISSION_LEVEL_CHOICES, default='None'), ChoiceField('tasks_folder_permission_level', field_uri='TasksFolderPermissionLevel', choices=PERMISSION_LEVEL_CHOICES, default='None'), ChoiceField('inbox_folder_permission_level', field_uri='InboxFolderPermissionLevel', choices=PERMISSION_LEVEL_CHOICES, default='None'), ChoiceField('contacts_folder_permission_level', field_uri='ContactsFolderPermissionLevel', choices=PERMISSION_LEVEL_CHOICES, default='None'), ChoiceField('notes_folder_permission_level', field_uri='NotesFolderPermissionLevel', choices=PERMISSION_LEVEL_CHOICES, default='None'), ChoiceField('journal_folder_permission_level', field_uri='JournalFolderPermissionLevel', choices=PERMISSION_LEVEL_CHOICES, default='None'), ] __slots__ = tuple(f.name for f in FIELDS) class DelegateUser(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/delegateuser""" ELEMENT_NAME = 'DelegateUser' NAMESPACE = MNS FIELDS = [ EWSElementField('user_id', field_uri='UserId', value_cls=UserId), EWSElementField('delegate_permissions', field_uri='DelegatePermissions', value_cls=DelegatePermissions), BooleanField('receive_copies_of_meeting_messages', field_uri='ReceiveCopiesOfMeetingMessages', default=False), BooleanField('view_private_items', field_uri='ViewPrivateItems', default=False), ] __slots__ = tuple(f.name for f in FIELDS) class SearchableMailbox(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/searchablemailbox""" ELEMENT_NAME = 'SearchableMailbox' FIELDS = [ CharField('guid', field_uri='Guid'), EmailAddressField('primary_smtp_address', field_uri='PrimarySmtpAddress'), BooleanField('is_external', field_uri='IsExternalMailbox'), EmailAddressField('external_email', field_uri='ExternalEmailAddress'), CharField('display_name', field_uri='DisplayName'), BooleanField('is_membership_group', field_uri='IsMembershipGroup'), CharField('reference_id', field_uri='ReferenceId'), ] __slots__ = tuple(f.name for f in FIELDS) class FailedMailbox(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/failedmailbox""" FIELDS = [ CharField('mailbox', field_uri='Mailbox'), IntegerField('error_code', field_uri='ErrorCode'), CharField('error_message', field_uri='ErrorMessage'), BooleanField('is_archive', field_uri='IsArchive'), ] __slots__ = tuple(f.name for f in FIELDS) # MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailtipsrequested MAIL_TIPS_TYPES = ( 'All', 'OutOfOfficeMessage', 'MailboxFullStatus', 'CustomMailTip', 'ExternalMemberCount', 'TotalMemberCount', 'MaxMessageSize', 'DeliveryRestriction', 'ModerationStatus', 'InvalidRecipient', ) class OutOfOffice(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/outofoffice""" ELEMENT_NAME = 'OutOfOffice' FIELDS = [ MessageField('reply_body', field_uri='ReplyBody'), DateTimeField('start', field_uri='StartTime', is_required=False), DateTimeField('end', field_uri='EndTime', is_required=False), ] __slots__ = tuple(f.name for f in FIELDS) @classmethod def duration_to_start_end(cls, elem, account): kwargs = {} duration = elem.find('{%s}Duration' % TNS) if duration is not None: for attr in ('start', 'end'): f = cls.get_field_by_fieldname(attr) kwargs[attr] = f.from_xml(elem=duration, account=account) return kwargs @classmethod def from_xml(cls, elem, account): kwargs = {} for attr in ('reply_body',): f = cls.get_field_by_fieldname(attr) kwargs[attr] = f.from_xml(elem=elem, account=account) kwargs.update(cls.duration_to_start_end(elem=elem, account=account)) cls._clear(elem) return cls(**kwargs) class MailTips(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailtips""" ELEMENT_NAME = 'MailTips' NAMESPACE = MNS FIELDS = [ RecipientAddressField('recipient_address'), ChoiceField('pending_mail_tips', field_uri='PendingMailTips', choices={Choice(c) for c in MAIL_TIPS_TYPES}), EWSElementField('out_of_office', field_uri='OutOfOffice', value_cls=OutOfOffice), BooleanField('mailbox_full', field_uri='MailboxFull'), TextField('custom_mail_tip', field_uri='CustomMailTip'), IntegerField('total_member_count', field_uri='TotalMemberCount'), IntegerField('external_member_count', field_uri='ExternalMemberCount'), IntegerField('max_message_size', field_uri='MaxMessageSize'), BooleanField('delivery_restricted', field_uri='DeliveryRestricted'), BooleanField('is_moderated', field_uri='IsModerated'), BooleanField('invalid_recipient', field_uri='InvalidRecipient'), ] __slots__ = tuple(f.name for f in FIELDS) ENTRY_ID = 'EntryId' # The base64-encoded PR_ENTRYID property EWS_ID = 'EwsId' # The EWS format used in Exchange 2007 SP1 and later EWS_LEGACY_ID = 'EwsLegacyId' # The EWS format used in Exchange 2007 before SP1 HEX_ENTRY_ID = 'HexEntryId' # The hexadecimal representation of the PR_ENTRYID property OWA_ID = 'OwaId' # The OWA format for Exchange 2007 and 2010 STORE_ID = 'StoreId' # The Exchange Store format # IdFormat enum ID_FORMATS = (ENTRY_ID, EWS_ID, EWS_LEGACY_ID, HEX_ENTRY_ID, OWA_ID, STORE_ID) class AlternateId(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternateid""" ELEMENT_NAME = 'AlternateId' FIELDS = [ CharField('id', field_uri='Id', is_required=True, is_attribute=True), ChoiceField('format', field_uri='Format', is_required=True, is_attribute=True, choices={Choice(c) for c in ID_FORMATS}), EmailAddressField('mailbox', field_uri='Mailbox', is_required=True, is_attribute=True), BooleanField('is_archive', field_uri='IsArchive', is_required=False, is_attribute=True), ] __slots__ = tuple(f.name for f in FIELDS) @classmethod def response_tag(cls): # This element is in TNS in the request and MNS in the response... return '{%s}%s' % (MNS, cls.ELEMENT_NAME) class AlternatePublicFolderId(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternatepublicfolderid""" ELEMENT_NAME = 'AlternatePublicFolderId' FIELDS = [ CharField('folder_id', field_uri='FolderId', is_required=True, is_attribute=True), ChoiceField('format', field_uri='Format', is_required=True, is_attribute=True, choices={Choice(c) for c in ID_FORMATS}), ] __slots__ = tuple(f.name for f in FIELDS) class AlternatePublicFolderItemId(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternatepublicfolderitemid """ ELEMENT_NAME = 'AlternatePublicFolderItemId' FIELDS = [ CharField('folder_id', field_uri='FolderId', is_required=True, is_attribute=True), ChoiceField('format', field_uri='Format', is_required=True, is_attribute=True, choices={Choice(c) for c in ID_FORMATS}), CharField('item_id', field_uri='ItemId', is_required=True, is_attribute=True), ] __slots__ = tuple(f.name for f in FIELDS) class IdChangeKeyMixIn(EWSElement): """Base class for classes that have 'id' and 'changekey' fields which are actually attributes on ID element""" ID_ELEMENT_CLS = ItemId FIELDS = [ IdField('id', field_uri=ID_ELEMENT_CLS.ID_ATTR, is_read_only=True), IdField('changekey', field_uri=ID_ELEMENT_CLS.CHANGEKEY_ATTR, is_read_only=True), ] __slots__ = tuple(f.name for f in FIELDS) @classmethod def id_from_xml(cls, elem): id_elem = elem.find(cls.ID_ELEMENT_CLS.response_tag()) if id_elem is None: return None, None return id_elem.get(cls.ID_ELEMENT_CLS.ID_ATTR), id_elem.get(cls.ID_ELEMENT_CLS.CHANGEKEY_ATTR) @classmethod def from_xml(cls, elem, account): # The ID and changekey are actually in an 'ItemId' child element item_id, changekey = cls.id_from_xml(elem) kwargs = { f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS if f.name not in ('id', 'changekey') } cls._clear(elem) return cls(id=item_id, changekey=changekey, **kwargs) def __eq__(self, other): if isinstance(other, tuple): return hash((self.id, self.changekey)) == hash(other) return super().__eq__(other) def __hash__(self): # If we have an ID and changekey, use that as key. Else return a hash of all attributes if self.id: return hash((self.id, self.changekey)) return super().__hash__() exchangelib-3.1.1/exchangelib/protocol.py000066400000000000000000000760051361226005600204620ustar00rootroot00000000000000""" A protocol is an endpoint for EWS service connections. It contains all necessary information to make HTTPS connections. Protocols should be accessed through an Account, and are either created from a default Configuration or autodiscovered when creating an Account. """ import datetime import logging from multiprocessing.pool import ThreadPool import os from threading import Lock from queue import LifoQueue, Empty, Full from cached_property import threaded_cached_property import requests.adapters import requests.sessions import requests.utils from oauthlib.oauth2 import BackendApplicationClient, WebApplicationClient from requests_oauthlib import OAuth2Session from .credentials import OAuth2AuthorizationCodeCredentials, OAuth2Credentials from .errors import TransportError, SessionPoolMinSizeReached from .properties import FreeBusyViewOptions, MailboxData, TimeWindow, TimeZone from .services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, GetUserAvailability, \ GetSearchableMailboxes, ExpandDL, ConvertId from .transport import get_auth_instance, get_service_authtype, NTLM, GSSAPI, SSPI, OAUTH2, DEFAULT_HEADERS from .version import Version, API_VERSIONS log = logging.getLogger(__name__) def close_connections(): CachingProtocol.clear_cache() class BaseProtocol: """Base class for Protocol which implements the bare essentials""" # The maximum number of sessions (== TCP connections, see below) we will open to this service endpoint. Keep this # low unless you have an agreement with the Exchange admin on the receiving end to hammer the server and # rate-limiting policies have been disabled for the connecting user. SESSION_POOLSIZE = 4 # We want only 1 TCP connection per Session object. We may have lots of different credentials hitting the server and # each credential needs its own session (NTLM auth will only send credentials once and then secure the connection, # so a connection can only handle requests for one credential). Having multiple connections ser Session could # quickly exhaust the maximum number of concurrent connections the Exchange server allows from one client. CONNECTIONS_PER_SESSION = 1 # Timeout for HTTP requests TIMEOUT = 120 # The adapter class to use for HTTP requests. Override this if you need e.g. proxy support or specific TLS versions HTTP_ADAPTER_CLS = requests.adapters.HTTPAdapter # The User-Agent header to use for HTTP requests. Override this to set an app-specific one USERAGENT = None def __init__(self, config): from .configuration import Configuration if not isinstance(config, Configuration): raise ValueError("'config' %r must be a Configuration instance" % config) if not config.service_endpoint: raise AttributeError("'config.service_endpoint' must be set") self.config = config self._session_pool_size = self.SESSION_POOLSIZE # Autodetect authentication type if necessary if self.config.auth_type is None: self.config.auth_type = self.get_auth_type() # Try to behave nicely with the remote server. We want to keep the connection open between requests. # We also want to re-use sessions, to avoid the NTLM auth handshake on every request. We must know the # authentication method to create a session pool. self._session_pool = self._create_session_pool() self._session_pool_lock = Lock() @property def service_endpoint(self): return self.config.service_endpoint @property def auth_type(self): return self.config.auth_type @property def credentials(self): return self.config.credentials @credentials.setter def credentials(self, value): # We are updating credentials, but that doesn't automatically propagate to the session objects. The simplest # solution is to just kill the session pool and rebuild it. with self._session_pool_lock: self.config._credentials = value self.close() self._session_pool = self._create_session_pool() @property def retry_policy(self): return self.config.retry_policy @property def server(self): return self.config.server def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() del state['_session_pool'] del state['_session_pool_lock'] return state def __setstate__(self, state): # Restore the session pool and lock self.__dict__.update(state) self._session_pool = self._create_session_pool() self._session_pool_lock = Lock() def __del__(self): # pylint: disable=bare-except try: self.close() except Exception: # nosec # __del__ should never fail pass def close(self): log.debug('Server %s: Closing sessions', self.server) while True: try: self._session_pool.get(block=False).close() except Empty: break @classmethod def get_adapter(cls): # We want just one connection per session. No retries, since we wrap all requests in our own retry handler return cls.HTTP_ADAPTER_CLS( pool_block=True, pool_connections=cls.CONNECTIONS_PER_SESSION, pool_maxsize=cls.CONNECTIONS_PER_SESSION, max_retries=0, ) def get_auth_type(self): # Autodetect and return authentication type raise NotImplementedError() @classmethod def get_useragent(cls): if not cls.USERAGENT: # import here to avoid a cyclic import from exchangelib import __version__ cls.USERAGENT = "exchangelib/%s (%s)" % (__version__, requests.utils.default_user_agent()) return cls.USERAGENT def _create_session_pool(self): # Create a pool to reuse sessions containing connections to the server session_pool = LifoQueue(maxsize=self._session_pool_size) for _ in range(self._session_pool_size): session_pool.put(self.create_session(), block=False) return session_pool @property def session_pool_size(self): return self._session_pool_size def decrease_poolsize(self): """Decreases the session pool size in response to error messages from the server requesting to rate-limit requests. We decrease by one session per call. """ # Take a single session from the pool and discard it. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must keep at least one session in the pool. if self._session_pool_size <= 1: raise SessionPoolMinSizeReached('Session pool size cannot be decreased further') with self._session_pool_lock: if self._session_pool_size <= 1: log.debug('Session pool size was decreased in another thread') return log.warning('Lowering session pool size from %s to %s', self._session_pool_size, self._session_pool_size - 1) self.get_session().close() self._session_pool_size -= 1 def get_session(self): _timeout = 60 # Rate-limit messages about session starvation while True: try: log.debug('Server %s: Waiting for session', self.server) session = self._session_pool.get(timeout=_timeout) log.debug('Server %s: Got session %s', self.server, session.session_id) return session except Empty: # This is normal when we have many worker threads starving for available sessions log.debug('Server %s: No sessions available for %s seconds', self.server, _timeout) def release_session(self, session): # This should never fail, as we don't have more sessions than the queue contains log.debug('Server %s: Releasing session %s', self.server, session.session_id) try: self._session_pool.put(session, block=False) except Full: log.debug('Server %s: Session pool was already full %s', self.server, session.session_id) def retire_session(self, session): # The session is useless. Close it completely and place a fresh session in the pool log.debug('Server %s: Retiring session %s', self.server, session.session_id) session.close() del session self.release_session(self.create_session()) def renew_session(self, session): # The session is useless. Close it completely and place a fresh session in the pool log.debug('Server %s: Renewing session %s', self.server, session.session_id) session.close() del session return self.create_session() def refresh_credentials(self, session): # Credentials need to be refreshed, probably due to an OAuth # access token expiring. If we've gotten here, it's because the # application didn't provide an OAuth client secret, so we can't # handle token refreshing for it. with self.credentials.lock: if hash(self.credentials) == session.credentials_hash: # Credentials have not been refreshed by another thread: # they're the same as the session was created with. If # this isn't the case, we can just go ahead with a new # session using the already-updated credentials. self.credentials.refresh() return self.renew_session(session) def create_session(self): with self.credentials.lock: if self.auth_type is None: raise ValueError('Cannot create session without knowing the auth type') if isinstance(self.credentials, OAuth2Credentials): session = self.create_oauth2_session() elif self.credentials: if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: username = '\\' + self.credentials.username else: username = self.credentials.username session = self.raw_session() session.auth = get_auth_instance(auth_type=self.auth_type, username=username, password=self.credentials.password) else: if self.auth_type not in (GSSAPI, SSPI): raise ValueError('Auth type %r requires credentials' % self.auth_type) session = self.raw_session() session.auth = get_auth_instance(auth_type=self.auth_type) # Keep track of the credentials used to create this session. If # and when we need to renew credentials (for example, refreshing # an OAuth access token), this lets us easily determine whether # the credentials have already been refreshed in another thread # by the time this session tries. session.credentials_hash = hash(self.credentials) # Add some extra info session.session_id = sum(map(ord, str(os.urandom(100)))) # Used for debugging messages in services session.protocol = self log.debug('Server %s: Created session %s', self.server, session.session_id) return session def create_oauth2_session(self): if self.auth_type != OAUTH2: raise ValueError('Auth type must be %r for credentials type OAuth2Credentials' % OAUTH2) has_token = False scope = ['https://outlook.office365.com/.default'] session_params = {} token_params = {} if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): # Ask for a refresh token scope.append('offline_access') # We don't know (or need) the Microsoft tenant ID. Use # common/ to let Microsoft select the appropriate tenant # for the provided authorization code or refresh token. # # Suppress looks-like-password warning from Bandit. token_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token' # nosec client_params = {} has_token = self.credentials.access_token is not None if has_token: session_params['token'] = self.credentials.access_token elif self.credentials.authorization_code is not None: token_params['code'] = self.credentials.authorization_code self.credentials.authorization_code = None if self.credentials.client_id is not None and self.credentials.client_secret is not None: # If we're given a client ID and secret, we have enough # to refresh access tokens ourselves. In other cases the # session will raise TokenExpiredError and we'll need to # ask the calling application to refresh the token (that # covers cases where the caller doesn't have access to # the client secret but is working with a service that # can provide it refreshed tokens on a limited basis). session_params.update({ 'auto_refresh_kwargs': { 'client_id': self.credentials.client_id, 'client_secret': self.credentials.client_secret, }, 'auto_refresh_url': token_url, 'token_updater': self.credentials.on_token_auto_refreshed, }) client = WebApplicationClient(self.credentials.client_id, **client_params) else: token_url = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token' % self.credentials.tenant_id client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(client, session_params) if not has_token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token(token_url=token_url, client_id=self.credentials.client_id, client_secret=self.credentials.client_secret, scope=scope, **token_params) # Allow the credentials object to update its copy of the new # token, and give the application an opportunity to cache it self.credentials.on_token_auto_refreshed(token) session.auth = get_auth_instance(auth_type=OAUTH2, client=client) return session @classmethod def raw_session(cls, oauth2_client=None, oauth2_session_params=None): if oauth2_client: session = OAuth2Session(client=oauth2_client, **(oauth2_session_params or {})) else: session = requests.sessions.Session() session.headers.update(DEFAULT_HEADERS) session.headers["User-Agent"] = cls.get_useragent() session.mount('http://', adapter=cls.get_adapter()) session.mount('https://', adapter=cls.get_adapter()) return session def __repr__(self): return self.__class__.__name__ + repr((self.service_endpoint, self.credentials, self.auth_type)) class CachingProtocol(type): _protocol_cache = {} _protocol_cache_lock = Lock() def __call__(cls, *args, **kwargs): # Cache Protocol instances that point to the same endpoint and use the same credentials. This ensures that we # re-use thread and connection pools etc. instead of flooding the remote server. This is a modified Singleton # pattern. # # We ignore auth_type from kwargs in the cache key. We trust caller to supply the correct auth_type - otherwise # __init__ will guess the correct auth type. # We may be using multiple different credentials and changing our minds on TLS verification. This key # combination should be safe. _protocol_cache_key = kwargs['config'].service_endpoint, kwargs['config'].credentials protocol = cls._protocol_cache.get(_protocol_cache_key) if isinstance(protocol, Exception): # The input data leads to a TransportError. Re-throw raise protocol if protocol is not None: return protocol # Acquire lock to guard against multiple threads competing to cache information. Having a per-server lock is # probably overkill although it would reduce lock contention. log.debug('Waiting for _protocol_cache_lock') with cls._protocol_cache_lock: protocol = cls._protocol_cache.get(_protocol_cache_key) if isinstance(protocol, Exception): # Someone got ahead of us while holding the lock, but the input data leads to a TransportError. Re-throw raise protocol if protocol is not None: # Someone got ahead of us while holding the lock return protocol log.debug("Protocol __call__ cache miss. Adding key '%s'", str(_protocol_cache_key)) try: protocol = super().__call__(*args, **kwargs) except TransportError as e: # This can happen if, for example, autodiscover supplies us with a bogus EWS endpoint log.warning('Failed to create cached protocol with key %s: %s', _protocol_cache_key, e) cls._protocol_cache[_protocol_cache_key] = e raise e cls._protocol_cache[_protocol_cache_key] = protocol return protocol @classmethod def clear_cache(mcs): for key, protocol in mcs._protocol_cache.items(): if isinstance(protocol, Exception): continue service_endpoint = key[0] log.debug("Service endpoint '%s': Closing sessions", service_endpoint) protocol.close() mcs._protocol_cache.clear() class Protocol(BaseProtocol, metaclass=CachingProtocol): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._api_version_hint = None self._version_lock = Lock() def get_auth_type(self): # Autodetect authentication type. We also set version hint here. name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY' auth_type, api_version_hint = get_service_authtype( service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name ) self._api_version_hint = api_version_hint return auth_type @property def version(self): # Make sure only one thread does the guessing. if not self.config.version or not self.config.version.build: with self._version_lock: if not self.config.version or not self.config.version.build: # Version.guess() needs auth objects and a working session pool self.config.version = Version.guess(self, api_version_hint=self._api_version_hint) return self.config.version @threaded_cached_property def thread_pool(self): # Used by services to process service requests that are able to run in parallel. Thread pool should be # larger than the connection pool so we have time to process data without idling the connection. # Create the pool as the last thing here, since we may fail in the version or auth type guessing, which would # leave open threads around to be garbage collected. thread_poolsize = 4 * self._session_pool_size return ThreadPool(processes=thread_poolsize) def close(self): log.debug('Server %s: Closing thread pool', self.server) # Close the thread pool before closing the session pool to ensure all sessions are released. if "thread_pool" in self.__dict__: # Calling thread_pool.join() in Python 3.8 will hang forever. This is seen when running a test case that # uses the thread pool, e.g.: python tests/__init__.py MessagesTest.test_export_with_error # I don't know yet why this is happening. self.thread_pool.terminate() del self.__dict__["thread_pool"] super().close() def get_timezones(self, timezones=None, return_full_timezone_data=False): """ Get timezone definitions from the server :param timezones: A list of EWSDateTime instances. If None, fetches all timezones from server :param return_full_timezone_data: If true, also returns periods and transitions :return: A list of (tz_id, name, periods, transitions) tuples """ return GetServerTimeZones(protocol=self).call( timezones=timezones, return_full_timezone_data=return_full_timezone_data ) def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, requested_view='DetailedMerged'): """ Returns free/busy information for a list of accounts :param accounts: A list of (account, attendee_type, exclude_conflicts) tuples, where account is an Account object, attendee_type is a MailboxData.attendee_type choice, and exclude_conflicts is a boolean. :param start: The start datetime of the request :param end: The end datetime of the request :param merged_free_busy_interval: The interval, in minutes, of merged free/busy information :param requested_view: The type of information returned. Possible values are defined in the FreeBusyViewOptions.requested_view choices. :return: A generator of FreeBusyView objects """ from .account import Account for account, attendee_type, exclude_conflicts in accounts: if not isinstance(account, Account): raise ValueError("'accounts' item %r must be an 'Account' instance" % account) if attendee_type not in MailboxData.ATTENDEE_TYPES: raise ValueError("'accounts' item %r must be one of %s" % (attendee_type, MailboxData.ATTENDEE_TYPES)) if not isinstance(exclude_conflicts, bool): raise ValueError("'accounts' item %r must be a 'bool' instance" % exclude_conflicts) if start >= end: raise ValueError("'start' must be less than 'end' (%s -> %s)" % (start, end)) if not isinstance(merged_free_busy_interval, int): raise ValueError("'merged_free_busy_interval' value %r must be an 'int'" % merged_free_busy_interval) if requested_view not in FreeBusyViewOptions.REQUESTED_VIEWS: raise ValueError( "'requested_view' value %r must be one of %s" % (requested_view, FreeBusyViewOptions.REQUESTED_VIEWS)) _, _, periods, transitions, transitions_groups = list(self.get_timezones( timezones=[start.tzinfo], return_full_timezone_data=True ))[0] return GetUserAvailability(self).call( timezone=TimeZone.from_server_timezone( periods=periods, transitions=transitions, transitionsgroups=transitions_groups, for_year=start.year ), mailbox_data=[MailboxData( email=account.primary_smtp_address, attendee_type=attendee_type, exclude_conflicts=exclude_conflicts ) for account, attendee_type, exclude_conflicts in accounts], free_busy_view_options=FreeBusyViewOptions( time_window=TimeWindow(start=start, end=end), merged_free_busy_interval=merged_free_busy_interval, requested_view=requested_view, ), ) def get_roomlists(self): return GetRoomLists(protocol=self).call() def get_rooms(self, roomlist): from .properties import RoomList return GetRooms(protocol=self).call(roomlist=RoomList(email_address=roomlist)) def resolve_names(self, names, return_full_contact_data=False, search_scope=None, shape=None): """ Resolve accounts on the server using partial account data, e.g. an email address or initials :param names: A list of identifiers to query :param return_full_contact_data: If True, returns full contact data :param search_scope: The scope to perform the search. Must be one of SEARCH_SCOPE_CHOICES :param shape: :return: A list of Mailbox items or, if return_full_contact_data is True, tuples of (Mailbox, Contact) items """ from .items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES if search_scope: if search_scope not in SEARCH_SCOPE_CHOICES: raise ValueError("'search_scope' %s must be one if %s" % (search_scope, SEARCH_SCOPE_CHOICES)) if shape: if shape not in SHAPE_CHOICES: raise ValueError("'shape' %s must be one if %s" % (shape, SHAPE_CHOICES)) return list(ResolveNames(protocol=self).call( unresolved_entries=names, return_full_contact_data=return_full_contact_data, search_scope=search_scope, contact_data_shape=shape, )) def expand_dl(self, distribution_list): """ Expand distribution list into it's members :param distribution_list: SMTP address of the distribution list to expand, or a DLMailbox representing the list :return: List of Mailbox items that are members of the distribution list """ from .properties import DLMailbox if isinstance(distribution_list, str): distribution_list = DLMailbox(email_address=distribution_list, mailbox_type='PublicDL') return list(ExpandDL(protocol=self).call(distribution_list=distribution_list)) def get_searchable_mailboxes(self, search_filter=None, expand_group_membership=False): """This method is only available to users who have been assigned the Discovery Management RBAC role. See https://docs.microsoft.com/en-us/exchange/permissions-exo/permissions-exo :param search_filter: Is set, must be a single email alias :param expand_group_membership: If True, returned distribution lists are expanded :return: a list of SearchableMailbox, FailedMailbox or Exception instances """ return list(GetSearchableMailboxes(protocol=self).call( search_filter=search_filter, expand_group_membership=expand_group_membership, )) def convert_ids(self, ids, destination_format): """Converts item and folder IDs between multiple formats :param ids: a list of AlternateId, AlternatePublicFolderId or AlternatePublicFolderItemId instances :param destination_format: A string :return: a generator of AlternateId, AlternatePublicFolderId or AlternatePublicFolderItemId instances """ from .properties import ID_FORMATS, AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId if destination_format not in ID_FORMATS: raise ValueError("'destination_format' %r must be one of %s" % (destination_format, ID_FORMATS)) cls_map = {cls.response_tag(): cls for cls in ( AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId )} for i in ConvertId(protocol=self).call(items=ids, destination_format=destination_format): if isinstance(i, Exception): yield i else: id_cls = cls_map[i.tag] yield id_cls.from_xml(i, account=None) def __getstate__(self): # The lock and thread pool cannot be pickled state = super().__getstate__() del state['_version_lock'] try: del state['thread_pool'] except KeyError: # thread_pool is a cached property and may not exist pass return state def __setstate__(self, state): # Restore the lock. The thread pool is a cached property and will be recreated automatically. self.__dict__.update(state) self._version_lock = Lock() def __str__(self): # Don't trigger version guessing here just for the sake of printing if self.config.version: fullname, api_version, build = self.version.fullname, self.version.api_version, self.version.build else: fullname, api_version, build = '[unknown]', '[unknown]', '[unknown]' return '''\ EWS url: %s Product name: %s EWS API version: %s Build number: %s EWS auth: %s''' % (self.service_endpoint, fullname, api_version, build, self.auth_type) class NoVerifyHTTPAdapter(requests.adapters.HTTPAdapter): """An HTTP adapter that ignores TLS validation errors. Use at own risk.""" def cert_verify(self, conn, url, verify, cert): # pylint: disable=unused-argument # We're overiding a method so we have to keep the signature super().cert_verify(conn=conn, url=url, verify=False, cert=cert) class RetryPolicy: """Stores retry logic used when faced with errors from the server""" @property def fail_fast(self): # Used to choose the error handling policy. When True, a fault-tolerant policy is used. False, a fail-fast # policy is used. raise NotImplementedError() @property def back_off_until(self): raise NotImplementedError() @back_off_until.setter def back_off_until(self, value): raise NotImplementedError() class FailFast(RetryPolicy): """Fail immediately on server errors""" @property def fail_fast(self): return True @property def back_off_until(self): return None class FaultTolerance(RetryPolicy): """Enables fault-tolerant error handling. Tells internal methods to do an exponential back off when requests start failing, and wait up to max_wait seconds before failing. """ def __init__(self, max_wait=3600): self.max_wait = max_wait self._back_off_until = None self._back_off_lock = Lock() def __getstate__(self): # Locks cannot be pickled state = self.__dict__.copy() del state['_back_off_lock'] return state def __setstate__(self, state): # Restore the lock self.__dict__.update(state) self._back_off_lock = Lock() @property def fail_fast(self): return False @property def back_off_until(self): """Returns the back off value as a datetime. Resets the current back off value if it has expired.""" if self._back_off_until is None: return None with self._back_off_lock: if self._back_off_until is None: return None if self._back_off_until < datetime.datetime.now(): self._back_off_until = None # The back off value has expired. Reset return None return self._back_off_until @back_off_until.setter def back_off_until(self, value): with self._back_off_lock: self._back_off_until = value def back_off(self, seconds): if seconds is None: seconds = 60 # Back off 60 seconds if we didn't get an explicit suggested value value = datetime.datetime.now() + datetime.timedelta(seconds=seconds) with self._back_off_lock: self._back_off_until = value exchangelib-3.1.1/exchangelib/queryset.py000066400000000000000000000720041361226005600204750ustar00rootroot00000000000000from copy import deepcopy from itertools import islice import logging from .errors import MultipleObjectsReturned, DoesNotExist from .items import CalendarItem, ID_ONLY from .fields import FieldPath, FieldOrder from .properties import InvalidField from .restriction import Q from .services import CHUNK_SIZE from .version import EXCHANGE_2010 log = logging.getLogger(__name__) class SearchableMixIn: """Implements a search API for inheritance""" def get(self, *args, **kwargs): raise NotImplementedError() def all(self): raise NotImplementedError() def none(self): raise NotImplementedError() def filter(self, *args, **kwargs): raise NotImplementedError() def exclude(self, *args, **kwargs): raise NotImplementedError() def people(self): raise NotImplementedError() class QuerySet(SearchableMixIn): """ A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports chaining to build up complex queries. Django QuerySet documentation: https://docs.djangoproject.com/en/dev/ref/models/querysets/ """ VALUES = 'values' VALUES_LIST = 'values_list' FLAT = 'flat' NONE = 'none' RETURN_TYPES = (VALUES, VALUES_LIST, FLAT, NONE) ITEM = 'item' PERSONA = 'persona' REQUEST_TYPES = (ITEM, PERSONA) def __init__(self, folder_collection, request_type=ITEM): from .folders import FolderCollection if not isinstance(folder_collection, FolderCollection): raise ValueError("folder_collection value '%s' must be a FolderCollection instance" % folder_collection) self.folder_collection = folder_collection # A FolderCollection instance if request_type not in self.REQUEST_TYPES: raise ValueError("'request_type' %r must be one of %s" % (request_type, self.REQUEST_TYPES)) self.request_type = request_type self.q = Q() # Default to no restrictions. 'None' means 'return nothing' self.only_fields = None self.order_fields = None self.return_format = self.NONE self.calendar_view = None self.page_size = None self.max_items = None self.offset = 0 self._depth = None self._cache = None def _copy_self(self): # When we copy a queryset where the cache has already been filled, we don't copy the cache. Thus, a copied # queryset will fetch results from the server again. # # All other behaviour would be awkward: # # qs = QuerySet(f).filter(foo='bar') # items = list(qs) # new_qs = qs.exclude(bar='baz') # This should work, and should fetch from the server # if not isinstance(self.q, (type(None), Q)): raise ValueError("self.q value '%s' must be None or a Q instance" % self.q) if not isinstance(self.only_fields, (type(None), tuple)): raise ValueError("self.only_fields value '%s' must be None or a tuple" % self.only_fields) if not isinstance(self.order_fields, (type(None), tuple)): raise ValueError("self.order_fields value '%s' must be None or a tuple" % self.order_fields) if self.return_format not in self.RETURN_TYPES: raise ValueError("self.return_value '%s' must be one of %s" % (self.return_format, self.RETURN_TYPES)) # Only mutable objects need to be deepcopied. Folder should be the same object new_qs = self.__class__(self.folder_collection, request_type=self.request_type) new_qs.q = None if self.q is None else deepcopy(self.q) new_qs.only_fields = self.only_fields new_qs.order_fields = None if self.order_fields is None else deepcopy(self.order_fields) new_qs.return_format = self.return_format new_qs.calendar_view = self.calendar_view new_qs.page_size = self.page_size new_qs.max_items = self.max_items new_qs.offset = self.offset new_qs._depth = self._depth return new_qs @property def is_cached(self): return self._cache is not None def _get_field_path(self, field_path): from .items import Persona if self.request_type == self.PERSONA: return FieldPath(field=Persona.get_field_by_fieldname(field_path)) for folder in self.folder_collection: try: return FieldPath.from_string(field_path=field_path, folder=folder) except InvalidField: pass raise InvalidField("Unknown field path %r on folders %s" % (field_path, self.folder_collection.folders)) def _get_field_order(self, field_path): from .items import Persona if self.request_type == self.PERSONA: return FieldOrder( field_path=FieldPath(field=Persona.get_field_by_fieldname(field_path.lstrip('-'))), reverse=field_path.startswith('-'), ) for folder in self.folder_collection: try: return FieldOrder.from_string(field_path=field_path, folder=folder) except InvalidField: pass raise InvalidField("Unknown field path %r on folders %s" % (field_path, self.folder_collection.folders)) @property def _item_id_field(self): return self._get_field_path('id') @property def _changekey_field(self): return self._get_field_path('changekey') def _additional_fields(self): if not isinstance(self.only_fields, tuple): raise ValueError("'only_fields' value %r must be a tuple" % self.only_fields) # Remove ItemId and ChangeKey. We get them unconditionally additional_fields = {f for f in self.only_fields if not f.field.is_attribute} if self.request_type != self.ITEM: return additional_fields # For CalendarItem items, we want to inject internal timezone fields into the requested fields. has_start = 'start' in {f.field.name for f in additional_fields} has_end = 'end' in {f.field.name for f in additional_fields} meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.folder_collection.account.version.build < EXCHANGE_2010: if has_start or has_end: additional_fields.add(FieldPath(field=meeting_tz_field)) else: if has_start: additional_fields.add(FieldPath(field=start_tz_field)) if has_end: additional_fields.add(FieldPath(field=end_tz_field)) return additional_fields def _format_items(self, items, return_format): return { self.VALUES: self._as_values, self.VALUES_LIST: self._as_values_list, self.FLAT: self._as_flat_values_list, self.NONE: self._as_items, }[return_format](items) def _query(self): from .items import Persona if self.only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. if self.request_type == self.PERSONA: additional_fields = {FieldPath(field=f) for f in Persona.supported_fields( version=self.folder_collection.account.version ) if not f.is_complex} complex_fields_requested = False else: additional_fields = {FieldPath(field=f) for f in self.folder_collection.allowed_item_fields()} complex_fields_requested = True else: additional_fields = self._additional_fields() complex_fields_requested = any(f.field.is_complex for f in additional_fields) # EWS can do server-side sorting on multiple fields. A caveat is that server-side sorting is not supported # for calendar views. In this case, we do all the sorting client-side. if self.calendar_view: must_sort_clientside = bool(self.order_fields) order_fields = None else: must_sort_clientside = False order_fields = self.order_fields if must_sort_clientside: # Also fetch order_by fields that we only need for client-side sorting. extra_order_fields = {f.field_path for f in self.order_fields} - additional_fields if extra_order_fields: additional_fields.update(extra_order_fields) else: extra_order_fields = set() if self.request_type == self.PERSONA: if len(self.folder_collection) != 1: raise ValueError('Personas can only be queried on a single folder') items = list(self.folder_collection)[0].find_people( self.q, shape=ID_ONLY, depth=self._depth, additional_fields=additional_fields, order_fields=order_fields, page_size=self.page_size, max_items=self.max_items, offset=self.offset, ) else: find_item_kwargs = dict( shape=ID_ONLY, # Always use IdOnly here, because AllProperties doesn't actually get *all* properties depth=self._depth, additional_fields=additional_fields, order_fields=order_fields, calendar_view=self.calendar_view, page_size=self.page_size, max_items=self.max_items, offset=self.offset, ) if complex_fields_requested: # The FindItem service does not support complex field types. Tell find_items() to return # (id, changekey) tuples, and pass that to fetch(). find_item_kwargs['additional_fields'] = None items = self.folder_collection.account.fetch( ids=self.folder_collection.find_items(self.q, **find_item_kwargs), only_fields=additional_fields, chunk_size=self.page_size, ) else: if not additional_fields: # If additional_fields is the empty set, we only requested ID and changekey fields. We can then # take a shortcut by using (shape=ID_ONLY, additional_fields=None) to tell find_items() to return # (id, changekey) tuples. We'll post-process those later. find_item_kwargs['additional_fields'] = None items = self.folder_collection.find_items(self.q, **find_item_kwargs) if not must_sort_clientside: return items # Resort to client-side sorting of the order_by fields. This is greedy. Sorting in Python is stable, so when # sorting on multiple fields, we can just do a sort on each of the requested fields in reverse order. Reverse # each sort operation if the field was marked as such. for f in reversed(self.order_fields): try: items = sorted(items, key=lambda i: _get_value_or_default(i, f), reverse=f.reverse) except TypeError as e: if 'unorderable types' not in e.args[0]: raise raise ValueError(( "Cannot sort on field '%s'. The field has no default value defined, and there are either items " "with None values for this field, or the query contains exception instances (original error: %s).") % (f.field_path, e)) if not extra_order_fields: return items # Nullify the fields we only needed for sorting before returning return (_rinse_item(i, extra_order_fields) for i in items) def __iter__(self): # Fill cache if this is the first iteration. Return an iterator over the results. Make this non-greedy by # filling the cache while we are iterating. # # We don't set self._cache until the iterator is finished. Otherwise an interrupted iterator would leave the # cache in an inconsistent state. if self.is_cached: for val in self._cache: yield val return if self.q is None: self._cache = [] return log.debug('Initializing cache') _cache = [] for val in self._format_items(items=self._query(), return_format=self.return_format): _cache.append(val) yield val self._cache = _cache def __len__(self): if self.is_cached: return len(self._cache) # This queryset has no cache yet. Call the optimized counting implementation return self.count() def __getitem__(self, idx_or_slice): # Support indexing and slicing. This is non-greedy when possible (slicing start, stop and step are not negative, # and we're ordering on at most one field), and will only fill the cache if the entire query is iterated. if isinstance(idx_or_slice, int): return self._getitem_idx(idx_or_slice) return self._getitem_slice(idx_or_slice) def _getitem_idx(self, idx): if self.is_cached: return self._cache[idx] if idx < 0: # Support negative indexes by reversing the queryset and negating the index value reverse_idx = -(idx+1) return self.reverse()[reverse_idx] # Optimize by setting an exact offset and fetching only 1 item new_qs = self._copy_self() new_qs.max_items = 1 new_qs.page_size = 1 new_qs.offset = idx # The iterator will return at most 1 item for item in new_qs.__iter__(): return item raise IndexError() def _getitem_slice(self, s): if ((s.start or 0) < 0) or ((s.stop or 0) < 0) or ((s.step or 0) < 0): # islice() does not support negative start, stop and step. Make sure cache is full by iterating the full # query result, and then slice on the cache. list(self.__iter__()) return self._cache[s] if self.is_cached: return islice(self.__iter__(), s.start, s.stop, s.step) # Optimize by setting an exact offset and max_items value new_qs = self._copy_self() if s.start is not None and s.stop is not None: new_qs.offset = s.start new_qs.max_items = s.stop - s.start elif s.start is not None: new_qs.offset = s.start elif s.stop is not None: new_qs.max_items = s.stop if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < CHUNK_SIZE: new_qs.page_size = new_qs.max_items return islice(new_qs.__iter__(), None, None, s.step) def _item_yielder(self, iterable, item_func, id_only_func, changekey_only_func, id_and_changekey_func): # Transforms results from the server according to the given transform functions. Makes sure to pass on # Exception instances unaltered. if self.only_fields: has_non_attribute_fields = bool({f for f in self.only_fields if not f.field.is_attribute}) else: has_non_attribute_fields = True if not has_non_attribute_fields: # _query() will return an iterator of (id, changekey) tuples if self._changekey_field not in self.only_fields: transform_func = id_only_func elif self._item_id_field not in self.only_fields: transform_func = changekey_only_func else: transform_func = id_and_changekey_func for i in iterable: if isinstance(i, Exception): yield i continue yield transform_func(*i) return for i in iterable: if isinstance(i, Exception): yield i continue yield item_func(i) def _as_items(self, iterable): from .items import Item return self._item_yielder( iterable=iterable, item_func=lambda i: i, id_only_func=lambda item_id, changekey: Item(id=item_id), changekey_only_func=lambda item_id, changekey: Item(changekey=changekey), id_and_changekey_func=lambda item_id, changekey: Item(id=item_id, changekey=changekey), ) def _as_values(self, iterable): if not self.only_fields: raise ValueError('values() requires at least one field name') return self._item_yielder( iterable=iterable, item_func=lambda i: {f.path: f.get_value(i) for f in self.only_fields}, id_only_func=lambda item_id, changekey: {'id': item_id}, changekey_only_func=lambda item_id, changekey: {'changekey': changekey}, id_and_changekey_func=lambda item_id, changekey: {'id': item_id, 'changekey': changekey}, ) def _as_values_list(self, iterable): if not self.only_fields: raise ValueError('values_list() requires at least one field name') return self._item_yielder( iterable=iterable, item_func=lambda i: tuple(f.get_value(i) for f in self.only_fields), id_only_func=lambda item_id, changekey: (item_id,), changekey_only_func=lambda item_id, changekey: (changekey,), id_and_changekey_func=lambda item_id, changekey: (item_id, changekey), ) def _as_flat_values_list(self, iterable): if not self.only_fields or len(self.only_fields) != 1: raise ValueError('flat=True requires exactly one field name') flat_field_path = self.only_fields[0] return self._item_yielder( iterable=iterable, item_func=flat_field_path.get_value, id_only_func=lambda item_id, changekey: item_id, changekey_only_func=lambda item_id, changekey: changekey, id_and_changekey_func=None, # Can never be called ) ############################### # # Methods that support chaining # ############################### # Return copies of self, so this works as expected: # # foo_qs = my_folder.filter(...) # foo_qs.filter(foo='bar') # foo_qs.filter(foo='baz') # Should not be affected by the previous statement # def all(self): """ Return everything, without restrictions """ new_qs = self._copy_self() return new_qs def none(self): """ Return a query that is guaranteed to be empty """ new_qs = self._copy_self() new_qs.q = None return new_qs def filter(self, *args, **kwargs): """ Return everything that matches these search criteria """ new_qs = self._copy_self() q = Q(*args, **kwargs) new_qs.q = q if new_qs.q is None else new_qs.q & q return new_qs def exclude(self, *args, **kwargs): """ Return everything that does NOT match these search criteria """ new_qs = self._copy_self() q = ~Q(*args, **kwargs) new_qs.q = q if new_qs.q is None else new_qs.q & q return new_qs def people(self): """ Changes the queryset to search the folder for Personas instead of Items """ new_qs = self._copy_self() new_qs.request_type = self.PERSONA return new_qs def only(self, *args): """ Fetch only the specified field names. All other item fields will be 'None' """ try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: raise ValueError("%s in only()" % e.args[0]) new_qs = self._copy_self() new_qs.only_fields = only_fields return new_qs def order_by(self, *args): """ Return the query result sorted by the specified field names. Field names prefixed with '-' will be sorted in reverse order. EWS only supports server-side sorting on a single field. Sorting on multiple fields is implemented client-side and will therefore make the query greedy """ try: order_fields = tuple(self._get_field_order(arg) for arg in args) except ValueError as e: raise ValueError("%s in order_by()" % e.args[0]) new_qs = self._copy_self() new_qs.order_fields = order_fields return new_qs def reverse(self): """ Return the entire query result in reverse order """ if not self.order_fields: raise ValueError('Reversing only makes sense if there are order_by fields') new_qs = self._copy_self() for f in new_qs.order_fields: f.reverse = not f.reverse return new_qs def values(self, *args): """ Return the values of the specified field names as dicts """ try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: raise ValueError("%s in values()" % e.args[0]) new_qs = self._copy_self() new_qs.only_fields = only_fields new_qs.return_format = self.VALUES return new_qs def values_list(self, *args, **kwargs): """ Return the values of the specified field names as lists. If called with flat=True and only one field name, return only this value instead of a list. Allow an arbitrary list of fields in *args, possibly ending with flat=True|False""" flat = kwargs.pop('flat', False) if kwargs: raise AttributeError('Unknown kwargs: %s' % kwargs) if flat and len(args) != 1: raise ValueError('flat=True requires exactly one field name') try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: raise ValueError("%s in values_list()" % e.args[0]) new_qs = self._copy_self() new_qs.only_fields = only_fields new_qs.return_format = self.FLAT if flat else self.VALUES_LIST return new_qs def depth(self, depth): """Specify the search depth (SHALLOW, ASSOCIATED or DEEP) """ new_qs = self._copy_self() new_qs._depth = depth return new_qs ########################### # # Methods that end chaining # ########################### def iterator(self): """ Return the query result as an iterator, without caching the result """ if self.q is None: return [] if self.is_cached: return self._cache # Return an iterator that doesn't bother with caching return self._format_items(items=self._query(), return_format=self.return_format) def get(self, *args, **kwargs): """ Assume the query will return exactly one item. Return that item """ if self.is_cached and not args and not kwargs: # We can only safely use the cache if get() is called without args items = self._cache elif not args and set(kwargs.keys()) in ({'id'}, {'id', 'changekey'}): # We allow calling get(id=..., changekey=...) to get a single item, but only if exactly these two # kwargs are present. account = self.folder_collection.account item_id = self._item_id_field.field.clean(kwargs['id'], version=account.version) changekey = self._changekey_field.field.clean(kwargs.get('changekey'), version=account.version) items = list(account.fetch(ids=[(item_id, changekey)], only_fields=self.only_fields)) else: new_qs = self.filter(*args, **kwargs) items = list(new_qs.__iter__()) if not items: raise DoesNotExist() if len(items) != 1: raise MultipleObjectsReturned() return items[0] def count(self, page_size=1000): """ Get the query count, with as little effort as possible 'page_size' is the number of items to fetch from the server per request. We're only fetching the IDs, so keep it high""" if self.is_cached: return len(self._cache) new_qs = self._copy_self() new_qs.only_fields = tuple() new_qs.order_fields = None new_qs.return_format = self.NONE new_qs.page_size = page_size return len(list(new_qs.__iter__())) def exists(self): """ Find out if the query contains any hits, with as little effort as possible """ if self.is_cached: return len(self._cache) > 0 new_qs = self._copy_self() new_qs.max_items = 1 return new_qs.count(page_size=1) > 0 def _id_only_copy_self(self): new_qs = self._copy_self() new_qs.only_fields = tuple() new_qs.order_fields = None new_qs.return_format = self.NONE return new_qs def delete(self, page_size=1000, **delete_kwargs): """ Delete the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and delete per request. We're only fetching the IDs, so keep it high""" if self.is_cached: ids = self._cache else: ids = self._id_only_copy_self() ids.page_size = page_size res = self.folder_collection.account.bulk_delete( ids=ids, chunk_size=page_size, **delete_kwargs ) self._cache = None # Invalidate the cache, regardless of the results return res def send(self, page_size=1000, **send_kwargs): """ Send the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and send per request. We're only fetching the IDs, so keep it high""" if self.is_cached: ids = self._cache else: ids = self._id_only_copy_self() ids.page_size = page_size res = self.folder_collection.account.bulk_send( ids=ids, chunk_size=page_size, **send_kwargs ) self._cache = None # Invalidate the cache, regardless of the results return res def copy(self, to_folder, page_size=1000, **copy_kwargs): """ Copy the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and copy per request. We're only fetching the IDs, so keep it high""" if self.is_cached: ids = self._cache else: ids = self._id_only_copy_self() ids.page_size = page_size res = self.folder_collection.account.bulk_copy( ids=ids, to_folder=to_folder, chunk_size=page_size, **copy_kwargs ) self._cache = None # Invalidate the cache, regardless of the results return res def move(self, to_folder, page_size=1000): """ Move the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and move per request. We're only fetching the IDs, so keep it high""" if self.is_cached: ids = self._cache else: ids = self._id_only_copy_self() ids.page_size = page_size res = self.folder_collection.account.bulk_move( ids=ids, to_folder=to_folder, chunk_size=page_size, ) self._cache = None # Invalidate the cache after delete, regardless of the results return res def archive(self, to_folder, page_size=1000): """ Archive the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and move per request. We're only fetching the IDs, so keep it high""" if self.is_cached: ids = self._cache else: ids = self._id_only_copy_self() ids.page_size = page_size res = self.folder_collection.account.bulk_archive( ids=ids, to_folder=to_folder, chunk_size=page_size, ) self._cache = None # Invalidate the cache after delete, regardless of the results return res def __str__(self): fmt_args = [('q', str(self.q)), ('folders', '[%s]' % ', '.join(str(f) for f in self.folder_collection.folders))] if self.is_cached: fmt_args.append(('len', str(len(self)))) return self.__class__.__name__ + '(%s)' % ', '.join('%s=%s' % (k, v) for k, v in fmt_args) def _get_value_or_default(item, field_order): # Python can only sort values when <, > and = are implemented for the two types. Try as best we can to sort # items, even when the item may have a None value for the field in question, or when the item is an # Exception. If the field to be sorted by does not have a default value, there's really nothing we can do # about it; we'll eventually raise a TypeError. If it does, we sort all None values and exceptions as the # default value. if isinstance(item, Exception): return field_order.field_path.field.default val = field_order.field_path.get_value(item) if val is None: return field_order.field_path.field.default return val def _rinse_item(i, fields_to_nullify): # Set fields in fields_to_nullify to None. Make sure to accept exceptions. if isinstance(i, Exception): return i for f in fields_to_nullify: setattr(i, f.field.name, None) return i exchangelib-3.1.1/exchangelib/recurrence.py000066400000000000000000000273461361226005600207620ustar00rootroot00000000000000import logging from .fields import IntegerField, EnumField, EnumListField, DateField, DateTimeField, EWSElementField, \ MONTHS, WEEK_NUMBERS, WEEKDAYS from .properties import EWSElement, IdChangeKeyMixIn log = logging.getLogger(__name__) def _month_to_str(month): return MONTHS[month-1] if isinstance(month, int) else month def _weekday_to_str(weekday): return WEEKDAYS[weekday - 1] if isinstance(weekday, int) else weekday def _week_number_to_str(week_number): return WEEK_NUMBERS[week_number - 1] if isinstance(week_number, int) else week_number class Pattern(EWSElement): """Base class for all classes implementing recurring pattern elements""" __slots__ = tuple() class AbsoluteYearlyPattern(Pattern): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absoluteyearlyrecurrence """ ELEMENT_NAME = 'AbsoluteYearlyRecurrence' FIELDS = [ # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month # value, the last day in the month is assumed IntegerField('day_of_month', field_uri='DayOfMonth', min=1, max=31, is_required=True), # The month of the year, from 1 - 12 EnumField('month', field_uri='Month', enum=MONTHS, is_required=True), ] __slots__ = tuple(f.name for f in FIELDS) def __str__(self): return 'Occurs on day %s of %s' % (self.day_of_month, _month_to_str(self.month)) class RelativeYearlyPattern(Pattern): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/relativeyearlyrecurrence """ ELEMENT_NAME = 'RelativeYearlyRecurrence' FIELDS = [ # The weekday of the occurrence, as a valid ISO 8601 weekday number in range 1 -> 7 (1 being Monday). # Alternatively, the weekday can be one of the DAY (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which # is interpreted as the first day, weekday, or weekend day in the month, respectively. EnumField('weekday', field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True), # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks EnumField('week_number', field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True), # The month of the year, from 1 - 12 EnumField('month', field_uri='Month', enum=MONTHS, is_required=True), ] __slots__ = tuple(f.name for f in FIELDS) def __str__(self): return 'Occurs on weekday %s in the %s week of %s' % ( _weekday_to_str(self.weekday), _week_number_to_str(self.week_number), _month_to_str(self.month) ) class AbsoluteMonthlyPattern(Pattern): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absolutemonthlyrecurrence """ ELEMENT_NAME = 'AbsoluteMonthlyRecurrence' FIELDS = [ # Interval, in months, in range 1 -> 99 IntegerField('interval', field_uri='Interval', min=1, max=99, is_required=True), # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month # value, the last day in the month is assumed IntegerField('day_of_month', field_uri='DayOfMonth', min=1, max=31, is_required=True), ] __slots__ = tuple(f.name for f in FIELDS) def __str__(self): return 'Occurs on day %s of every %s month(s)' % (self.day_of_month, self.interval) class RelativeMonthlyPattern(Pattern): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/relativemonthlyrecurrence """ ELEMENT_NAME = 'RelativeMonthlyRecurrence' FIELDS = [ # Interval, in months, in range 1 -> 99 IntegerField('interval', field_uri='Interval', min=1, max=99, is_required=True), # The weekday of the occurrence, as a valid ISO 8601 weekday number in range 1 -> 7 (1 being Monday). # Alternatively, the weekday can be one of the DAY (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which # is interpreted as the first day, weekday, or weekend day in the month, respectively. EnumField('weekday', field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True), # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks. EnumField('week_number', field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True), ] __slots__ = tuple(f.name for f in FIELDS) def __str__(self): return 'Occurs on weekday %s in the %s week of every %s month(s)' % ( _weekday_to_str(self.weekday), _week_number_to_str(self.week_number), self.interval ) class WeeklyPattern(Pattern): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/weeklyrecurrence """ ELEMENT_NAME = 'WeeklyRecurrence' FIELDS = [ # Interval, in weeks, in range 1 -> 99 IntegerField('interval', field_uri='Interval', min=1, max=99, is_required=True), # List of valid ISO 8601 weekdays, as list of numbers in range 1 -> 7 (1 being Monday) EnumListField('weekdays', field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True), # The first day of the week. Defaults to Monday EnumField('first_day_of_week', field_uri='FirstDayOfWeek', enum=WEEKDAYS, default=1, is_required=True), ] __slots__ = tuple(f.name for f in FIELDS) def __str__(self): if isinstance(self.weekdays, str): weekdays = [self.weekdays] elif isinstance(self.weekdays, int): weekdays = [_weekday_to_str(self.weekdays)] else: weekdays = [_weekday_to_str(i) for i in self.weekdays] return 'Occurs on weekdays %s of every %s week(s) where the first day of the week is %s' % ( ', '.join(weekdays), self.interval, _weekday_to_str(self.first_day_of_week) ) class DailyPattern(Pattern): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dailyrecurrence """ ELEMENT_NAME = 'DailyRecurrence' FIELDS = [ # Interval, in days, in range 1 -> 999 IntegerField('interval', field_uri='Interval', min=1, max=999, is_required=True), ] __slots__ = tuple(f.name for f in FIELDS) def __str__(self): return 'Occurs every %s day(s)' % self.interval class Boundary(EWSElement): """Base class for all classes implementing recurring boundary elements""" __slots__ = tuple() class NoEndPattern(Boundary): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/noendrecurrence """ ELEMENT_NAME = 'NoEndRecurrence' FIELDS = [ # Start date, as EWSDate DateField('start', field_uri='StartDate', is_required=True), ] __slots__ = tuple(f.name for f in FIELDS) class EndDatePattern(Boundary): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/enddaterecurrence """ ELEMENT_NAME = 'EndDateRecurrence' FIELDS = [ # Start date, as EWSDate DateField('start', field_uri='StartDate', is_required=True), # End date, as EWSDate DateField('end', field_uri='EndDate', is_required=True), ] __slots__ = tuple(f.name for f in FIELDS) class NumberedPattern(Boundary): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/numberedrecurrence""" ELEMENT_NAME = 'NumberedRecurrence' FIELDS = [ # Start date, as EWSDate DateField('start', field_uri='StartDate', is_required=True), # The number of occurrences in this pattern, in range 1 -> 999 IntegerField('number', field_uri='NumberOfOccurrences', min=1, max=999, is_required=True), ] __slots__ = tuple(f.name for f in FIELDS) class Occurrence(IdChangeKeyMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/occurrence""" ELEMENT_NAME = 'Occurrence' LOCAL_FIELDS = [ # The modified start time of the item, as EWSDateTime DateTimeField('start', field_uri='Start'), # The modified end time of the item, as EWSDateTime DateTimeField('end', field_uri='End'), # The original start time of the item, as EWSDateTime DateTimeField('original_start', field_uri='OriginalStart'), ] FIELDS = IdChangeKeyMixIn.FIELDS + LOCAL_FIELDS __slots__ = tuple(f.name for f in LOCAL_FIELDS) # Container elements: # 'ModifiedOccurrences' # 'DeletedOccurrences' class FirstOccurrence(Occurrence): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/firstoccurrence""" ELEMENT_NAME = 'FirstOccurrence' __slots__ = tuple() class LastOccurrence(Occurrence): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/lastoccurrence""" ELEMENT_NAME = 'LastOccurrence' __slots__ = tuple() class DeletedOccurrence(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletedoccurrence""" ELEMENT_NAME = 'DeletedOccurrence' FIELDS = [ # The modified start time of the item, as EWSDateTime DateTimeField('start', field_uri='Start'), ] __slots__ = tuple(f.name for f in FIELDS) PATTERN_CLASSES = AbsoluteYearlyPattern, RelativeYearlyPattern, AbsoluteMonthlyPattern, RelativeMonthlyPattern, \ WeeklyPattern, DailyPattern BOUNDARY_CLASSES = NoEndPattern, EndDatePattern, NumberedPattern class Recurrence(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurrence-recurrencetype """ ELEMENT_NAME = 'Recurrence' FIELDS = [ EWSElementField('pattern', value_cls=Pattern), EWSElementField('boundary', value_cls=Boundary), ] __slots__ = tuple(f.name for f in FIELDS) def __init__(self, **kwargs): # Allow specifying a start, end and/or number as a shortcut to creating a boundary start = kwargs.pop('start', None) end = kwargs.pop('end', None) number = kwargs.pop('number', None) if any([start, end, number]): if 'boundary' in kwargs: raise ValueError("'boundary' is not allowed in combination with 'start', 'end' or 'number'") if start and not end and not number: kwargs['boundary'] = NoEndPattern(start=start) elif start and end and not number: kwargs['boundary'] = EndDatePattern(start=start, end=end) elif start and number and not end: kwargs['boundary'] = NumberedPattern(start=start, number=number) else: raise ValueError("Unsupported 'start', 'end', 'number' combination") super().__init__(**kwargs) @classmethod def from_xml(cls, elem, account): for pattern_cls in PATTERN_CLASSES: pattern_elem = elem.find(pattern_cls.response_tag()) if pattern_elem is None: continue pattern = pattern_cls.from_xml(elem=pattern_elem, account=account) break else: pattern = None for boundary_cls in BOUNDARY_CLASSES: boundary_elem = elem.find(boundary_cls.response_tag()) if boundary_elem is None: continue boundary = boundary_cls.from_xml(elem=boundary_elem, account=account) break else: boundary = None return cls(pattern=pattern, boundary=boundary) def __str__(self): return 'Pattern: %s, Boundary: %s' % (self.pattern, self.boundary) exchangelib-3.1.1/exchangelib/restriction.py000066400000000000000000000563011361226005600211630ustar00rootroot00000000000000import base64 from collections import OrderedDict import logging from .properties import InvalidField from .util import create_element, xml_to_str, value_to_xml_text, is_iterable from .version import EXCHANGE_2010 log = logging.getLogger(__name__) class Q: """A class with an API similar to Django Q objects. Used to implemnt advanced filtering logic.""" # Connection types AND = 'AND' OR = 'OR' NOT = 'NOT' CONN_TYPES = {AND, OR, NOT} # EWS Operators EQ = '==' NE = '!=' GT = '>' GTE = '>=' LT = '<' LTE = '<=' EXACT = 'exact' IEXACT = 'iexact' CONTAINS = 'contains' ICONTAINS = 'icontains' STARTSWITH = 'startswith' ISTARTSWITH = 'istartswith' EXISTS = 'exists' OP_TYPES = {EQ, NE, GT, GTE, LT, LTE, EXACT, IEXACT, CONTAINS, ICONTAINS, STARTSWITH, ISTARTSWITH, EXISTS} CONTAINS_OPS = {EXACT, IEXACT, CONTAINS, ICONTAINS, STARTSWITH, ISTARTSWITH} # Valid lookups LOOKUP_RANGE = 'range' LOOKUP_IN = 'in' LOOKUP_NOT = 'not' LOOKUP_GT = 'gt' LOOKUP_GTE = 'gte' LOOKUP_LT = 'lt' LOOKUP_LTE = 'lte' LOOKUP_EXACT = 'exact' LOOKUP_IEXACT = 'iexact' LOOKUP_CONTAINS = 'contains' LOOKUP_ICONTAINS = 'icontains' LOOKUP_STARTSWITH = 'startswith' LOOKUP_ISTARTSWITH = 'istartswith' LOOKUP_EXISTS = 'exists' LOOKUP_TYPES = {LOOKUP_RANGE, LOOKUP_IN, LOOKUP_NOT, LOOKUP_GT, LOOKUP_GTE, LOOKUP_LT, LOOKUP_LTE, LOOKUP_EXACT, LOOKUP_IEXACT, LOOKUP_CONTAINS, LOOKUP_ICONTAINS, LOOKUP_STARTSWITH, LOOKUP_ISTARTSWITH, LOOKUP_EXISTS} __slots__ = ('conn_type', 'field_path', 'op', 'value', 'children', 'query_string') def __init__(self, *args, **kwargs): self.conn_type = kwargs.pop('conn_type', self.AND) self.field_path = None # Name of the field we want to filter on self.op = None self.value = None self.query_string = None # Parsing of args and kwargs may require child elements self.children = [] # Remove any empty Q elements in args before proceeding args = tuple(a for a in args if not (isinstance(a, self.__class__) and a.is_empty())) # Check for query string, or Q object containing query string, as the only argument if len(args) == 1 and not kwargs: if isinstance(args[0], str): self.query_string = args[0] return if isinstance(args[0], self.__class__) and args[0].query_string: self.query_string = args[0].query_string return # Parse args which must be Q objects for q in args: if not isinstance(q, self.__class__): raise ValueError("Non-keyword arg %r must be a Q instance" % q) if q.query_string: raise ValueError( 'A query string cannot be combined with other restrictions (args: %r, kwargs: %r)' % (args, kwargs) ) self.children.append(q) # Parse keyword args and extract the filter is_single_kwarg = len(args) == 0 and len(kwargs) == 1 for key, value in kwargs.items(): children = self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg) self.children.extend(children) if len(self.children) == 1 and self.field_path is None and self.conn_type != self.NOT: # We only have one child and no expression on ourselves, so we are a no-op. Flatten by taking over the child self._promote() def _get_children_from_kwarg(self, key, value, is_single_kwarg=False): # Generates Q objects corresponding to a single keyword argument. Makes this a leaf if there are no children to # generate. key_parts = key.rsplit('__', 1) if len(key_parts) == 2 and key_parts[1] in self.LOOKUP_TYPES: # This is a kwarg with a lookup at the end field_path, lookup = key_parts if lookup == self.LOOKUP_EXISTS: # value=True will fall through to further processing if not value: return [~self.__class__(**{key: True})] if lookup == self.LOOKUP_RANGE: # EWS doesn't have a 'range' operator. Emulate 'foo__range=(1, 2)' as 'foo__gte=1 and foo__lte=2' # (both values inclusive). if len(value) != 2: raise ValueError("Value of lookup '%s' must have exactly 2 elements" % key) return [ self.__class__(**{'%s__gte' % field_path: value[0]}), self.__class__(**{'%s__lte' % field_path: value[1]}), ] if lookup == self.LOOKUP_IN: # EWS doesn't have an '__in' operator. Allow '__in' lookups on list and non-list field types, # specifying a list value. We'll emulate it as a set of OR'ed exact matches. if not is_iterable(value, generators_allowed=True): raise ValueError("Value for lookup %r must be a list" % key) children = [self.__class__(**{field_path: v}) for v in value] return [self.__class__(*children, conn_type=self.OR)] # Filtering on list types is a bit quirky. The only lookup type I have found to work is: # # item:Categories == 'foo' AND item:Categories == 'bar' AND ... # # item:Categories == 'foo' OR item:Categories == 'bar' OR ... # # The former returns items that have all these categories, but maybe also others. The latter returns # items that have at least one of these categories. This translates to the 'contains' and 'in' lookups. # Both versions are case-insensitive. # # Exact matching and case-sensitive or partial-string matching is not possible since that requires the # 'Contains' element which only supports matching on string elements, not arrays. # # Exact matching of categories (i.e. match ['a', 'b'] but not ['a', 'b', 'c']) could be implemented by # post-processing items by fetch the categories field unconditionally and removing the items that don't # have an exact match. if lookup == self.LOOKUP_CONTAINS and is_iterable(value, generators_allowed=True): # '__contains' lookups on list field types children = [self.__class__(**{field_path: v}) for v in value] return [self.__class__(*children, conn_type=self.AND)] try: op = self._lookup_to_op(lookup) except KeyError: raise ValueError("Lookup '%s' is not supported (called as '%s=%r')" % (lookup, key, value)) else: field_path, op = key, self.EQ if not is_single_kwarg: return [self.__class__(**{key: value})] # This is a single-kwarg Q object with a lookup that requires a single value. Make this a leaf self.field_path = field_path self.op = op self.value = value return [] def _promote(self): # Flatten by taking over the only child if len(self.children) != 1: raise ValueError('Can only flatten when child count is 1') if self.field_path is not None: raise ValueError("Can only flatten when 'field_path' is not set") q = self.children[0] self.conn_type = q.conn_type self.field_path = q.field_path self.op = q.op self.value = q.value self.query_string = q.query_string self.children = q.children def clean(self, version): # Do some basic checks on the attributes, using a generic folder. to_xml() does a really good job of # validating. There's no reason to replicate much of that here. from .folders import Folder self.to_xml(folders=[Folder()], version=version, applies_to=Restriction.ITEMS) @classmethod def _lookup_to_op(cls, lookup): return { cls.LOOKUP_NOT: cls.NE, cls.LOOKUP_GT: cls.GT, cls.LOOKUP_GTE: cls.GTE, cls.LOOKUP_LT: cls.LT, cls.LOOKUP_LTE: cls.LTE, cls.LOOKUP_EXACT: cls.EXACT, cls.LOOKUP_IEXACT: cls.IEXACT, cls.LOOKUP_CONTAINS: cls.CONTAINS, cls.LOOKUP_ICONTAINS: cls.ICONTAINS, cls.LOOKUP_STARTSWITH: cls.STARTSWITH, cls.LOOKUP_ISTARTSWITH: cls.ISTARTSWITH, cls.LOOKUP_EXISTS: cls.EXISTS, }[lookup] @classmethod def _conn_to_xml(cls, conn_type): xml_tag_map = { cls.AND: 't:And', cls.OR: 't:Or', cls.NOT: 't:Not', } return create_element(xml_tag_map[conn_type]) @classmethod def _op_to_xml(cls, op): xml_tag_map = { cls.EQ: 't:IsEqualTo', cls.NE: 't:IsNotEqualTo', cls.GTE: 't:IsGreaterThanOrEqualTo', cls.LTE: 't:IsLessThanOrEqualTo', cls.LT: 't:IsLessThan', cls.GT: 't:IsGreaterThan', cls.EXISTS: 't:Exists', } if op in xml_tag_map: return create_element(xml_tag_map[op]) valid_ops = cls.EXACT, cls.IEXACT, cls.CONTAINS, cls.ICONTAINS, cls.STARTSWITH, cls.ISTARTSWITH if op not in valid_ops: raise ValueError("'op' %s must be one of %s" % (op, valid_ops)) # For description of Contains attribute values, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contains # # Possible ContainmentMode values: # FullString, Prefixed, Substring, PrefixOnWords, ExactPhrase # Django lookups have no equivalent of PrefixOnWords and ExactPhrase (and I'm unsure how they actually # work). # # EWS has no equivalent of '__endswith' or '__iendswith'. That could be emulated using '__contains' and # '__icontains' and filtering results afterwards in Python. But it could be inefficient because we might be # fetching and discarding a lot of non-matching items, plus we would need to always fetch the field we're # matching on, to be able to do the filtering. I think it's better to leave this to the consumer, i.e.: # # items = [i for i in fld.filter(subject__contains=suffix) if i.subject.endswith(suffix)] # items = [i for i in fld.filter(subject__icontains=suffix) if i.subject.lower().endswith(suffix.lower())] # # Possible ContainmentComparison values (there are more, but the rest are "To be removed"): # Exact, IgnoreCase, IgnoreNonSpacingCharacters, IgnoreCaseAndNonSpacingCharacters # I'm unsure about non-spacing characters, but as I read # https://en.wikipedia.org/wiki/Graphic_character#Spacing_and_non-spacing_characters # we shouldn't ignore them ('a' would match both 'a' and 'å', the latter having a non-spacing character). if op in {cls.EXACT, cls.IEXACT}: match_mode = 'FullString' elif op in (cls.CONTAINS, cls.ICONTAINS): match_mode = 'Substring' elif op in (cls.STARTSWITH, cls.ISTARTSWITH): match_mode = 'Prefixed' else: raise ValueError('Unsupported op: %s' % op) if op in (cls.IEXACT, cls.ICONTAINS, cls.ISTARTSWITH): compare_mode = 'IgnoreCase' else: compare_mode = 'Exact' return create_element( 't:Contains', attrs=OrderedDict([ ('ContainmentMode', match_mode), ('ContainmentComparison', compare_mode), ]) ) def is_leaf(self): return not self.children def is_empty(self): return self.is_leaf() and self.field_path is None and self.query_string is None def expr(self): if self.is_empty(): return None if self.query_string: return self.query_string if self.is_leaf(): expr = '%s %s %r' % (self.field_path, self.op, self.value) else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. expr = (' %s ' % (self.AND if self.conn_type == self.NOT else self.conn_type)).join( (c.expr() if c.is_leaf() or c.conn_type == self.NOT else '(%s)' % c.expr()) for c in sorted(self.children, key=lambda i: i.field_path or '') ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: return self.conn_type + ' %s' % expr return self.conn_type + ' (%s)' % expr return expr def to_xml(self, folders, version, applies_to): if self.query_string: if version.build < EXCHANGE_2010: raise NotImplementedError('QueryString filtering is only supported for Exchange 2010 servers and later') elem = create_element('m:QueryString') elem.text = self.query_string return elem # Translate this Q object to a valid Restriction XML tree elem = self.xml_elem(folders=folders, version=version, applies_to=applies_to) if elem is None: return None restriction = create_element('m:Restriction') restriction.append(elem) return restriction def _check_integrity(self): if self.is_empty(): return if self.query_string: if any([self.field_path, self.op, self.value, self.children]): raise ValueError('Query strings cannot be combined with other settings') return if self.conn_type not in self.CONN_TYPES: raise ValueError("'conn_type' %s must be one of %s" % (self.conn_type, self.CONN_TYPES)) if not self.is_leaf(): return if not self.field_path: raise ValueError("'field_path' must be set") if self.op not in self.OP_TYPES: raise ValueError("'op' %s must be one of %s" % (self.op, self.OP_TYPES)) if self.op == self.EXISTS: if self.value is not True: raise ValueError("'value' must be True when operator is EXISTS") if self.value is None: raise ValueError('Value for filter on field path "%s" cannot be None' % self.field_path) if is_iterable(self.value, generators_allowed=True): raise ValueError( 'Value %r for filter on field path "%s" must be a single value' % (self.value, self.field_path) ) def _validate_field_path(self, field_path, folder, applies_to, version): from .indexed_properties import MultiFieldIndexedElement if applies_to == Restriction.FOLDERS: # This is a restriction on Folder fields folder.validate_field(field=field_path.field, version=version) else: folder.validate_item_field(field=field_path.field, version=version) if not field_path.field.is_searchable: raise ValueError("EWS does not support filtering on field '%s'" % field_path.field.name) if field_path.subfield and not field_path.subfield.is_searchable: raise ValueError("EWS does not support filtering on subfield '%s'" % field_path.subfield.name) if issubclass(field_path.field.value_cls, MultiFieldIndexedElement) and not field_path.subfield: raise ValueError("Field path '%s' must contain a subfield" % self.field_path) def _get_field_path(self, folders, applies_to, version): # Convert the string field path to a real FieldPath object. The path is validated using the given folders. from .fields import FieldPath for folder in folders: try: if applies_to == Restriction.FOLDERS: # This is a restriction on Folder fields field = folder.get_field_by_fieldname(fieldname=self.field_path) field_path = FieldPath(field=field) else: field_path = FieldPath.from_string(field_path=self.field_path, folder=folder) except ValueError: continue self._validate_field_path(field_path=field_path, folder=folder, applies_to=applies_to, version=version) break else: raise InvalidField("Unknown field path %r on folders %s" % (self.field_path, folders)) return field_path def _get_clean_value(self, field_path, version): if self.op == self.EXISTS: return None clean_field = field_path.subfield if (field_path.subfield and field_path.label) else field_path.field if clean_field.is_list: # With __contains, we allow filtering by only one value even though the field is a list type return clean_field.clean(value=[self.value], version=version)[0] else: return clean_field.clean(value=self.value, version=version) def xml_elem(self, folders, version, applies_to): # Recursively build an XML tree structure of this Q object. If this is an empty leaf (the equivalent of Q()), # return None. from .indexed_properties import SingleFieldIndexedElement from .extended_properties import ExtendedProperty # Don't check self.value just yet. We want to return error messages on the field path first, and then the value. # This is done in _get_field_path() and _get_clean_value(), respectively. self._check_integrity() if self.is_empty(): return None if self.is_leaf(): elem = self._op_to_xml(self.op) field_path = self._get_field_path(folders, applies_to=applies_to, version=version) clean_value = self._get_clean_value(field_path=field_path, version=version) if issubclass(field_path.field.value_cls, ExtendedProperty) and field_path.field.value_cls.is_binary_type(): # We need to base64-encode binary data clean_value = base64.b64encode(clean_value.value).decode('ascii') elif issubclass(field_path.field.value_cls, SingleFieldIndexedElement) and not field_path.label: # We allow a filter shortcut of e.g. email_addresses__contains=EmailAddress(label='Foo', ...) instead of # email_addresses__Foo_email_address=.... Set FieldPath label now so we can generate the field_uri. field_path.label = clean_value.label elem.append(field_path.to_xml()) constant = create_element('t:Constant') if self.op != self.EXISTS: # Use .set() to not fill up the create_element() cache with unique values constant.set('Value', value_to_xml_text(clean_value)) if self.op in self.CONTAINS_OPS: elem.append(constant) else: uriorconst = create_element('t:FieldURIOrConstant') uriorconst.append(constant) elem.append(uriorconst) elif len(self.children) == 1: # We have only one child elem = self.children[0].xml_elem(folders=folders, version=version, applies_to=applies_to) else: # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) # Sort children by field name so we get stable output (for easier testing). Children should never be empty for c in sorted(self.children, key=lambda i: i.field_path or ''): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: return None # Should not be necessary, but play safe if self.conn_type == self.NOT: # Encapsulate everything in the NOT element not_elem = self._conn_to_xml(self.conn_type) not_elem.append(elem) return not_elem return elem def __and__(self, other): # & operator. Return a new Q with two children and conn_type AND return self.__class__(self, other, conn_type=self.AND) def __or__(self, other): # | operator. Return a new Q with two children and conn_type OR return self.__class__(self, other, conn_type=self.OR) def __invert__(self): # ~ operator. If op has an inverse, change op. Else return a new Q with conn_type NOT if self.conn_type == self.NOT: # This is NOT NOT. Change to AND self.conn_type = self.AND if len(self.children) == 1 and self.field_path is None: self._promote() return self if self.is_leaf(): if self.op == self.EQ: self.op = self.NE return self if self.op == self.NE: self.op = self.EQ return self if self.op == self.GT: self.op = self.LTE return self if self.op == self.GTE: self.op = self.LT return self if self.op == self.LT: self.op = self.GTE return self if self.op == self.LTE: self.op = self.GT return self return self.__class__(self, conn_type=self.NOT) def __eq__(self, other): return repr(self) == repr(other) def __hash__(self): return hash(repr(self)) def __str__(self): return self.expr() or 'Q()' def __repr__(self): if self.is_leaf(): if self.query_string: return self.__class__.__name__ + '(%r)' % self.query_string return self.__class__.__name__ + '(%s %s %r)' % (self.field_path, self.op, self.value) sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or '')) if self.conn_type == self.NOT or len(self.children) > 1: return self.__class__.__name__ + repr((self.conn_type,) + sorted_children) return self.__class__.__name__ + repr(sorted_children) class Restriction: """ Implements an EWS Restriction type. """ # The type of item the restriction applies to FOLDERS = 'folders' ITEMS = 'items' RESTRICTION_TYPES = (FOLDERS, ITEMS) def __init__(self, q, folders, applies_to): if not isinstance(q, Q): raise ValueError("'q' value %r must be a Q instance" % q) if q.is_empty(): raise ValueError("Q object must not be empty") from .folders import BaseFolder for folder in folders: if not isinstance(folder, BaseFolder): raise ValueError("'folder' value %r must be a Folder instance" % folder) if applies_to not in self.RESTRICTION_TYPES: raise ValueError("'applies_to' must be one of %s" % (self.RESTRICTION_TYPES,)) self.q = q self.folders = folders self.applies_to = applies_to def to_xml(self, version): return self.q.to_xml(folders=self.folders, version=version, applies_to=self.applies_to) def __str__(self): """ Prints the XML syntax tree """ return xml_to_str(self.to_xml(version=self.folders[0].account.version)) exchangelib-3.1.1/exchangelib/services/000077500000000000000000000000001361226005600200625ustar00rootroot00000000000000exchangelib-3.1.1/exchangelib/services/__init__.py000066400000000000000000000046761361226005600222100ustar00rootroot00000000000000""" Implement a selection of EWS services (operations). Exchange is very picky about things like the order of XML elements in SOAP requests, so we need to generate XML automatically instead of taking advantage of Python SOAP libraries and the WSDL file. Exchange EWS operations overview: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/ews-operations-in-exchange """ from .common import CHUNK_SIZE from .archive_item import ArchiveItem from .convert_id import ConvertId from .copy_item import CopyItem from .create_attachment import CreateAttachment from .create_folder import CreateFolder from .create_item import CreateItem from .delete_attachment import DeleteAttachment from .delete_folder import DeleteFolder from .delete_item import DeleteItem from .empty_folder import EmptyFolder from .expand_dl import ExpandDL from .export_items import ExportItems from .find_folder import FindFolder from .find_item import FindItem from .find_people import FindPeople from .get_attachment import GetAttachment from .get_delegate import GetDelegate from .get_folder import GetFolder from .get_item import GetItem from .get_mail_tips import GetMailTips from .get_persona import GetPersona from .get_room_lists import GetRoomLists from .get_rooms import GetRooms from .get_searchable_mailboxes import GetSearchableMailboxes from .get_server_time_zones import GetServerTimeZones from .get_user_availability import GetUserAvailability from .get_user_oof_settings import GetUserOofSettings from .move_item import MoveItem from .resolve_names import ResolveNames from .send_item import SendItem from .set_user_oof_settings import SetUserOofSettings from .update_folder import UpdateFolder from .update_item import UpdateItem from .upload_items import UploadItems __all__ = [ 'CHUNK_SIZE', 'ArchiveItem', 'ConvertId', 'CopyItem', 'CreateAttachment', 'CreateFolder', 'CreateItem', 'DeleteAttachment', 'DeleteFolder', 'DeleteItem', 'EmptyFolder', 'ExpandDL', 'ExportItems', 'FindFolder', 'FindItem', 'FindPeople', 'GetAttachment', 'GetDelegate', 'GetFolder', 'GetItem', 'GetMailTips', 'GetPersona', 'GetRoomLists', 'GetRooms', 'GetSearchableMailboxes', 'GetServerTimeZones', 'GetUserAvailability', 'GetUserOofSettings', 'MoveItem', 'ResolveNames', 'SendItem', 'SetUserOofSettings', 'UpdateFolder', 'UpdateItem', 'UploadItems', ] exchangelib-3.1.1/exchangelib/services/archive_item.py000066400000000000000000000035541361226005600231020ustar00rootroot00000000000000from ..util import create_element, MNS from ..version import EXCHANGE_2013 from .common import EWSAccountService, EWSPooledMixIn, create_folder_ids_element, create_item_ids_element class ArchiveItem(EWSAccountService, EWSPooledMixIn): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/archiveitem-operation """ SERVICE_NAME = 'ArchiveItem' element_container_name = '{%s}Items' % MNS def call(self, items, to_folder): """ Move a list of items to a specific folder in the archive mailbox. :param items: a list of (id, changekey) tuples or Item objects :return: None """ if self.protocol.version.build < EXCHANGE_2013: raise NotImplementedError('%s is only supported for Exchange 2013 servers and later' % self.SERVICE_NAME) return self._pool_requests(payload_func=self.get_payload, **dict(items=items, to_folder=to_folder)) def _get_elements_in_response(self, response): for msg in response: container_or_exc = self._get_element_container(message=msg, name=self.element_container_name) if isinstance(container_or_exc, (bool, Exception)): yield container_or_exc else: if len(container_or_exc): raise ValueError('Unexpected container length: %s' % container_or_exc) yield True def get_payload(self, items, to_folder): archiveitem = create_element('m:%s' % self.SERVICE_NAME) folder_id = create_folder_ids_element(tag='m:ArchiveSourceFolderId', folders=[to_folder], version=self.account.version) item_ids = create_item_ids_element(items=items, version=self.account.version) archiveitem.append(folder_id) archiveitem.append(item_ids) return archiveitem exchangelib-3.1.1/exchangelib/services/common.py000066400000000000000000000773311361226005600217370ustar00rootroot00000000000000import abc from itertools import chain import logging import traceback from .. import errors from ..errors import EWSWarning, TransportError, SOAPError, ErrorTimeoutExpired, ErrorBatchProcessingStopped, \ ErrorQuotaExceeded, ErrorCannotDeleteObject, ErrorCreateItemAccessDenied, ErrorFolderNotFound, \ ErrorNonExistentMailbox, ErrorMailboxStoreUnavailable, ErrorImpersonateUserDenied, ErrorInternalServerError, \ ErrorInternalServerTransientError, ErrorNoRespondingCASInDestinationSite, ErrorImpersonationFailed, \ ErrorMailboxMoveInProgress, ErrorAccessDenied, ErrorConnectionFailed, RateLimitError, ErrorServerBusy, \ ErrorTooManyObjectsOpened, ErrorInvalidLicense, ErrorInvalidSchemaVersionForMailboxVersion, \ ErrorInvalidServerVersion, ErrorItemNotFound, ErrorADUnavailable, ErrorInvalidChangeKey, \ ErrorItemSave, ErrorInvalidIdMalformed, ErrorMessageSizeExceeded, UnauthorizedError, \ ErrorCannotDeleteTaskOccurrence, ErrorMimeContentConversionFailed, ErrorRecurrenceHasNoOccurrence, \ ErrorNoPublicFolderReplicaAvailable, MalformedResponseError, ErrorExceededConnectionCount, \ SessionPoolMinSizeReached, ErrorIncorrectSchemaVersion, ErrorInvalidRequest from ..transport import wrap, extra_headers from ..util import chunkify, create_element, add_xml_child, get_xml_attr, to_xml, post_ratelimited, \ xml_to_str, set_xml_value, SOAPNS, TNS, MNS, ENS, ParseError log = logging.getLogger(__name__) CHUNK_SIZE = 100 # A default chunk size for all services class EWSService(metaclass=abc.ABCMeta): SERVICE_NAME = None # The name of the SOAP service element_container_name = None # The name of the XML element wrapping the collection of returned items # Return exception instance instead of raising exceptions for the following errors when contained in an element ERRORS_TO_CATCH_IN_RESPONSE = ( EWSWarning, ErrorCannotDeleteObject, ErrorInvalidChangeKey, ErrorItemNotFound, ErrorItemSave, ErrorInvalidIdMalformed, ErrorMessageSizeExceeded, ErrorCannotDeleteTaskOccurrence, ErrorMimeContentConversionFailed, ErrorRecurrenceHasNoOccurrence, ) # Similarly, define the warnings we want to return unraised WARNINGS_TO_CATCH_IN_RESPONSE = ErrorBatchProcessingStopped # Define the warnings we want to ignore, to let response processing proceed WARNINGS_TO_IGNORE_IN_RESPONSE = () # Controls whether the HTTP request should be streaming or fetch everything at once streaming = False def __init__(self, protocol, chunk_size=None): self.chunk_size = chunk_size or CHUNK_SIZE # The number of items to send in a single request if not isinstance(self.chunk_size, int): raise ValueError("'chunk_size' %r must be an integer" % chunk_size) if self.chunk_size < 1: raise ValueError("'chunk_size' must be a positive number") self.protocol = protocol # The following two methods are the minimum required to be implemented by subclasses, but the name and number of # kwargs differs between services. Therefore, we cannot make these methods abstract. # @abc.abstractmethod # def call(self, **kwargs): # raise NotImplementedError() # @abc.abstractmethod # def get_payload(self, **kwargs): # raise NotImplementedError() def _get_elements(self, payload): while True: try: # Send the request, get the response and do basic sanity checking on the SOAP XML response = self._get_response_xml(payload=payload) # Read the XML and throw any general EWS error messages. Return a generator over the result elements return self._get_elements_in_response(response=response) except ErrorServerBusy as e: self._handle_backoff(e) continue except ( ErrorAccessDenied, ErrorADUnavailable, ErrorBatchProcessingStopped, ErrorCannotDeleteObject, ErrorConnectionFailed, ErrorCreateItemAccessDenied, ErrorExceededConnectionCount, ErrorFolderNotFound, ErrorImpersonateUserDenied, ErrorImpersonationFailed, ErrorInternalServerError, ErrorInternalServerTransientError, ErrorInvalidChangeKey, ErrorInvalidLicense, ErrorItemNotFound, ErrorMailboxMoveInProgress, ErrorMailboxStoreUnavailable, ErrorNonExistentMailbox, ErrorNoPublicFolderReplicaAvailable, ErrorNoRespondingCASInDestinationSite, ErrorQuotaExceeded, ErrorTimeoutExpired, RateLimitError, UnauthorizedError, ): # These are known and understood, and don't require a backtrace. raise except Exception: # This may run from a thread pool, which obfuscates the stack trace. Print trace immediately. account = self.account if isinstance(self, EWSAccountService) else None log.warning('EWS %s, account %s: Exception in _get_elements: %s', self.protocol.service_endpoint, account, traceback.format_exc(20)) raise def _get_response_xml(self, payload, **parse_opts): # Takes an XML tree and returns SOAP payload as an XML tree # Microsoft really doesn't want to make our lives easy. The server may report one version in our initial version # guessing tango, but then the server may decide that any arbitrary legacy backend server may actually process # the request for an account. Prepare to handle ErrorInvalidSchemaVersionForMailboxVersion errors and set the # server version per-account. from ..version import API_VERSIONS if isinstance(self, EWSAccountService): account = self.account version_hint = self.account.version else: account = None # We may be here due to version guessing in Protocol.version, so we can't use the Protocol.version property version_hint = self.protocol.config.version api_versions = [version_hint.api_version] + [v for v in API_VERSIONS if v != version_hint.api_version] for api_version in api_versions: log.debug('Trying API version %s for account %s', api_version, account) r, session = post_ratelimited( protocol=self.protocol, session=self.protocol.get_session(), url=self.protocol.service_endpoint, headers=extra_headers(account=account), data=wrap(content=payload, api_version=api_version, account=account), allow_redirects=False, stream=self.streaming, ) if self.streaming: # Let 'requests' decode raw data automatically r.raw.decode_content = True else: # If we're streaming, we want to wait to release the session until we have consumed the stream. self.protocol.release_session(session) try: header, body = self._get_soap_parts(response=r, **parse_opts) except ParseError as e: raise SOAPError('Bad SOAP response: %s' % e) # The body may contain error messages from Exchange, but we still want to collect version info if header is not None: try: self._update_api_version(version_hint=version_hint, api_version=api_version, header=header, **parse_opts) except TransportError as te: log.debug('Failed to update version info (%s)', te) try: res = self._get_soap_messages(body=body, **parse_opts) except (ErrorInvalidServerVersion, ErrorIncorrectSchemaVersion, ErrorInvalidRequest): # The guessed server version is wrong. Try the next version log.debug('API version %s was invalid', api_version) continue except ErrorInvalidSchemaVersionForMailboxVersion: if not account: # This should never happen for non-account services raise ValueError("'account' should not be None") # The guessed server version is wrong for this account. Try the next version log.debug('API version %s was invalid for account %s', api_version, account) continue except ErrorExceededConnectionCount as e: # ErrorExceededConnectionCount indicates that the connecting user has too many open TCP connections to # the server. Decrease our session pool size. if self.streaming: # In streaming mode, we haven't released the session yet, so we can't discard the session raise else: try: self.protocol.decrease_poolsize() continue except SessionPoolMinSizeReached: # We're already as low as we can go. Let the user handle this. raise e except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired) as e: # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very # often a symptom of sending too many requests. # # ErrorTimeoutExpired can be caused by a busy server, or by overly large requests. Start by lowering the # session count. This is done by downstream code. if isinstance(e, ErrorTimeoutExpired) and self.protocol.session_pool_size <= 1: # We're already as low as we can go, so downstream cannot limit the session count to put less load # on the server. We don't have a way of lowering the page size of requests from # this part of the code yet. Let the user handle this. raise e # Re-raise as an ErrorServerBusy with a default delay of 5 minutes raise ErrorServerBusy(msg='Reraised from %s(%s)' % (e.__class__.__name__, e), back_off=300) finally: if self.streaming: # TODO: We shouldn't release the session yet if we still haven't fully consumed the stream. It seems # a Session can handle multiple unfinished streaming requests, though. self.protocol.release_session(session) return res if account: raise ErrorInvalidSchemaVersionForMailboxVersion('Tried versions %s but all were invalid for account %s' % (api_versions, account)) raise ErrorInvalidServerVersion('Tried versions %s but all were invalid' % api_versions) def _handle_backoff(self, e): log.debug('Got ErrorServerBusy (back off %s seconds)', e.back_off) # ErrorServerBusy is very often a symptom of sending too many requests. Scale back if possible. try: self.protocol.decrease_poolsize() except SessionPoolMinSizeReached: pass if self.protocol.retry_policy.fail_fast: raise e self.protocol.retry_policy.back_off(e.back_off) # We'll warn about this later if we actually need to sleep def _update_api_version(self, version_hint, api_version, header, **parse_opts): from ..version import Version head_version = Version.from_soap_header(requested_api_version=api_version, header=header) if version_hint == head_version: # Nothing to do return log.debug('Found new version (%s -> %s)', version_hint, head_version) # The api_version that worked was different than our hint, or we never got a build version. Set new # version for account. if isinstance(self, EWSAccountService): self.account.version = head_version else: self.protocol.config.version = head_version @classmethod def _response_tag(cls): return '{%s}%sResponse' % (MNS, cls.SERVICE_NAME) @staticmethod def _response_messages_tag(): return '{%s}ResponseMessages' % MNS @classmethod def _response_message_tag(cls): return '{%s}%sResponseMessage' % (MNS, cls.SERVICE_NAME) @classmethod def _get_soap_parts(cls, response, **parse_opts): root = to_xml(response.iter_content()) header = root.find('{%s}Header' % SOAPNS) if header is None: # This is normal when the response contains SOAP-level errors log.debug('No header in XML response') body = root.find('{%s}Body' % SOAPNS) if body is None: raise MalformedResponseError('No Body element in SOAP response') return header, body @classmethod def _get_soap_messages(cls, body, **parse_opts): response = body.find(cls._response_tag()) if response is None: fault = body.find('{%s}Fault' % SOAPNS) if fault is None: raise SOAPError('Unknown SOAP response: %s' % xml_to_str(body)) cls._raise_soap_errors(fault=fault) # Will throw SOAPError or custom EWS error response_messages = response.find(cls._response_messages_tag()) if response_messages is None: # Result isn't delivered in a list of FooResponseMessages, but directly in the FooResponse. Consumers expect # a list, so return a list return [response] return response_messages.findall(cls._response_message_tag()) @classmethod def _raise_soap_errors(cls, fault): # Fault: See http://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383507 faultcode = get_xml_attr(fault, 'faultcode') faultstring = get_xml_attr(fault, 'faultstring') faultactor = get_xml_attr(fault, 'faultactor') detail = fault.find('detail') if detail is not None: code, msg = None, '' if detail.find('{%s}ResponseCode' % ENS) is not None: code = get_xml_attr(detail, '{%s}ResponseCode' % ENS) if detail.find('{%s}Message' % ENS) is not None: msg = get_xml_attr(detail, '{%s}Message' % ENS) msg_xml = detail.find('{%s}MessageXml' % TNS) # Crazy. Here, it's in the TNS namespace if code == 'ErrorServerBusy': back_off = None try: value = msg_xml.find('{%s}Value' % TNS) if value.get('Name') == 'BackOffMilliseconds': back_off = int(value.text) / 1000.0 # Convert to seconds except (TypeError, AttributeError): pass raise ErrorServerBusy(msg, back_off=back_off) elif code == 'ErrorSchemaValidation' and msg_xml is not None: violation = get_xml_attr(msg_xml, '{%s}Violation' % TNS) if violation is not None: msg = '%s %s' % (msg, violation) try: raise vars(errors)[code](msg) except KeyError: detail = '%s: code: %s msg: %s (%s)' % (cls.SERVICE_NAME, code, msg, xml_to_str(detail)) try: raise vars(errors)[faultcode](faultstring) except KeyError: pass raise SOAPError('SOAP error code: %s string: %s actor: %s detail: %s' % ( faultcode, faultstring, faultactor, detail)) def _get_element_container(self, message, response_message=None, name=None): if response_message is None: response_message = message # ResponseClass: See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditemresponsemessage response_class = response_message.get('ResponseClass') # ResponseCode, MessageText: See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode response_code = get_xml_attr(response_message, '{%s}ResponseCode' % MNS) msg_text = get_xml_attr(response_message, '{%s}MessageText' % MNS) msg_xml = response_message.find('{%s}MessageXml' % MNS) if response_class == 'Success' and response_code == 'NoError': if not name: return True container = message.find(name) if container is None: raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % (name, xml_to_str(message))) return container if response_code == 'NoError': return True # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance if response_class == 'Warning': try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) except self.WARNINGS_TO_CATCH_IN_RESPONSE as e: return e except self.WARNINGS_TO_IGNORE_IN_RESPONSE as e: log.warning(str(e)) container = message.find(name) if container is None: raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % (name, xml_to_str(message))) return container # rspclass == 'Error', or 'Success' and not 'NoError' try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: return e @classmethod def _get_exception(cls, code, text, msg_xml): if not code: return TransportError('Empty ResponseCode in ResponseMessage (MessageText: %s, MessageXml: %s)' % ( text, msg_xml)) if msg_xml is not None: # If this is an ErrorInvalidPropertyRequest error, the xml may contain a specific FieldURI for tag_name in ('FieldURI', 'IndexedFieldURI', 'ExtendedFieldURI', 'ExceptionFieldURI'): field_uri_elem = msg_xml.find('{%s}%s' % (TNS, tag_name)) if field_uri_elem is not None: text += ' (field: %s)' % xml_to_str(field_uri_elem) # If this is an ErrorInternalServerError error, the xml may contain a more specific error code inner_code, inner_text = None, None for value_elem in msg_xml.findall('{%s}Value' % TNS): name = value_elem.get('Name') if name == 'InnerErrorResponseCode': inner_code = value_elem.text elif name == 'InnerErrorMessageText': inner_text = value_elem.text if inner_code: try: # Raise the error as the inner error code return vars(errors)[inner_code]('%s (raised from: %s(%r))' % (inner_text, code, text)) except KeyError: # Inner code is unknown to us. Just append to the original text text += ' (inner error: %s(%r))' % (inner_code, inner_text) try: # Raise the error corresponding to the ResponseCode return vars(errors)[code](text) except KeyError: # Should not happen return TransportError('Unknown ResponseCode in ResponseMessage: %s (MessageText: %s, MessageXml: %s)' % ( code, text, msg_xml)) def _get_elements_in_response(self, response): for msg in response: container_or_exc = self._get_element_container(message=msg, name=self.element_container_name) if isinstance(container_or_exc, (bool, Exception)): yield container_or_exc else: for c in self._get_elements_in_container(container=container_or_exc): yield c @staticmethod def _get_elements_in_container(container): return [elem for elem in container] class EWSAccountService(EWSService): def __init__(self, *args, **kwargs): self.account = kwargs.pop('account') kwargs['protocol'] = self.account.protocol super().__init__(*args, **kwargs) class EWSFolderService(EWSAccountService): def __init__(self, *args, **kwargs): self.folders = kwargs.pop('folders') if not self.folders: raise ValueError('"folders" must not be empty') super().__init__(*args, **kwargs) class PagingEWSMixIn(EWSService): def _paged_call(self, payload_func, max_items, **kwargs): if isinstance(self, EWSAccountService): log_prefix = 'EWS %s, account %s, service %s' % ( self.protocol.service_endpoint, self.account, self.SERVICE_NAME) else: log_prefix = 'EWS %s, service %s' % (self.protocol.service_endpoint, self.SERVICE_NAME) if isinstance(self, EWSFolderService): expected_message_count = len(self.folders) else: expected_message_count = 1 paging_infos = [dict(item_count=0, next_offset=None) for _ in range(expected_message_count)] common_next_offset = kwargs['offset'] total_item_count = 0 while True: log.debug('%s: Getting items at offset %s (max_items %s)', log_prefix, common_next_offset, max_items) kwargs['offset'] = common_next_offset payload = payload_func(**kwargs) try: response = self._get_response_xml(payload=payload) except ErrorServerBusy as e: self._handle_backoff(e) continue # Collect a tuple of (rootfolder, next_offset) tuples parsed_pages = [self._get_page(message) for message in response] if len(parsed_pages) != expected_message_count: raise MalformedResponseError( "Expected %s items in 'response', got %s" % (expected_message_count, len(parsed_pages)) ) for (rootfolder, next_offset), paging_info in zip(parsed_pages, paging_infos): paging_info['next_offset'] = next_offset if isinstance(rootfolder, Exception): yield rootfolder continue if rootfolder is not None: container = rootfolder.find(self.element_container_name) if container is None: raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % ( self.element_container_name, xml_to_str(rootfolder))) for elem in self._get_elements_in_container(container=container): if max_items and total_item_count >= max_items: # No need to continue. Break out of elements loop log.debug("'max_items' count reached (elements)") break paging_info['item_count'] += 1 total_item_count += 1 yield elem if max_items and total_item_count >= max_items: # No need to continue. Break out of inner loop log.debug("'max_items' count reached (inner)") break if not paging_info['next_offset']: # Paging is done for this message continue # Check sanity of paging offsets, but don't fail. When we are iterating huge collections that take a # long time to complete, the collection may change while we are iterating. This can affect the # 'next_offset' value and make it inconsistent with the number of already collected items. # We may have a mismatch if we stopped early due to reaching 'max_items'. if paging_info['next_offset'] != paging_info['item_count'] and ( not max_items or total_item_count < max_items ): log.warning('Unexpected next offset: %s -> %s. Maybe the server-side collection has changed?' % (paging_info['item_count'], paging_info['next_offset'])) # Also break out of outer loop if max_items and total_item_count >= max_items: log.debug("'max_items' count reached (outer)") break next_offsets = {p['next_offset'] for p in paging_infos if p['next_offset'] is not None} if not next_offsets: # Paging is done for all messages break # We cannot guarantee that all messages that have a next_offset also have the *same* next_offset. This is # because the collections that we are iterating may change while iterating. We'll do our best but we cannot # guarantee 100% consistency when large collections are simultaneously being changed on the server. # # It's not possible to supply a per-folder offset when iterating multiple folders, so we'll just have to # choose something that is most likely to work. Select the lowest of all the values to at least make sure # we don't miss any items, although we may then get duplicates ¯\_(ツ)_/¯ if len(next_offsets) > 1: log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets) common_next_offset = min(next_offsets) def _get_page(self, message): rootfolder = self._get_element_container(message=message, name='{%s}RootFolder' % MNS) if isinstance(rootfolder, Exception): return rootfolder, None is_last_page = rootfolder.get('IncludesLastItemInRange').lower() in ('true', '0') offset = rootfolder.get('IndexedPagingOffset') if offset is None and not is_last_page: log.debug("Not last page in range, but Exchange didn't send a page offset. Assuming first page") offset = '1' next_offset = None if is_last_page else int(offset) item_count = int(rootfolder.get('TotalItemsInView')) if not item_count: if next_offset is not None: raise ValueError("Expected empty 'next_offset' when 'item_count' is 0") rootfolder = None log.debug('%s: Got page with next offset %s (last_page %s)', self.SERVICE_NAME, next_offset, is_last_page) return rootfolder, next_offset class EWSPooledMixIn(EWSService): def _pool_requests(self, payload_func, items, **kwargs): log.debug('Processing items in chunks of %s', self.chunk_size) # Chop items list into suitable pieces and let worker threads chew on the work. The order of the output result # list must be the same as the input id list, so the caller knows which status message belongs to which ID. # Yield results as they become available. results = [] n = 0 for chunk in chunkify(items, self.chunk_size): n += 1 log.debug('Starting %s._get_elements worker %s for %s items', self.__class__.__name__, n, len(chunk)) results.append((n, self.protocol.thread_pool.apply_async( lambda c: self._get_elements(payload=payload_func(c, **kwargs)), (chunk,) ))) # Results will be available before iteration has finished if 'items' is a slow generator. Return early while True: if not results: break i, r = results[0] if not r.ready(): # First non-yielded result isn't ready yet. Yielding other ready results would mess up ordering break log.debug('%s._get_elements result %s is ready early', self.__class__.__name__, i) for elem in r.get(): yield elem # Results object has been processed. Remove from list. del results[0] # Yield remaining results in order, as they become available for i, r in results: log.debug('Waiting for %s._get_elements result %s of %s', self.__class__.__name__, i, n) elems = r.get() log.debug('%s._get_elements result %s of %s is ready', self.__class__.__name__, i, n) for elem in elems: yield elem def to_item_id(item, item_cls): # Coerce a tuple, dict or object to an 'item_cls' instance. Used to create [Parent][Item|Folder]Id instances from a # variety of input. if isinstance(item, item_cls): return item if isinstance(item, (tuple, list)): return item_cls(*item) if isinstance(item, dict): return item_cls(**item) return item_cls(item.id, item.changekey) def create_shape_element(tag, shape, additional_fields, version): shape_elem = create_element(tag) add_xml_child(shape_elem, 't:BaseShape', shape) if additional_fields: additional_properties = create_element('t:AdditionalProperties') expanded_fields = chain(*(f.expand(version=version) for f in additional_fields)) set_xml_value(additional_properties, sorted(expanded_fields, key=lambda f: f.path), version=version) shape_elem.append(additional_properties) return shape_elem def create_folder_ids_element(tag, folders, version): from ..folders import BaseFolder, FolderId, DistinguishedFolderId folder_ids = create_element(tag) for folder in folders: log.debug('Collecting folder %s', folder) if not isinstance(folder, (BaseFolder, FolderId, DistinguishedFolderId)): folder = to_item_id(folder, FolderId) set_xml_value(folder_ids, folder, version=version) if not len(folder_ids): raise ValueError('"folders" must not be empty') return folder_ids def create_item_ids_element(items, version): from ..properties import ItemId item_ids = create_element('m:ItemIds') for item in items: log.debug('Collecting item %s', item) set_xml_value(item_ids, to_item_id(item, ItemId), version=version) if not len(item_ids): raise ValueError('"items" must not be empty') return item_ids def create_attachment_ids_element(items, version): from ..attachments import AttachmentId attachment_ids = create_element('m:AttachmentIds') for item in items: attachment_id = item if isinstance(item, AttachmentId) else AttachmentId(id=item) set_xml_value(attachment_ids, attachment_id, version=version) if not len(attachment_ids): raise ValueError('"items" must not be empty') return attachment_ids def parse_folder_elem(elem, folder, account): from ..folders import BaseFolder, Folder, DistinguishedFolderId, RootOfHierarchy if isinstance(elem, Exception): return elem if isinstance(folder, RootOfHierarchy): f = folder.from_xml(elem=elem, account=folder.account) elif isinstance(folder, Folder): f = folder.from_xml_with_root(elem=elem, root=folder.root) elif isinstance(folder, DistinguishedFolderId): # We don't know the root, so assume account.root. for folder_cls in account.root.WELLKNOWN_FOLDERS: if folder_cls.DISTINGUISHED_FOLDER_ID == folder.id: break else: raise ValueError('Unknown distinguished folder ID: %s', folder.id) f = folder_cls.from_xml_with_root(elem=elem, root=account.root) else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. f = Folder.from_xml_with_root(elem=elem, root=account.root) if isinstance(folder, DistinguishedFolderId): f.is_distinguished = True elif isinstance(folder, BaseFolder) and folder.is_distinguished: f.is_distinguished = True return f exchangelib-3.1.1/exchangelib/services/convert_id.py000066400000000000000000000045311361226005600225730ustar00rootroot00000000000000import logging from ..util import create_element, set_xml_value from ..version import EXCHANGE_2007_SP1 from .common import EWSPooledMixIn log = logging.getLogger(__name__) class ConvertId(EWSPooledMixIn): """ Takes a list of IDs to convert. Returns a list of converted IDs or exception instances, in the same order as the input list. MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/convertid-operation """ SERVICE_NAME = 'ConvertId' def call(self, items, destination_format): if self.protocol.version.build < EXCHANGE_2007_SP1: raise NotImplementedError( '%r is only supported for Exchange 2007 SP1 servers and later' % self.SERVICE_NAME) return self._pool_requests(payload_func=self.get_payload, **dict( items=items, destination_format=destination_format, )) def get_payload(self, items, destination_format): from ..properties import AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId convertid = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DestinationFormat=destination_format)) item_ids = create_element('m:SourceIds') for item in items: log.debug('Collecting item %s', item) if not isinstance(item, supported_item_classes): raise ValueError("'item' value %r must be an instance of %r" % (item, supported_item_classes)) set_xml_value(item_ids, item, version=self.protocol.version) if not len(item_ids): raise ValueError('"items" must not be empty') convertid.append(item_ids) return convertid def _get_elements_in_container(self, container): # We may have other elements in here, e.g. 'ResponseCode'. Filter away those. from ..properties import AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId return container.findall(AlternateId.response_tag()) \ + container.findall(AlternatePublicFolderId.response_tag()) \ + container.findall(AlternatePublicFolderItemId.response_tag()) def _get_element_container(self, message, response_message=None, name=None): # There is no element container return message exchangelib-3.1.1/exchangelib/services/copy_item.py000066400000000000000000000003321361226005600224220ustar00rootroot00000000000000from . import move_item class CopyItem(move_item.MoveItem): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copyitem-operation """ SERVICE_NAME = 'CopyItem' exchangelib-3.1.1/exchangelib/services/create_attachment.py000066400000000000000000000021621361226005600241100ustar00rootroot00000000000000from ..util import create_element, set_xml_value, MNS from .common import EWSAccountService, to_item_id class CreateAttachment(EWSAccountService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createattachment-operation """ SERVICE_NAME = 'CreateAttachment' element_container_name = '{%s}Attachments' % MNS def call(self, parent_item, items): return self._get_elements(payload=self.get_payload( parent_item=parent_item, items=items, )) def get_payload(self, parent_item, items): from ..properties import ParentItemId payload = create_element('m:%s' % self.SERVICE_NAME) parent_id = to_item_id(parent_item, ParentItemId) payload.append(parent_id.to_xml(version=self.account.version)) attachments = create_element('m:Attachments') for item in items: set_xml_value(attachments, item, version=self.account.version) if not len(attachments): raise ValueError('"items" must not be empty') payload.append(attachments) return payload exchangelib-3.1.1/exchangelib/services/create_folder.py000066400000000000000000000027131361226005600232350ustar00rootroot00000000000000from ..util import create_element, set_xml_value, MNS from .common import EWSAccountService, parse_folder_elem, create_folder_ids_element class CreateFolder(EWSAccountService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createfolder-operation """ SERVICE_NAME = 'CreateFolder' element_container_name = '{%s}Folders' % MNS def call(self, parent_folder, folders): # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. folders_list = list(folders) # Convert to a list, in case 'folders' is a generator for folder, elem in zip(folders_list, self._get_elements(payload=self.get_payload( parent_folder=parent_folder, folders=folders ))): yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, parent_folder, folders): create_folder = create_element('m:%s' % self.SERVICE_NAME) parentfolderid = create_element('m:ParentFolderId') set_xml_value(parentfolderid, parent_folder, version=self.account.version) set_xml_value(create_folder, parentfolderid, version=self.account.version) folder_ids = create_folder_ids_element(tag='m:Folders', folders=folders, version=self.account.version) create_folder.append(folder_ids) return create_folder exchangelib-3.1.1/exchangelib/services/create_item.py000066400000000000000000000050721361226005600227210ustar00rootroot00000000000000from collections import OrderedDict import logging from ..util import create_element, set_xml_value, MNS from .common import EWSAccountService, EWSPooledMixIn log = logging.getLogger(__name__) class CreateItem(EWSAccountService, EWSPooledMixIn): """ Takes folder and a list of items. Returns result of creation as a list of tuples (success[True|False], errormessage), in the same order as the input list. MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem """ SERVICE_NAME = 'CreateItem' element_container_name = '{%s}Items' % MNS def call(self, items, folder, message_disposition, send_meeting_invitations): return self._pool_requests(payload_func=self.get_payload, **dict( items=items, folder=folder, message_disposition=message_disposition, send_meeting_invitations=send_meeting_invitations, )) def get_payload(self, items, folder, message_disposition, send_meeting_invitations): """ Takes a list of Item objects (CalendarItem, Message etc) and returns the XML for a CreateItem request. convert items to XML Elements MessageDisposition is only applicable to email messages, where it is required. SendMeetingInvitations is required for calendar items. It is also applicable to tasks, meeting request responses (see https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem-operation-meeting-request ) and sharing invitation accepts (see https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem-acceptsharinginvitation ). The last two are not supported yet. """ createitem = create_element( 'm:%s' % self.SERVICE_NAME, attrs=OrderedDict([ ('MessageDisposition', message_disposition), ('SendMeetingInvitations', send_meeting_invitations), ]) ) if folder: saveditemfolderid = create_element('m:SavedItemFolderId') set_xml_value(saveditemfolderid, folder, version=self.account.version) createitem.append(saveditemfolderid) item_elems = create_element('m:Items') for item in items: log.debug('Adding item %s', item) set_xml_value(item_elems, item, version=self.account.version) if not len(item_elems): raise ValueError('"items" must not be empty') createitem.append(item_elems) return createitem exchangelib-3.1.1/exchangelib/services/delete_attachment.py000066400000000000000000000024701361226005600241110ustar00rootroot00000000000000from ..util import create_element from .common import EWSAccountService, create_attachment_ids_element class DeleteAttachment(EWSAccountService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteattachment-operation """ SERVICE_NAME = 'DeleteAttachment' def call(self, items): return self._get_elements(payload=self.get_payload( items=items, )) def _get_element_container(self, message, response_message=None, name=None): # DeleteAttachment returns RootItemIds directly beneath DeleteAttachmentResponseMessage. Collect the elements # and make our own fake container. from ..properties import RootItemId res = super()._get_element_container( message=message, response_message=response_message, name=name ) if not res: return res fake_elem = create_element('FakeContainer') for elem in message.findall(RootItemId.response_tag()): fake_elem.append(elem) return fake_elem def get_payload(self, items): payload = create_element('m:%s' % self.SERVICE_NAME) attachment_ids = create_attachment_ids_element(items=items, version=self.account.version) payload.append(attachment_ids) return payload exchangelib-3.1.1/exchangelib/services/delete_folder.py000066400000000000000000000015731361226005600232370ustar00rootroot00000000000000from ..util import create_element from .common import EWSAccountService, create_folder_ids_element class DeleteFolder(EWSAccountService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletefolder-operation """ SERVICE_NAME = 'DeleteFolder' element_container_name = None # DeleteFolder doesn't return a response object, just status in XML attrs def call(self, folders, delete_type): return self._get_elements(payload=self.get_payload(folders=folders, delete_type=delete_type)) def get_payload(self, folders, delete_type): deletefolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DeleteType=delete_type)) folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) deletefolder.append(folder_ids) return deletefolder exchangelib-3.1.1/exchangelib/services/delete_item.py000066400000000000000000000046011361226005600227150ustar00rootroot00000000000000from collections import OrderedDict from ..util import create_element from ..version import EXCHANGE_2013_SP1 from .common import EWSAccountService, EWSPooledMixIn, create_item_ids_element class DeleteItem(EWSAccountService, EWSPooledMixIn): """ Takes a folder and a list of (id, changekey) tuples. Returns result of deletion as a list of tuples (success[True|False], errormessage), in the same order as the input list. MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem """ SERVICE_NAME = 'DeleteItem' element_container_name = None # DeleteItem doesn't return a response object, just status in XML attrs def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): return self._pool_requests(payload_func=self.get_payload, **dict( items=items, delete_type=delete_type, send_meeting_cancellations=send_meeting_cancellations, affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts, )) def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request. if self.account.version.build >= EXCHANGE_2013_SP1: deleteitem = create_element( 'm:%s' % self.SERVICE_NAME, attrs=OrderedDict([ ('DeleteType', delete_type), ('SendMeetingCancellations', send_meeting_cancellations), ('AffectedTaskOccurrences', affected_task_occurrences), ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'), ]) ) else: deleteitem = create_element( 'm:%s' % self.SERVICE_NAME, attrs=OrderedDict([ ('DeleteType', delete_type), ('SendMeetingCancellations', send_meeting_cancellations), ('AffectedTaskOccurrences', affected_task_occurrences), ]) ) item_ids = create_item_ids_element(items=items, version=self.account.version) deleteitem.append(item_ids) return deleteitem exchangelib-3.1.1/exchangelib/services/empty_folder.py000066400000000000000000000022711361226005600231270ustar00rootroot00000000000000from collections import OrderedDict from ..util import create_element from .common import EWSAccountService, create_folder_ids_element class EmptyFolder(EWSAccountService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/emptyfolder """ SERVICE_NAME = 'EmptyFolder' element_container_name = None # EmptyFolder doesn't return a response object, just status in XML attrs def call(self, folders, delete_type, delete_sub_folders): return self._get_elements(payload=self.get_payload(folders=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders)) def get_payload(self, folders, delete_type, delete_sub_folders): emptyfolder = create_element( 'm:%s' % self.SERVICE_NAME, attrs=OrderedDict([ ('DeleteType', delete_type), ('DeleteSubFolders', 'true' if delete_sub_folders else 'false'), ]) ) folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) emptyfolder.append(folder_ids) return emptyfolder exchangelib-3.1.1/exchangelib/services/expand_dl.py000066400000000000000000000021071361226005600223720ustar00rootroot00000000000000from ..errors import ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults from ..util import create_element, set_xml_value, MNS from .common import EWSService class ExpandDL(EWSService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/expanddl-operation """ SERVICE_NAME = 'ExpandDL' element_container_name = '{%s}DLExpansion' % MNS ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults def call(self, distribution_list): from ..properties import Mailbox elements = self._get_elements(payload=self.get_payload(distribution_list=distribution_list)) for elem in elements: if isinstance(elem, Exception): raise elem yield Mailbox.from_xml(elem, account=None) def get_payload(self, distribution_list): payload = create_element('m:%s' % self.SERVICE_NAME) set_xml_value(payload, distribution_list, version=self.protocol.version) return payload exchangelib-3.1.1/exchangelib/services/export_items.py000066400000000000000000000021201361226005600231510ustar00rootroot00000000000000from ..errors import ResponseMessageError from ..util import create_element, MNS from .common import EWSAccountService, EWSPooledMixIn, create_item_ids_element class ExportItems(EWSAccountService, EWSPooledMixIn): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exportitems-operation """ ERRORS_TO_CATCH_IN_RESPONSE = ResponseMessageError SERVICE_NAME = 'ExportItems' element_container_name = '{%s}Data' % MNS def call(self, items): return self._pool_requests(payload_func=self.get_payload, **dict(items=items)) def get_payload(self, items): exportitems = create_element('m:%s' % self.SERVICE_NAME) item_ids = create_item_ids_element(items=items, version=self.account.version) exportitems.append(item_ids) return exportitems # We need to override this since ExportItemsResponseMessage is formatted a # little bit differently. Namely, all we want is the 64bit string in the # Data tag. def _get_elements_in_container(self, container): return [container.text] exchangelib-3.1.1/exchangelib/services/find_folder.py000066400000000000000000000057421361226005600227170ustar00rootroot00000000000000from collections import OrderedDict from ..util import create_element, set_xml_value, TNS from ..version import EXCHANGE_2010 from .common import EWSFolderService, PagingEWSMixIn, create_shape_element class FindFolder(EWSFolderService, PagingEWSMixIn): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findfolder """ SERVICE_NAME = 'FindFolder' element_container_name = '{%s}Folders' % TNS def call(self, additional_fields, restriction, shape, depth, max_items, offset): """ Find subfolders of a folder. :param additional_fields: the extra fields that should be returned with the folder, as FieldPath objects :param shape: The set of attributes to return :param depth: How deep in the folder structure to search for folders :param max_items: The maximum number of items to return :param offset: the offset relative to the first item in the item collection. Usually 0. :return: XML elements for the matching folders """ from ..folders import Folder roots = {f.root for f in self.folders} if len(roots) != 1: raise ValueError('FindFolder must be called with folders in the same root hierarchy (%r)' % roots) root = roots.pop() for elem in self._paged_call(payload_func=self.get_payload, max_items=max_items, **dict( additional_fields=additional_fields, restriction=restriction, shape=shape, depth=depth, page_size=self.chunk_size, offset=offset, )): if isinstance(elem, Exception): yield elem continue yield Folder.from_xml_with_root(elem=elem, root=root) def get_payload(self, additional_fields, restriction, shape, depth, page_size, offset=0): findfolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) foldershape = create_shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version ) findfolder.append(foldershape) if self.account.version.build >= EXCHANGE_2010: indexedpageviewitem = create_element( 'm:IndexedPageFolderView', attrs=OrderedDict([ ('MaxEntriesReturned', str(page_size)), ('Offset', str(offset)), ('BasePoint', 'Beginning'), ]) ) findfolder.append(indexedpageviewitem) else: if offset != 0: raise ValueError('Offsets are only supported from Exchange 2010') if restriction: findfolder.append(restriction.to_xml(version=self.account.version)) parentfolderids = create_element('m:ParentFolderIds') set_xml_value(parentfolderids, self.folders, version=self.account.version) findfolder.append(parentfolderids) return findfolder exchangelib-3.1.1/exchangelib/services/find_item.py000066400000000000000000000062471361226005600224030ustar00rootroot00000000000000from collections import OrderedDict from ..util import create_element, set_xml_value, TNS from .common import EWSFolderService, PagingEWSMixIn, create_shape_element class FindItem(EWSFolderService, PagingEWSMixIn): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditem """ SERVICE_NAME = 'FindItem' element_container_name = '{%s}Items' % TNS def call(self, additional_fields, restriction, order_fields, shape, query_string, depth, calendar_view, max_items, offset): """ Find items in an account. :param additional_fields: the extra fields that should be returned with the item, as FieldPath objects :param restriction: a Restriction object for :param order_fields: the fields to sort the results by :param shape: The set of attributes to return :param query_string: a QueryString object :param depth: How deep in the folder structure to search for items :param calendar_view: If set, returns recurring calendar items unfolded :param max_items: the max number of items to return :param offset: the offset relative to the first item in the item collection. Usually 0. :return: XML elements for the matching items """ return self._paged_call(payload_func=self.get_payload, max_items=max_items, **dict( additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, query_string=query_string, shape=shape, depth=depth, calendar_view=calendar_view, page_size=self.chunk_size, offset=offset, )) def get_payload(self, additional_fields, restriction, order_fields, query_string, shape, depth, calendar_view, page_size, offset=0): finditem = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) itemshape = create_shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version ) finditem.append(itemshape) if calendar_view is None: view_type = create_element( 'm:IndexedPageItemView', attrs=OrderedDict([ ('MaxEntriesReturned', str(page_size)), ('Offset', str(offset)), ('BasePoint', 'Beginning'), ]) ) else: view_type = calendar_view.to_xml(version=self.account.version) finditem.append(view_type) if restriction: finditem.append(restriction.to_xml(version=self.account.version)) if order_fields: finditem.append(set_xml_value( create_element('m:SortOrder'), order_fields, version=self.account.version )) finditem.append(set_xml_value( create_element('m:ParentFolderIds'), self.folders, version=self.account.version )) if query_string: finditem.append(query_string.to_xml(version=self.account.version)) return finditem exchangelib-3.1.1/exchangelib/services/find_people.py000066400000000000000000000133251361226005600227240ustar00rootroot00000000000000from collections import OrderedDict import logging from ..errors import MalformedResponseError, ErrorServerBusy from ..util import create_element, set_xml_value, xml_to_str, MNS from .common import EWSAccountService, PagingEWSMixIn, create_shape_element log = logging.getLogger(__name__) class FindPeople(EWSAccountService, PagingEWSMixIn): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findpeople-operation """ SERVICE_NAME = 'FindPeople' element_container_name = '{%s}People' % MNS def call(self, folder, additional_fields, restriction, order_fields, shape, query_string, depth, max_items, offset): """ Find items in an account. :param folder: the Folder object to query :param additional_fields: the extra fields that should be returned with the item, as FieldPath objects :param restriction: a Restriction object for :param order_fields: the fields to sort the results by :param shape: The set of attributes to return :param query_string: a QueryString object :param depth: How deep in the folder structure to search for items :param max_items: the max number of items to return :param offset: the offset relative to the first item in the item collection. Usually 0. :return: XML elements for the matching items """ from ..items import Persona, ID_ONLY personas = self._paged_call(payload_func=self.get_payload, max_items=max_items, **dict( folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, query_string=query_string, shape=shape, depth=depth, page_size=self.chunk_size, offset=offset, )) if shape == ID_ONLY and additional_fields is None: for p in personas: yield p if isinstance(p, Exception) else Persona.id_from_xml(p) else: for p in personas: yield p if isinstance(p, Exception) else Persona.from_xml(p, account=self.account) def get_payload(self, folder, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0): findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) personashape = create_shape_element( tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version ) findpeople.append(personashape) view_type = create_element( 'm:IndexedPageItemView', attrs=OrderedDict([ ('MaxEntriesReturned', str(page_size)), ('Offset', str(offset)), ('BasePoint', 'Beginning'), ]) ) findpeople.append(view_type) if restriction: findpeople.append(restriction.to_xml(version=self.account.version)) if order_fields: findpeople.append(set_xml_value( create_element('m:SortOrder'), order_fields, version=self.account.version )) findpeople.append(set_xml_value( create_element('m:ParentFolderId'), folder, version=self.account.version )) if query_string: findpeople.append(query_string.to_xml(version=self.account.version)) return findpeople def _paged_call(self, payload_func, max_items, **kwargs): item_count = kwargs['offset'] while True: log.debug('EWS %s, account %s, service %s: Getting items at offset %s', self.protocol.service_endpoint, self.account, self.SERVICE_NAME, item_count) kwargs['offset'] = item_count try: response = self._get_response_xml(payload=payload_func(**kwargs)) except ErrorServerBusy as e: self._handle_backoff(e) continue # Collect a tuple of (rootfolder, total_items) tuples parsed_pages = [self._get_page(message) for message in response] if len(parsed_pages) != 1: # We can only query one folder, so there should only be one element in response raise MalformedResponseError("Expected single item in 'response', got %s" % len(parsed_pages)) rootfolder, total_items = parsed_pages[0] if rootfolder is not None: container = rootfolder.find(self.element_container_name) if container is None: raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % ( self.element_container_name, xml_to_str(rootfolder))) for elem in self._get_elements_in_container(container=container): item_count += 1 yield elem if max_items and item_count >= max_items: log.debug("'max_items' count reached") break if total_items <= 0 or item_count >= total_items: log.debug('Got all items in view') break def _get_page(self, message): self._get_element_container(message=message) # Just raise exceptions total_items = int(message.find('{%s}TotalNumberOfPeopleInView' % MNS).text) first_matching = int(message.find('{%s}FirstMatchingRowIndex' % MNS).text) first_loaded = int(message.find('{%s}FirstLoadedRowIndex' % MNS).text) log.debug('%s: Got page with total items %s, first matching %s, first loaded %s ', self.SERVICE_NAME, total_items, first_matching, first_loaded) return message, total_items exchangelib-3.1.1/exchangelib/services/get_attachment.py000066400000000000000000000074341361226005600234330ustar00rootroot00000000000000from ..util import create_element, add_xml_child, DummyResponse, StreamingBase64Parser, StreamingContentHandler, \ ElementNotFound, MNS from .common import EWSAccountService, create_attachment_ids_element class GetAttachment(EWSAccountService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getattachment-operation """ SERVICE_NAME = 'GetAttachment' element_container_name = '{%s}Attachments' % MNS streaming = True def call(self, items, include_mime_content): return self._get_elements(payload=self.get_payload( items=items, include_mime_content=include_mime_content, )) def get_payload(self, items, include_mime_content): payload = create_element('m:%s' % self.SERVICE_NAME) # TODO: Support additional properties of AttachmentShape. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attachmentshape if include_mime_content: attachment_shape = create_element('m:AttachmentShape') add_xml_child(attachment_shape, 't:IncludeMimeContent', 'true') payload.append(attachment_shape) attachment_ids = create_attachment_ids_element(items=items, version=self.account.version) payload.append(attachment_ids) return payload def _update_api_version(self, version_hint, api_version, header, **parse_opts): if not parse_opts.get('stream_file_content', False): return super()._update_api_version(version_hint, api_version, header, **parse_opts) # TODO: We're skipping this part in streaming mode because our streaming parser cannot parse the SOAP header @classmethod def _get_soap_parts(cls, response, **parse_opts): if not parse_opts.get('stream_file_content', False): return super()._get_soap_parts(response, **parse_opts) # Pass the response unaltered. We want to use our custom streaming parser return None, response @classmethod def _get_soap_messages(cls, body, **parse_opts): if not parse_opts.get('stream_file_content', False): return super()._get_soap_messages(body, **parse_opts) # 'body' is actually the raw response passed on by '_get_soap_parts' from ..attachments import FileAttachment parser = StreamingBase64Parser() field = FileAttachment.get_field_by_fieldname('_content') handler = StreamingContentHandler(parser=parser, ns=field.namespace, element_name=field.field_uri) parser.setContentHandler(handler) return parser.parse(body) def stream_file_content(self, attachment_id): # The streaming XML parser can only stream content of one attachment payload = self.get_payload(items=[attachment_id], include_mime_content=False) try: for chunk in self._get_response_xml(payload=payload, stream_file_content=True): yield chunk except ElementNotFound as enf: # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_payload() expects an iter_content() method. response = DummyResponse(url=None, headers=None, request_headers=None, content=enf.data) _, body = super()._get_soap_parts(response=response) res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): if isinstance(e, Exception): raise e # The returned content did not contain any EWS exceptions. Give up and re-raise the original exception. raise enf exchangelib-3.1.1/exchangelib/services/get_delegate.py000066400000000000000000000044401361226005600230470ustar00rootroot00000000000000from ..util import create_element, set_xml_value, MNS from ..version import EXCHANGE_2007_SP1 from .common import EWSAccountService, EWSPooledMixIn class GetDelegate(EWSAccountService, EWSPooledMixIn): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getdelegate-operation """ SERVICE_NAME = 'GetDelegate' def call(self, user_ids, include_permissions): if self.protocol.version.build < EXCHANGE_2007_SP1: raise NotImplementedError( '%r is only supported for Exchange 2007 SP1 servers and later' % self.SERVICE_NAME) from ..properties import DLMailbox, DelegateUser # The service expects a Mailbox element in the MNS namespace for elem in self._pool_requests( items=user_ids, payload_func=self.get_payload, **dict( mailbox=DLMailbox(email_address=self.account.primary_smtp_address), include_permissions=include_permissions, ) ): if isinstance(elem, Exception): raise elem yield DelegateUser.from_xml(elem=elem, account=self.account) def get_payload(self, mailbox, user_ids, include_permissions): payload = create_element( 'm:%s' % self.SERVICE_NAME, attrs=dict(IncludePermissions='true' if include_permissions else 'false'), ) set_xml_value(payload, mailbox, version=self.protocol.version) if user_ids: set_xml_value(payload, user_ids, version=self.protocol.version) return payload def _get_elements_in_container(self, container): # We may have other elements in here, e.g. 'ResponseCode'. Filter away those. from ..properties import DelegateUser return container.findall(DelegateUser.response_tag()) def _get_element_container(self, message, response_message=None, name=None): # Do nothing. See self._response_message_tag. return message @classmethod def _response_message_tag(cls): # We're using this in place of self.element_container_name because self._get_soap_messages expects to find # elements at this level. We'll let self._get_element_container do nothing instead. return '{%s}DelegateUserResponseMessageType' % MNS exchangelib-3.1.1/exchangelib/services/get_folder.py000066400000000000000000000043221361226005600225470ustar00rootroot00000000000000from ..errors import ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation from ..util import create_element, MNS from .common import EWSAccountService, EWSPooledMixIn, parse_folder_elem, create_folder_ids_element,\ create_shape_element class GetFolder(EWSAccountService, EWSPooledMixIn): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getfolder """ SERVICE_NAME = 'GetFolder' element_container_name = '{%s}Folders' % MNS ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation, ) def call(self, folders, additional_fields, shape): """ Takes a folder ID and returns the full information for that folder. :param folders: a list of Folder objects :param additional_fields: the extra fields that should be returned with the folder, as FieldPath objects :param shape: The set of attributes to return :return: XML elements for the folders, in stable order """ # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. folders_list = list(folders) # Convert to a list, in case 'folders' is a generator for folder, elem in zip(folders_list, self._pool_requests( payload_func=self.get_payload, items=folders, **dict( additional_fields=additional_fields, shape=shape, ) )): yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, additional_fields, shape): getfolder = create_element('m:%s' % self.SERVICE_NAME) foldershape = create_shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version ) getfolder.append(foldershape) folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) getfolder.append(folder_ids) return getfolder exchangelib-3.1.1/exchangelib/services/get_item.py000066400000000000000000000027251361226005600222370ustar00rootroot00000000000000from ..util import create_element, MNS from .common import EWSAccountService, EWSPooledMixIn, create_item_ids_element, create_shape_element class GetItem(EWSAccountService, EWSPooledMixIn): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getitem """ SERVICE_NAME = 'GetItem' element_container_name = '{%s}Items' % MNS def call(self, items, additional_fields, shape): """ Returns all items in an account that correspond to a list of ID's, in stable order. :param items: a list of (id, changekey) tuples or Item objects :param additional_fields: the extra fields that should be returned with the item, as FieldPath objects :param shape: The shape of returned objects :return: XML elements for the items, in stable order """ return self._pool_requests(payload_func=self.get_payload, **dict( items=items, additional_fields=additional_fields, shape=shape, )) def get_payload(self, items, additional_fields, shape): getitem = create_element('m:%s' % self.SERVICE_NAME) itemshape = create_shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version ) getitem.append(itemshape) item_ids = create_item_ids_element(items=items, version=self.account.version) getitem.append(item_ids) return getitem exchangelib-3.1.1/exchangelib/services/get_mail_tips.py000066400000000000000000000031651361226005600232610ustar00rootroot00000000000000from ..util import create_element, set_xml_value, MNS from .common import EWSService class GetMailTips(EWSService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getmailtips-operation """ SERVICE_NAME = 'GetMailTips' def call(self, sending_as, recipients, mail_tips_requested): from ..properties import MailTips for elem in self._get_elements(payload=self.get_payload( sending_as=sending_as, recipients=recipients, mail_tips_requested=mail_tips_requested, )): yield MailTips.from_xml(elem=elem, account=None) def get_payload(self, sending_as, recipients, mail_tips_requested): payload = create_element('m:%s' % self.SERVICE_NAME) set_xml_value(payload, sending_as, version=self.protocol.version) recipients_elem = create_element('m:Recipients') for recipient in recipients: set_xml_value(recipients_elem, recipient, version=self.protocol.version) if not len(recipients_elem): raise ValueError('"recipients" must not be empty') payload.append(recipients_elem) if mail_tips_requested: set_xml_value(payload, mail_tips_requested, version=self.protocol.version) return payload def _get_elements_in_response(self, response): from ..properties import MailTips for msg in response: yield self._get_element_container(message=msg, name=MailTips.response_tag()) @classmethod def _response_message_tag(cls): return '{%s}MailTipsResponseMessageType' % MNS exchangelib-3.1.1/exchangelib/services/get_persona.py000066400000000000000000000021031361226005600227360ustar00rootroot00000000000000from ..util import create_element, set_xml_value, MNS from .common import EWSService, to_item_id class GetPersona(EWSService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getpersona-operation """ SERVICE_NAME = 'GetPersona' def call(self, persona): from ..items import Persona elements = list(self._get_elements(payload=self.get_payload(persona=persona))) if len(elements) != 1: raise ValueError('Expected exactly one element in response') elem = elements[0] if isinstance(elem, Exception): raise elem return Persona.from_xml(elem=elem.find(Persona.response_tag()), account=None) def get_payload(self, persona): from ..properties import PersonaId payload = create_element('m:%s' % self.SERVICE_NAME) set_xml_value(payload, to_item_id(persona, PersonaId), version=self.protocol.version) return payload @classmethod def _response_tag(cls): return '{%s}%sResponseMessage' % (MNS, cls.SERVICE_NAME) exchangelib-3.1.1/exchangelib/services/get_room_lists.py000066400000000000000000000014361361226005600234710ustar00rootroot00000000000000from ..util import create_element, MNS from ..version import EXCHANGE_2010 from .common import EWSService class GetRoomLists(EWSService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getroomlists """ SERVICE_NAME = 'GetRoomLists' element_container_name = '{%s}RoomLists' % MNS def call(self): from ..properties import RoomList if self.protocol.version.build < EXCHANGE_2010: raise NotImplementedError('%s is only supported for Exchange 2010 servers and later' % self.SERVICE_NAME) for elem in self._get_elements(payload=self.get_payload()): yield RoomList.from_xml(elem=elem, account=None) def get_payload(self): return create_element('m:%s' % self.SERVICE_NAME) exchangelib-3.1.1/exchangelib/services/get_rooms.py000066400000000000000000000016371361226005600224410ustar00rootroot00000000000000from ..util import create_element, set_xml_value, MNS from ..version import EXCHANGE_2010 from .common import EWSService class GetRooms(EWSService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getrooms """ SERVICE_NAME = 'GetRooms' element_container_name = '{%s}Rooms' % MNS def call(self, roomlist): from ..properties import Room if self.protocol.version.build < EXCHANGE_2010: raise NotImplementedError('%s is only supported for Exchange 2010 servers and later' % self.SERVICE_NAME) for elem in self._get_elements(payload=self.get_payload(roomlist=roomlist)): yield Room.from_xml(elem=elem, account=None) def get_payload(self, roomlist): getrooms = create_element('m:%s' % self.SERVICE_NAME) set_xml_value(getrooms, roomlist, version=self.protocol.version) return getrooms exchangelib-3.1.1/exchangelib/services/get_searchable_mailboxes.py000066400000000000000000000052551361226005600254360ustar00rootroot00000000000000from ..errors import MalformedResponseError from ..util import create_element, add_xml_child, MNS from ..version import EXCHANGE_2013 from .common import EWSService class GetSearchableMailboxes(EWSService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getsearchablemailboxes-operation """ SERVICE_NAME = 'GetSearchableMailboxes' element_container_name = '{%s}SearchableMailboxes' % MNS failed_mailboxes_container_name = '{%s}FailedMailboxes' % MNS def call(self, search_filter, expand_group_membership): if self.protocol.version.build < EXCHANGE_2013: raise NotImplementedError('%s is only supported for Exchange 2013 servers and later' % self.SERVICE_NAME) from ..properties import SearchableMailbox, FailedMailbox for elem in self._get_elements(payload=self.get_payload( search_filter=search_filter, expand_group_membership=expand_group_membership, )): if isinstance(elem, Exception): yield elem continue if elem.tag == SearchableMailbox.response_tag(): yield SearchableMailbox.from_xml(elem=elem, account=None) elif elem.tag == FailedMailbox.response_tag(): yield FailedMailbox.from_xml(elem=elem, account=None) else: raise ValueError("Unknown element tag '%s': (%s)" % (elem.tag, elem)) def get_payload(self, search_filter, expand_group_membership): payload = create_element('m:%s' % self.SERVICE_NAME) if search_filter: add_xml_child(payload, 'm:SearchFilter', search_filter) if expand_group_membership is not None: add_xml_child(payload, 'm:ExpandGroupMembership', 'true' if expand_group_membership else 'false') return payload def _get_elements_in_response(self, response): for msg in response: for container_name in (self.element_container_name, self.failed_mailboxes_container_name): try: container_or_exc = self._get_element_container(message=msg, name=container_name) except MalformedResponseError: # Responses bay contain no failed mailboxes. _get_element_container() does not accept this. if container_name == self.failed_mailboxes_container_name: continue raise if isinstance(container_or_exc, (bool, Exception)): yield container_or_exc else: for c in self._get_elements_in_container(container=container_or_exc): yield c exchangelib-3.1.1/exchangelib/services/get_server_time_zones.py000066400000000000000000000133031361226005600250350ustar00rootroot00000000000000import datetime from ..errors import NaiveDateTimeNotAllowed from ..ewsdatetime import EWSDateTime from ..fields import WEEKDAY_NAMES from ..util import create_element, set_xml_value, xml_text_to_value, peek, TNS, MNS from ..version import EXCHANGE_2010 from .common import EWSService class GetServerTimeZones(EWSService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getservertimezones """ SERVICE_NAME = 'GetServerTimeZones' element_container_name = '{%s}TimeZoneDefinitions' % MNS def call(self, timezones=None, return_full_timezone_data=False): if self.protocol.version.build < EXCHANGE_2010: raise NotImplementedError('%s is only supported for Exchange 2010 servers and later' % self.SERVICE_NAME) return self._get_elements(payload=self.get_payload( timezones=timezones, return_full_timezone_data=return_full_timezone_data )) def get_payload(self, timezones, return_full_timezone_data): payload = create_element( 'm:%s' % self.SERVICE_NAME, attrs=dict(ReturnFullTimeZoneData='true' if return_full_timezone_data else 'false'), ) if timezones is not None: is_empty, timezones = peek(timezones) if not is_empty: tz_ids = create_element('m:Ids') for timezone in timezones: tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id, version=self.protocol.version) tz_ids.append(tz_id) payload.append(tz_ids) return payload def _get_elements_in_container(self, container): for timezonedef in container: tz_id = timezonedef.get('Id') tz_name = timezonedef.get('Name') tz_periods = self._get_periods(timezonedef) tz_transitions_groups = self._get_transitions_groups(timezonedef) tz_transitions = self._get_transitions(timezonedef) yield (tz_id, tz_name, tz_periods, tz_transitions, tz_transitions_groups) @staticmethod def _get_periods(timezonedef): tz_periods = {} periods = timezonedef.find('{%s}Periods' % TNS) for period in periods.findall('{%s}Period' % TNS): # Convert e.g. "trule:Microsoft/Registry/W. Europe Standard Time/2006-Daylight" to (2006, 'Daylight') p_year, p_type = period.get('Id').rsplit('/', 1)[1].split('-') tz_periods[(int(p_year), p_type)] = dict( name=period.get('Name'), bias=xml_text_to_value(period.get('Bias'), datetime.timedelta) ) return tz_periods @staticmethod def _get_transitions_groups(timezonedef): tz_transitions_groups = {} transitiongroups = timezonedef.find('{%s}TransitionsGroups' % TNS) if transitiongroups is not None: for transitiongroup in transitiongroups.findall('{%s}TransitionsGroup' % TNS): tg_id = int(transitiongroup.get('Id')) tz_transitions_groups[tg_id] = [] for transition in transitiongroup.findall('{%s}Transition' % TNS): # Apply same conversion to To as for period IDs to_year, to_type = transition.find('{%s}To' % TNS).text.rsplit('/', 1)[1].split('-') tz_transitions_groups[tg_id].append(dict( to=(int(to_year), to_type), )) for transition in transitiongroup.findall('{%s}RecurringDayTransition' % TNS): # Apply same conversion to To as for period IDs to_year, to_type = transition.find('{%s}To' % TNS).text.rsplit('/', 1)[1].split('-') occurrence = xml_text_to_value(transition.find('{%s}Occurrence' % TNS).text, int) if occurrence == -1: # See TimeZoneTransition.from_xml() occurrence = 5 tz_transitions_groups[tg_id].append(dict( to=(int(to_year), to_type), offset=xml_text_to_value(transition.find('{%s}TimeOffset' % TNS).text, datetime.timedelta), iso_month=xml_text_to_value(transition.find('{%s}Month' % TNS).text, int), iso_weekday=WEEKDAY_NAMES.index(transition.find('{%s}DayOfWeek' % TNS).text) + 1, occurrence=occurrence, )) return tz_transitions_groups @staticmethod def _get_transitions(timezonedef): tz_transitions = {} transitions = timezonedef.find('{%s}Transitions' % TNS) if transitions is not None: for transition in transitions.findall('{%s}Transition' % TNS): to = transition.find('{%s}To' % TNS) if to.get('Kind') != 'Group': raise ValueError('Unexpected "Kind" XML attr: %s' % to.get('Kind')) tg_id = xml_text_to_value(to.text, int) tz_transitions[tg_id] = None for transition in transitions.findall('{%s}AbsoluteDateTransition' % TNS): to = transition.find('{%s}To' % TNS) if to.get('Kind') != 'Group': raise ValueError('Unexpected "Kind" XML attr: %s' % to.get('Kind')) tg_id = xml_text_to_value(to.text, int) try: t_date = xml_text_to_value(transition.find('{%s}DateTime' % TNS).text, EWSDateTime).date() except NaiveDateTimeNotAllowed as e: # We encountered a naive datetime. Don't worry. we just need the date t_date = e.args[0].date() tz_transitions[tg_id] = t_date return tz_transitions exchangelib-3.1.1/exchangelib/services/get_user_availability.py000066400000000000000000000042011361226005600250000ustar00rootroot00000000000000from ..util import create_element, set_xml_value, MNS from .common import EWSService class GetUserAvailability(EWSService): """ Get detailed availability information for a list of users MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuseravailability-operation """ SERVICE_NAME = 'GetUserAvailability' def call(self, timezone, mailbox_data, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions from ..properties import FreeBusyView for elem in self._get_elements(payload=self.get_payload( timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options )): if isinstance(elem, Exception): yield elem continue yield FreeBusyView.from_xml(elem=elem, account=None) def get_payload(self, timezone, mailbox_data, free_busy_view_options): payload = create_element('m:%sRequest' % self.SERVICE_NAME) set_xml_value(payload, timezone, version=self.protocol.version) mailbox_data_array = create_element('m:MailboxDataArray') set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version) payload.append(mailbox_data_array) set_xml_value(payload, free_busy_view_options, version=self.protocol.version) return payload @staticmethod def _response_messages_tag(): return '{%s}FreeBusyResponseArray' % MNS @classmethod def _response_message_tag(cls): return '{%s}FreeBusyResponse' % MNS def _get_elements_in_response(self, response): for msg in response: # Just check the response code and raise errors self._get_element_container(message=msg.find('{%s}ResponseMessage' % MNS)) for c in self._get_elements_in_container(container=msg): yield c def _get_elements_in_container(self, container): return [container.find('{%s}FreeBusyView' % MNS)] exchangelib-3.1.1/exchangelib/services/get_user_oof_settings.py000066400000000000000000000033261361226005600250400ustar00rootroot00000000000000from ..util import create_element, set_xml_value, MNS, TNS from .common import EWSAccountService class GetUserOofSettings(EWSAccountService): """ Get automatic reply settings for the specified mailbox. MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuseroofsettings-operation """ SERVICE_NAME = 'GetUserOofSettings' element_container_name = '{%s}OofSettings' % TNS def call(self, mailbox): return self._get_elements(payload=self.get_payload(mailbox=mailbox)) def get_payload(self, mailbox): from ..properties import AvailabilityMailbox payload = create_element('m:%sRequest' % self.SERVICE_NAME) return set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) def _get_elements_in_response(self, response): # This service only returns one result, but 'response' is a list from ..settings import OofSettings response = list(response) if len(response) != 1: raise ValueError("Expected 'response' length 1, got %s" % response) msg = response[0] container_or_exc = self._get_element_container(message=msg, name=self.element_container_name) if isinstance(container_or_exc, (bool, Exception)): # pylint: disable=raising-bad-type raise container_or_exc return OofSettings.from_xml(container_or_exc, account=self.account) def _get_element_container(self, message, response_message=None, name=None): response_message = message.find('{%s}ResponseMessage' % MNS) return super()._get_element_container( message=message, response_message=response_message, name=name ) exchangelib-3.1.1/exchangelib/services/move_item.py000066400000000000000000000017531361226005600224260ustar00rootroot00000000000000from ..util import create_element, set_xml_value, MNS from .common import EWSAccountService, create_item_ids_element class MoveItem(EWSAccountService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/moveitem-operation """ SERVICE_NAME = 'MoveItem' element_container_name = '{%s}Items' % MNS def call(self, items, to_folder): return self._get_elements(payload=self.get_payload( items=items, to_folder=to_folder, )) def get_payload(self, items, to_folder): # Takes a list of items and returns their new item IDs moveitem = create_element('m:%s' % self.SERVICE_NAME) tofolderid = create_element('m:ToFolderId') set_xml_value(tofolderid, to_folder, version=self.account.version) moveitem.append(tofolderid) item_ids = create_item_ids_element(items=items, version=self.account.version) moveitem.append(item_ids) return moveitem exchangelib-3.1.1/exchangelib/services/resolve_names.py000066400000000000000000000057741361226005600233130ustar00rootroot00000000000000from ..errors import ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults from ..util import create_element, set_xml_value, add_xml_child, MNS from ..version import EXCHANGE_2010_SP2 from .common import EWSService class ResolveNames(EWSService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/resolvenames """ # TODO: Does not support paged responses yet. See example in issue #205 SERVICE_NAME = 'ResolveNames' element_container_name = '{%s}ResolutionSet' % MNS ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None, contact_data_shape=None): from ..items import Contact from ..properties import Mailbox elements = self._get_elements(payload=self.get_payload( unresolved_entries=unresolved_entries, parent_folders=parent_folders, return_full_contact_data=return_full_contact_data, search_scope=search_scope, contact_data_shape=contact_data_shape, )) for elem in elements: if isinstance(elem, ErrorNameResolutionNoResults): continue if isinstance(elem, Exception): raise elem if return_full_contact_data: mailbox_elem = elem.find(Mailbox.response_tag()) contact_elem = elem.find(Contact.response_tag()) yield ( None if mailbox_elem is None else Mailbox.from_xml(elem=mailbox_elem, account=None), None if contact_elem is None else Contact.from_xml(elem=contact_elem, account=None), ) else: yield Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, contact_data_shape): payload = create_element( 'm:%s' % self.SERVICE_NAME, attrs=dict(ReturnFullContactData='true' if return_full_contact_data else 'false'), ) if search_scope: payload.set('SearchScope', search_scope) if contact_data_shape: if self.protocol.version.build < EXCHANGE_2010_SP2: raise NotImplementedError( "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later") payload.set('ContactDataShape', contact_data_shape) if parent_folders: parentfolderids = create_element('m:ParentFolderIds') set_xml_value(parentfolderids, parent_folders, version=self.protocol.version) for entry in unresolved_entries: add_xml_child(payload, 'm:UnresolvedEntry', entry) if not len(payload): raise ValueError('"unresolved_entries" must not be empty') return payload exchangelib-3.1.1/exchangelib/services/send_item.py000066400000000000000000000022261361226005600224050ustar00rootroot00000000000000from ..util import create_element, set_xml_value from .common import EWSAccountService, create_item_ids_element class SendItem(EWSAccountService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/senditem-operation """ SERVICE_NAME = 'SendItem' element_container_name = None # SendItem doesn't return a response object, just status in XML attrs def call(self, items, saved_item_folder): return self._get_elements(payload=self.get_payload(items=items, saved_item_folder=saved_item_folder)) def get_payload(self, items, saved_item_folder): senditem = create_element( 'm:%s' % self.SERVICE_NAME, attrs=dict(SaveItemToFolder='true' if saved_item_folder else 'false'), ) item_ids = create_item_ids_element(items=items, version=self.account.version) senditem.append(item_ids) if saved_item_folder: saveditemfolderid = create_element('m:SavedItemFolderId') set_xml_value(saveditemfolderid, saved_item_folder, version=self.account.version) senditem.append(saveditemfolderid) return senditem exchangelib-3.1.1/exchangelib/services/set_user_oof_settings.py000066400000000000000000000024171361226005600250540ustar00rootroot00000000000000from ..util import create_element, set_xml_value, MNS from .common import EWSAccountService class SetUserOofSettings(EWSAccountService): """ Set automatic replies for the specified mailbox. MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/setuseroofsettings-operation """ SERVICE_NAME = 'SetUserOofSettings' def call(self, oof_settings, mailbox): res = list(self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox))) if len(res) != 1: raise ValueError("Expected 'res' length 1, got %s" % res) return res[0] def get_payload(self, oof_settings, mailbox): from ..properties import AvailabilityMailbox payload = create_element('m:%sRequest' % self.SERVICE_NAME) set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) set_xml_value(payload, oof_settings, version=self.account.version) return payload def _get_element_container(self, message, response_message=None, name=None): response_message = message.find('{%s}ResponseMessage' % MNS) return super()._get_element_container( message=message, response_message=response_message, name=name ) exchangelib-3.1.1/exchangelib/services/update_folder.py000066400000000000000000000103011361226005600232440ustar00rootroot00000000000000import logging from ..util import create_element, set_xml_value, MNS from .common import EWSAccountService, parse_folder_elem, to_item_id log = logging.getLogger(__name__) class UpdateFolder(EWSAccountService): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updatefolder-operation """ SERVICE_NAME = 'UpdateFolder' element_container_name = '{%s}Folders' % MNS def call(self, folders): # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. folders_list = list(f[0] for f in folders) # Convert to a list, in case 'folders' is a generator for folder, elem in zip(folders_list, self._get_elements(payload=self.get_payload(folders=folders))): yield parse_folder_elem(elem=elem, folder=folder, account=self.account) @staticmethod def _sort_fieldnames(folder_model, fieldnames): # Take a list of fieldnames and return the fields in the order they are mentioned in folder_model.FIELDS. # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field. for f in folder_model.FIELDS: if f.name in fieldnames: yield f.name def _set_folder_elem(self, folder_model, field_path, value): setfolderfield = create_element('t:SetFolderField') set_xml_value(setfolderfield, field_path, version=self.account.version) folder = create_element(folder_model.request_tag()) field_elem = field_path.field.to_xml(value, version=self.account.version) set_xml_value(folder, field_elem, version=self.account.version) setfolderfield.append(folder) return setfolderfield def _delete_folder_elem(self, field_path): deletefolderfield = create_element('t:DeleteFolderField') return set_xml_value(deletefolderfield, field_path, version=self.account.version) def _get_folder_update_elems(self, folder, fieldnames): from ..fields import FieldPath folder_model = folder.__class__ fieldnames_set = set(fieldnames) for fieldname in self._sort_fieldnames(folder_model=folder_model, fieldnames=fieldnames_set): field = folder_model.get_field_by_fieldname(fieldname) if field.is_read_only: raise ValueError('%s is a read-only field' % field.name) value = field.clean(getattr(folder, field.name), version=self.account.version) # Make sure the value is OK if value is None or (field.is_list and not value): # A value of None or [] means we want to remove this field from the item if field.is_required or field.is_required_after_save: raise ValueError('%s is a required field and may not be deleted' % field.name) for field_path in FieldPath(field=field).expand(version=self.account.version): yield self._delete_folder_elem(field_path=field_path) continue yield self._set_folder_elem(folder_model=folder_model, field_path=FieldPath(field=field), value=value) def get_payload(self, folders): from ..folders import BaseFolder, FolderId, DistinguishedFolderId updatefolder = create_element('m:%s' % self.SERVICE_NAME) folderchanges = create_element('m:FolderChanges') for folder, fieldnames in folders: log.debug('Updating folder %s', folder) folderchange = create_element('t:FolderChange') if not isinstance(folder, (BaseFolder, FolderId, DistinguishedFolderId)): folder = to_item_id(folder, FolderId) set_xml_value(folderchange, folder, version=self.account.version) updates = create_element('t:Updates') for elem in self._get_folder_update_elems(folder=folder, fieldnames=fieldnames): updates.append(elem) folderchange.append(updates) folderchanges.append(folderchange) if not len(folderchanges): raise ValueError('"folders" must not be empty') updatefolder.append(folderchanges) return updatefolder exchangelib-3.1.1/exchangelib/services/update_item.py000066400000000000000000000225161361226005600227420ustar00rootroot00000000000000from collections import OrderedDict import logging from ..util import create_element, set_xml_value, MNS from ..version import EXCHANGE_2010, EXCHANGE_2013_SP1 from .common import EWSAccountService, EWSPooledMixIn log = logging.getLogger(__name__) class UpdateItem(EWSAccountService, EWSPooledMixIn): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem """ SERVICE_NAME = 'UpdateItem' element_container_name = '{%s}Items' % MNS def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, suppress_read_receipts): return self._pool_requests(payload_func=self.get_payload, **dict( items=items, conflict_resolution=conflict_resolution, message_disposition=message_disposition, send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, suppress_read_receipts=suppress_read_receipts, )) def _delete_item_elem(self, field_path): deleteitemfield = create_element('t:DeleteItemField') return set_xml_value(deleteitemfield, field_path, version=self.account.version) def _set_item_elem(self, item_model, field_path, value): setitemfield = create_element('t:SetItemField') set_xml_value(setitemfield, field_path, version=self.account.version) folderitem = create_element(item_model.request_tag()) field_elem = field_path.field.to_xml(value, version=self.account.version) set_xml_value(folderitem, field_elem, version=self.account.version) setitemfield.append(folderitem) return setitemfield @staticmethod def _sorted_fields(item_model, fieldnames): # Take a list of fieldnames and return the (unique) fields in the order they are mentioned in item_class.FIELDS. # Checks that all fieldnames are valid. unique_fieldnames = list(OrderedDict.fromkeys(fieldnames)) # Make field names unique ,but keep ordering # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field. for f in item_model.FIELDS: if f.name in unique_fieldnames: unique_fieldnames.remove(f.name) yield f if unique_fieldnames: raise ValueError("Field name(s) %s are not valid for a '%s' item" % ( ', '.join("'%s'" % f for f in unique_fieldnames), item_model.__name__)) def _get_item_update_elems(self, item, fieldnames): from ..items import CalendarItem fieldnames_copy = list(fieldnames) if item.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to update internal timezone fields item.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.account.version.build < EXCHANGE_2010: if 'start' in fieldnames_copy or 'end' in fieldnames_copy: fieldnames_copy.append(meeting_tz_field.name) else: if 'start' in fieldnames_copy: fieldnames_copy.append(start_tz_field.name) if 'end' in fieldnames_copy: fieldnames_copy.append(end_tz_field.name) else: meeting_tz_field, start_tz_field, end_tz_field = None, None, None for field in self._sorted_fields(item_model=item.__class__, fieldnames=fieldnames_copy): if field.is_read_only: raise ValueError('%s is a read-only field' % field.name) value = self._get_item_value(item, field, meeting_tz_field, start_tz_field, end_tz_field) if value is None or (field.is_list and not value): # A value of None or [] means we want to remove this field from the item for elem in self._get_delete_item_elems(field=field): yield elem else: for elem in self._get_set_item_elems(item_model=item.__class__, field=field, value=value): yield elem def _get_item_value(self, item, field, meeting_tz_field, start_tz_field, end_tz_field): from ..items import CalendarItem value = field.clean(getattr(item, field.name), version=self.account.version) # Make sure the value is OK if item.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to send values in the local timezone if self.account.version.build < EXCHANGE_2010: if field.name in ('start', 'end'): value = value.astimezone(getattr(item, meeting_tz_field.name)) else: if field.name == 'start': value = value.astimezone(getattr(item, start_tz_field.name)) elif field.name == 'end': value = value.astimezone(getattr(item, end_tz_field.name)) return value def _get_delete_item_elems(self, field): from ..fields import FieldPath if field.is_required or field.is_required_after_save: raise ValueError('%s is a required field and may not be deleted' % field.name) for field_path in FieldPath(field=field).expand(version=self.account.version): yield self._delete_item_elem(field_path=field_path) def _get_set_item_elems(self, item_model, field, value): from ..fields import FieldPath, IndexedField from ..indexed_properties import MultiFieldIndexedElement if isinstance(field, IndexedField): # TODO: Maybe the set/delete logic should extend into subfields, not just overwrite the whole item. for v in value: # TODO: We should also delete the labels that no longer exist in the list if issubclass(field.value_cls, MultiFieldIndexedElement): # We have subfields. Generate SetItem XML for each subfield. SetItem only accepts items that # have the one value set that we want to change. Create a new IndexedField object that has # only that value set. for subfield in field.value_cls.supported_fields(version=self.account.version): yield self._set_item_elem( item_model=item_model, field_path=FieldPath(field=field, label=v.label, subfield=subfield), value=field.value_cls(**{'label': v.label, subfield.name: getattr(v, subfield.name)}), ) else: # The simpler IndexedFields with only one subfield subfield = field.value_cls.value_field(version=self.account.version) yield self._set_item_elem( item_model=item_model, field_path=FieldPath(field=field, label=v.label, subfield=subfield), value=v, ) else: yield self._set_item_elem(item_model=item_model, field_path=FieldPath(field=field), value=value) def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, suppress_read_receipts): # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' # are the attribute names that were updated. Returns the XML for an UpdateItem call. # an UpdateItem request. from ..properties import ItemId if self.account.version.build >= EXCHANGE_2013_SP1: updateitem = create_element( 'm:%s' % self.SERVICE_NAME, attrs=OrderedDict([ ('ConflictResolution', conflict_resolution), ('MessageDisposition', message_disposition), ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations), ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'), ]) ) else: updateitem = create_element( 'm:%s' % self.SERVICE_NAME, attrs=OrderedDict([ ('ConflictResolution', conflict_resolution), ('MessageDisposition', message_disposition), ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations), ]) ) itemchanges = create_element('m:ItemChanges') for item, fieldnames in items: if not fieldnames: raise ValueError('"fieldnames" must not be empty') itemchange = create_element('t:ItemChange') log.debug('Updating item %s values %s', item.id, fieldnames) set_xml_value(itemchange, ItemId(item.id, item.changekey), version=self.account.version) updates = create_element('t:Updates') for elem in self._get_item_update_elems(item=item, fieldnames=fieldnames): updates.append(elem) itemchange.append(updates) itemchanges.append(itemchange) if not len(itemchanges): raise ValueError('"items" must not be empty') updateitem.append(itemchanges) return updateitem exchangelib-3.1.1/exchangelib/services/upload_items.py000066400000000000000000000035331361226005600231250ustar00rootroot00000000000000from ..util import create_element, set_xml_value, add_xml_child, MNS from .common import EWSAccountService, EWSPooledMixIn class UploadItems(EWSAccountService, EWSPooledMixIn): """ MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/uploaditems-operation This currently has the existing limitation of only being able to upload items that do not yet exist in the database. The full spec also allows actions "Update" and "UpdateOrCreate". """ SERVICE_NAME = 'UploadItems' element_container_name = '{%s}ItemId' % MNS def call(self, data): # _pool_requests expects 'items', not 'data' return self._pool_requests(payload_func=self.get_payload, **dict(items=data)) def get_payload(self, items): """Upload given items to given account data is an iterable of tuples where the first element is a Folder instance representing the ParentFolder that the item will be placed in and the second element is a Data string returned from an ExportItems call. """ from ..properties import ParentFolderId uploaditems = create_element('m:%s' % self.SERVICE_NAME) itemselement = create_element('m:Items') uploaditems.append(itemselement) for parent_folder, data_str in items: item = create_element('t:Item', attrs=dict(CreateAction='CreateNew')) parentfolderid = ParentFolderId(parent_folder.id, parent_folder.changekey) set_xml_value(item, parentfolderid, version=self.account.version) add_xml_child(item, 't:Data', data_str) itemselement.append(item) return uploaditems def _get_elements_in_container(self, container): from ..properties import ItemId return [(container.get(ItemId.ID_ATTR), container.get(ItemId.CHANGEKEY_ATTR))] exchangelib-3.1.1/exchangelib/settings.py000066400000000000000000000077541361226005600204660ustar00rootroot00000000000000from .ewsdatetime import UTC_NOW from .fields import DateTimeField, MessageField, ChoiceField, Choice from .properties import EWSElement, OutOfOffice from .util import create_element, set_xml_value class OofSettings(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/oofsettings""" ELEMENT_NAME = 'OofSettings' REQUEST_ELEMENT_NAME = 'UserOofSettings' ENABLED = 'Enabled' SCHEDULED = 'Scheduled' DISABLED = 'Disabled' FIELDS = [ ChoiceField('state', field_uri='OofState', is_required=True, choices={Choice(ENABLED), Choice(SCHEDULED), Choice(DISABLED)}), ChoiceField('external_audience', field_uri='ExternalAudience', choices={Choice('None'), Choice('Known'), Choice('All')}, default='All'), DateTimeField('start', field_uri='StartTime'), DateTimeField('end', field_uri='EndTime'), MessageField('internal_reply', field_uri='InternalReply'), MessageField('external_reply', field_uri='ExternalReply'), ] __slots__ = tuple(f.name for f in FIELDS) def clean(self, version=None): super().clean(version=version) if self.state == self.SCHEDULED: if not self.start or not self.end: raise ValueError("'start' and 'end' must be set when state is '%s'" % self.SCHEDULED) if self.start >= self.end: raise ValueError("'start' must be before 'end'") if self.end < UTC_NOW(): raise ValueError("'end' must be in the future") if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): raise ValueError("'internal_reply' and 'external_reply' must be set when state is not '%s'" % self.DISABLED) @classmethod def from_xml(cls, elem, account): kwargs = {} for attr in ('state', 'external_audience', 'internal_reply', 'external_reply'): f = cls.get_field_by_fieldname(attr) kwargs[attr] = f.from_xml(elem=elem, account=account) kwargs.update(OutOfOffice.duration_to_start_end(elem=elem, account=account)) cls._clear(elem) return cls(**kwargs) def to_xml(self, version): self.clean(version=version) elem = create_element('t:%s' % self.REQUEST_ELEMENT_NAME) for attr in ('state', 'external_audience'): value = getattr(self, attr) if value is None: continue f = self.get_field_by_fieldname(attr) set_xml_value(elem, f.to_xml(value, version=version), version=version) if self.start or self.end: duration = create_element('t:Duration') if self.start: f = self.get_field_by_fieldname('start') set_xml_value(duration, f.to_xml(self.start, version=version), version) if self.end: f = self.get_field_by_fieldname('end') set_xml_value(duration, f.to_xml(self.end, version=version), version) elem.append(duration) for attr in ('internal_reply', 'external_reply'): value = getattr(self, attr) if value is None: value = '' # The value can be empty, but the XML element must always be present f = self.get_field_by_fieldname(attr) set_xml_value(elem, f.to_xml(value, version=version), version) return elem def __hash__(self): # Customize comparison if self.state == self.DISABLED: # All values except state are ignored by the server relevant_attrs = ('state',) elif self.state != self.SCHEDULED: # 'start' and 'end' values are ignored by the server, and the server always returns today's date relevant_attrs = tuple(f.name for f in self.FIELDS if f.name not in ('start', 'end')) else: relevant_attrs = tuple(f.name for f in self.FIELDS) return hash(tuple(getattr(self, attr) for attr in relevant_attrs)) exchangelib-3.1.1/exchangelib/transport.py000066400000000000000000000175671361226005600206650ustar00rootroot00000000000000import logging import time import requests.auth import requests_ntlm import requests_oauthlib from .credentials import IMPERSONATION from .errors import UnauthorizedError, TransportError from .util import create_element, add_xml_child, xml_to_str, ns_translation, _may_retry_on_error, _back_off_if_needed, \ DummyResponse, CONNECTION_ERRORS log = logging.getLogger(__name__) # Authentication method enums NOAUTH = 'no authentication' NTLM = 'NTLM' BASIC = 'basic' DIGEST = 'digest' GSSAPI = 'gssapi' SSPI = 'sspi' OAUTH2 = 'OAuth 2.0' AUTH_TYPE_MAP = { NTLM: requests_ntlm.HttpNtlmAuth, BASIC: requests.auth.HTTPBasicAuth, DIGEST: requests.auth.HTTPDigestAuth, OAUTH2: requests_oauthlib.OAuth2, NOAUTH: None, } try: import requests_kerberos AUTH_TYPE_MAP[GSSAPI] = requests_kerberos.HTTPKerberosAuth except ImportError: # Kerberos auth is optional pass try: import requests_negotiate_sspi AUTH_TYPE_MAP[SSPI] = requests_negotiate_sspi.HttpNegotiateAuth except ImportError: # SSPI auth is optional pass DEFAULT_ENCODING = 'utf-8' DEFAULT_HEADERS = {'Content-Type': 'text/xml; charset=%s' % DEFAULT_ENCODING, 'Accept-Encoding': 'gzip, deflate'} def extra_headers(account): """Generate extra HTTP headers """ if account: # See # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ return {'X-AnchorMailbox': account.primary_smtp_address} return None def wrap(content, api_version, account=None): """ Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. ExchangeImpersonation allows to act as the user we want to impersonate. """ envelope = create_element('s:Envelope', nsmap=ns_translation) header = create_element('s:Header') requestserverversion = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) header.append(requestserverversion) if account: if account.access_type == IMPERSONATION: exchangeimpersonation = create_element('t:ExchangeImpersonation') connectingsid = create_element('t:ConnectingSID') add_xml_child(connectingsid, 't:PrimarySmtpAddress', account.primary_smtp_address) exchangeimpersonation.append(connectingsid) header.append(exchangeimpersonation) timezonecontext = create_element('t:TimeZoneContext') timezonedefinition = create_element('t:TimeZoneDefinition', attrs=dict(Id=account.default_timezone.ms_id)) timezonecontext.append(timezonedefinition) header.append(timezonecontext) envelope.append(header) body = create_element('s:Body') body.append(content) envelope.append(body) return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True) def get_auth_instance(auth_type, **kwargs): """ Returns an *Auth instance suitable for the requests package """ model = AUTH_TYPE_MAP[auth_type] if model is None: return None if auth_type == GSSAPI: # Kerberos auth relies on credentials supplied via a ticket available externally to this library return model() if auth_type == SSPI: # SSPI auth does not require credentials, but can have it return model(**kwargs) return model(**kwargs) def get_service_authtype(service_endpoint, retry_policy, api_versions, name): # Get auth type by tasting headers from the server. Only do POST requests. HEAD is too error prone, and some servers # are set up to redirect to OWA on all requests except POST to /EWS/Exchange.asmx # # We don't know the API version yet, but we need it to create a valid request because some Exchange servers only # respond when given a valid request. Try all known versions. Gross. from .protocol import BaseProtocol retry = 0 wait = 10 # seconds t_start = time.monotonic() headers = DEFAULT_HEADERS.copy() for api_version in api_versions: data = dummy_xml(api_version=api_version, name=name) log.debug('Requesting %s from %s', data, service_endpoint) while True: _back_off_if_needed(retry_policy.back_off_until) log.debug('Trying to get service auth type for %s', service_endpoint) with BaseProtocol.raw_session() as s: try: r = s.post(url=service_endpoint, headers=headers, data=data, allow_redirects=False, timeout=BaseProtocol.TIMEOUT) break except CONNECTION_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. total_wait = time.monotonic() - t_start r = DummyResponse(url=service_endpoint, headers={}, request_headers=headers) if _may_retry_on_error(response=r, retry_policy=retry_policy, wait=total_wait): log.info("Connection error on URL %s (retry %s, error: %s). Cool down %s secs", service_endpoint, retry, e, wait) retry_policy.back_off(wait) retry += 1 continue else: raise TransportError(str(e)) from e if r.status_code not in (200, 401): log.debug('Unexpected response: %s %s', r.status_code, r.reason) continue try: auth_type = get_auth_method_from_response(response=r) log.debug('Auth type is %s', auth_type) return auth_type, api_version except UnauthorizedError: continue raise TransportError('Failed to get auth type from service') def get_auth_method_from_response(response): # First, get the auth method from headers. Then, test credentials. Don't handle redirects - burden is on caller. log.debug('Request headers: %s', response.request.headers) log.debug('Response headers: %s', response.headers) if response.status_code == 200: return NOAUTH # Get auth type from headers for key, val in response.headers.items(): if key.lower() == 'www-authenticate': # Requests will combine multiple HTTP headers into one in 'request.headers' vals = _tokenize(val.lower()) for v in vals: if v.startswith('realm'): realm = v.split('=')[1].strip('"') log.debug('realm: %s', realm) # Prefer most secure auth method if more than one is offered. See discussion at # http://docs.oracle.com/javase/7/docs/technotes/guides/net/http-auth.html if 'digest' in vals: return DIGEST if 'ntlm' in vals: return NTLM if 'basic' in vals: return BASIC raise UnauthorizedError('No compatible auth type was reported by server') def _tokenize(val): # Splits cookie auth values auth_methods = [] auth_method = '' quote = False for c in val: if c in (' ', ',') and not quote: if auth_method not in ('', ','): auth_methods.append(auth_method) auth_method = '' continue elif c == '"': auth_method += c if quote: auth_methods.append(auth_method) auth_method = '' quote = not quote continue auth_method += c if auth_method: auth_methods.append(auth_method) return auth_methods def dummy_xml(api_version, name): # Generate a minimal, valid EWS request from .services import ResolveNames # Avoid circular import return wrap(content=ResolveNames(protocol=None).get_payload( unresolved_entries=[name], parent_folders=None, return_full_contact_data=False, search_scope=None, contact_data_shape=None, ), api_version=api_version) exchangelib-3.1.1/exchangelib/util.py000066400000000000000000001025341361226005600175730ustar00rootroot00000000000000from base64 import b64decode from codecs import BOM_UTF8 from collections import OrderedDict import datetime from decimal import Decimal import io import itertools import logging import re import socket from threading import get_ident import time from urllib.parse import urlparse import xml.sax.handler # Import _etree via defusedxml instead of directly from lxml.etree, to silence overly strict linters from defusedxml.lxml import parse, tostring, GlobalParserTLS, RestrictedElement, _etree from defusedxml.expatreader import DefusedExpatParser from defusedxml.sax import _InputSource import dns.resolver import isodate from oauthlib.oauth2 import TokenExpiredError from pygments import highlight from pygments.lexers.html import XmlLexer from pygments.formatters.terminal import TerminalFormatter import requests.exceptions from .errors import TransportError, RateLimitError, RedirectError, RelativeRedirect, CASError, UnauthorizedError, \ ErrorInvalidSchemaVersionForMailboxVersion log = logging.getLogger(__name__) class ParseError(_etree.ParseError): """Used to wrap lxml ParseError in our own class""" pass class ElementNotFound(Exception): def __init__(self, msg, data): super().__init__(msg) self.data = data # Regex of UTF-8 control characters that are illegal in XML 1.0 (and XML 1.1) _ILLEGAL_XML_CHARS_RE = re.compile('[\x00-\x08\x0b\x0c\x0e-\x1F\uD800-\uDFFF\uFFFE\uFFFF]') # XML namespaces SOAPNS = 'http://schemas.xmlsoap.org/soap/envelope/' MNS = 'http://schemas.microsoft.com/exchange/services/2006/messages' TNS = 'http://schemas.microsoft.com/exchange/services/2006/types' ENS = 'http://schemas.microsoft.com/exchange/services/2006/errors' AUTODISCOVER_BASE_NS = 'http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006' AUTODISCOVER_REQUEST_NS = 'http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006' AUTODISCOVER_RESPONSE_NS = 'http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' ns_translation = OrderedDict([ ('s', SOAPNS), ('m', MNS), ('t', TNS), ]) for item in ns_translation.items(): _etree.register_namespace(*item) def is_iterable(value, generators_allowed=False): """Checks if value is a list-like object. Don't match generators and generator-like objects here by default, because callers don't necessarily guarantee that they only iterate the value once. Take care to not match string types and bytes. :param value: any type of object :param generators_allowed: if True, generators will be treated as iterable :return: True or False """ if generators_allowed: if not isinstance(value, (bytes, str)) and hasattr(value, '__iter__'): return True else: if isinstance(value, (tuple, list, set)): return True return False def chunkify(iterable, chunksize): """Splits an iterable into chunks of size ``chunksize``. The last chunk may be smaller than ``chunksize``.""" from .queryset import QuerySet if hasattr(iterable, '__getitem__') and not isinstance(iterable, QuerySet): # tuple, list. QuerySet has __getitem__ but that evaluates the entire query greedily. We don't want that here. for i in range(0, len(iterable), chunksize): yield iterable[i:i + chunksize] else: # generator, set, map, QuerySet chunk = [] for i in iterable: chunk.append(i) if len(chunk) == chunksize: yield chunk chunk = [] if chunk: yield chunk def peek(iterable): """Checks if an iterable is empty and returns status and the rewinded iterable""" from .queryset import QuerySet if isinstance(iterable, QuerySet): # QuerySet has __len__ but that evaluates the entire query greedily. We don't want that here. Instead, peek() # should be called on QuerySet.iterator() raise ValueError('Cannot peek on a QuerySet') if hasattr(iterable, '__len__'): # tuple, list, set return not iterable, iterable # generator try: first = next(iterable) except StopIteration: return True, iterable # We can't rewind a generator. Instead, chain the first element and the rest of the generator return False, itertools.chain([first], iterable) def xml_to_str(tree, encoding=None, xml_declaration=False): """Serialize an XML tree. Returns unicode if 'encoding' is None. Otherwise, we return encoded 'bytes'.""" if xml_declaration and not encoding: raise ValueError("'xml_declaration' is not supported when 'encoding' is None") if encoding: return tostring(tree, encoding=encoding, xml_declaration=True) return tostring(tree, encoding=str, xml_declaration=False) def get_xml_attr(tree, name): elem = tree.find(name) if elem is None: # Must compare with None, see XML docs return None return elem.text or None def get_xml_attrs(tree, name): return [elem.text for elem in tree.findall(name) if elem.text is not None] def value_to_xml_text(value): # We can't handle bytes in this function because str == bytes on Python2 from .ewsdatetime import EWSTimeZone, EWSDateTime, EWSDate from .indexed_properties import PhoneNumber, EmailAddress from .properties import Mailbox, Attendee, ConversationId if isinstance(value, str): return safe_xml_value(value) if isinstance(value, bool): return '1' if value else '0' if isinstance(value, (int, Decimal)): return str(value) if isinstance(value, datetime.time): return value.isoformat() if isinstance(value, EWSTimeZone): return value.ms_id if isinstance(value, EWSDateTime): return value.ewsformat() if isinstance(value, EWSDate): return value.ewsformat() if isinstance(value, PhoneNumber): return value.phone_number if isinstance(value, EmailAddress): return value.email if isinstance(value, Mailbox): return value.email_address if isinstance(value, Attendee): return value.mailbox.email_address if isinstance(value, ConversationId): return value.id raise NotImplementedError('Unsupported type: %s (%s)' % (type(value), value)) def xml_text_to_value(value, value_type): # We can't handle bytes in this function because str == bytes on Python2 from .ewsdatetime import EWSDateTime return { bool: lambda v: True if v == 'true' else False if v == 'false' else None, int: int, Decimal: Decimal, datetime.timedelta: isodate.parse_duration, EWSDateTime: EWSDateTime.from_string, str: lambda v: v }[value_type](value) def set_xml_value(elem, value, version): from .ewsdatetime import EWSDateTime, EWSDate from .fields import FieldPath, FieldOrder from .properties import EWSElement from .version import Version if isinstance(value, (str, bool, bytes, int, Decimal, datetime.time, EWSDate, EWSDateTime)): elem.text = value_to_xml_text(value) elif isinstance(value, RestrictedElement): elem.append(value) elif is_iterable(value, generators_allowed=True): for v in value: if isinstance(v, (FieldPath, FieldOrder)): elem.append(v.to_xml()) elif isinstance(v, EWSElement): if not isinstance(version, Version): raise ValueError("'version' %r must be a Version instance" % version) elem.append(v.to_xml(version=version)) elif isinstance(v, RestrictedElement): elem.append(v) elif isinstance(v, str): add_xml_child(elem, 't:String', v) else: raise ValueError('Unsupported type %s for list element %s on elem %s' % (type(v), v, elem)) elif isinstance(value, (FieldPath, FieldOrder)): elem.append(value.to_xml()) elif isinstance(value, EWSElement): if not isinstance(version, Version): raise ValueError("'version' %r must be a Version instance" % version) elem.append(value.to_xml(version=version)) else: raise ValueError('Unsupported type %s for value %s on elem %s' % (type(value), value, elem)) return elem def safe_xml_value(value, replacement='?'): return _ILLEGAL_XML_CHARS_RE.sub(replacement, value) def create_element(name, attrs=None, nsmap=None): # Python versions prior to 3.6 do not preserve dict or kwarg ordering, so we cannot pull in attrs as **kwargs if we # also want stable XML attribute output. Instead, let callers supply us with an OrderedDict instance. if ':' in name: ns, name = name.split(':') name = '{%s}%s' % (ns_translation[ns], name) elem = RestrictedElement(nsmap=nsmap) if attrs: # Try hard to keep attribute order, to ensure deterministic output. This simplifies testing. for k, v in attrs.items(): elem.set(k, v) elem.tag = name return elem def add_xml_child(tree, name, value): # We're calling add_xml_child many places where we don't have the version handy. Don't pass EWSElement or list of # EWSElement to this function! tree.append(set_xml_value(elem=create_element(name), value=value, version=None)) class StreamingContentHandler(xml.sax.handler.ContentHandler): """A SAX content handler that returns a character data for a single element back to the parser. The parser must have a 'buffer' attribute we can append data to. """ def __init__(self, parser, ns, element_name): xml.sax.handler.ContentHandler.__init__(self) self._parser = parser self._ns = ns self._element_name = element_name self._parsing = False def startElementNS(self, name, qname, attrs): if name == (self._ns, self._element_name): # we can expect element data next self._parsing = True self._parser.element_found = True def endElementNS(self, name, qname): if name == (self._ns, self._element_name): # all element data received self._parsing = False def characters(self, content): if not self._parsing: return self._parser.buffer.append(content) def prepare_input_source(source): # Extracted from xml.sax.expatreader.saxutils.prepare_input_source f = source source = _InputSource() source.setByteStream(f) return source def safe_b64decode(data): # Incoming base64-encoded data is not always padded to a multiple of 4. Python's parser is more strict and requires # padding. Add padding if it's needed. overflow = len(data) % 4 if overflow: if isinstance(data, str): padding = '=' * (4 - overflow) else: padding = b'=' * (4 - overflow) data += padding return b64decode(data) class StreamingBase64Parser(DefusedExpatParser): """A SAX parser that returns a generator of base64-decoded character content""" def __init__(self, *args, **kwargs): DefusedExpatParser.__init__(self, *args, **kwargs) self._namespaces = True self.buffer = None self.element_found = None def parse(self, source): raw_source = source.raw # Like upstream but yields the return value of self.feed() raw_source = prepare_input_source(raw_source) self.prepareParser(raw_source) file = raw_source.getByteStream() self.buffer = [] self.element_found = False buffer = file.read(self._bufsize) collected_data = [] while buffer: if not self.element_found: collected_data += buffer for data in self.feed(buffer): yield data buffer = file.read(self._bufsize) # Any remaining data in self.buffer should be padding chars now self.buffer = None source.close() self.close() if not self.element_found: data = bytes(collected_data) raise ElementNotFound('The element to be streamed from was not found', data=bytes(data)) def feed(self, data, isFinal=0): # Like upstream, but yields the current content of the character buffer DefusedExpatParser.feed(self, data=data, isFinal=isFinal) return self._decode_buffer() def _decode_buffer(self): remainder = '' for data in self.buffer: available = len(remainder) + len(data) overflow = available % 4 # Make sure we always decode a multiple of 4 if remainder: data = (remainder + data) remainder = '' if overflow: remainder, data = data[-overflow:], data[:-overflow] if data: yield b64decode(data) self.buffer = [remainder] if remainder else [] class ForgivingParser(GlobalParserTLS): parser_config = { 'resolve_entities': False, 'recover': True, # This setting is non-default 'huge_tree': True, # This setting enables parsing huge attachments, mime_content and other large data } _forgiving_parser = ForgivingParser() class BytesGeneratorIO(io.RawIOBase): """A BytesIO that can produce bytes from a streaming HTTP request. Expects r.iter_content() as input lxml tries to be smart by calling `getvalue` when present, assuming that the entire string is in memory. Omitting `getvalue` forces lxml to stream the request through `read` avoiding the memory duplication. """ def __init__(self, bytes_generator): self._bytes_generator = bytes_generator self._next = bytearray() self._tell = 0 super().__init__() def readable(self): return not self.closed def tell(self): return self._tell def read(self, size=-1): # requests `iter_content()` auto-adjusts the number of bytes based on bandwidth # can't assume how many bytes next returns so stash any extra in `self._next` if self.closed: raise ValueError("read from a closed file") if self._next is None: return b'' if size is None: size = -1 res = self._next while size < 0 or len(res) < size: try: res.extend(next(self._bytes_generator)) except StopIteration: self._next = None break if size > 0 and self._next is not None: self._next = res[size:] res = res[:size] self._tell += len(res) return bytes(res) def close(self): if not self.closed: self._bytes_generator.close() super().close() def to_xml(bytes_content): # Converts bytes or a generator of bytes to an XML tree # Exchange servers may spit out the weirdest XML. lxml is pretty good at recovering from errors if isinstance(bytes_content, bytes): stream = io.BytesIO(bytes_content) else: stream = BytesGeneratorIO(bytes_content) forgiving_parser = _forgiving_parser.getDefaultParser() try: return parse(stream, parser=forgiving_parser) except AssertionError as e: raise ParseError(e.args[0], '', -1, 0) except _etree.ParseError as e: if hasattr(e, 'position'): e.lineno, e.offset = e.position if not e.lineno: raise ParseError(str(e), '', e.lineno, e.offset) try: stream.seek(0) offending_line = stream.read().splitlines()[e.lineno - 1] except (IndexError, io.UnsupportedOperation): raise ParseError(str(e), '', e.lineno, e.offset) else: offending_excerpt = offending_line[max(0, e.offset - 20):e.offset + 20] msg = '%s\nOffending text: [...]%s[...]' % (str(e), offending_excerpt) raise ParseError(msg, e.lineno, e.offset) except TypeError: try: stream.seek(0) except (IndexError, io.UnsupportedOperation): pass raise ParseError('This is not XML: %r' % stream.read(), '', -1, 0) def is_xml(text): """ Helper function. Lightweight test if response is an XML doc """ # BOM_UTF8 is an UTF-8 byte order mark which may precede the XML from an Exchange server bom_len = len(BOM_UTF8) if text[:bom_len] == BOM_UTF8: return text[bom_len:bom_len + 5] == b' 0: log.warning('Server requested back off until %s. Sleeping %s seconds', back_off_until, sleep_secs) time.sleep(sleep_secs) return True return False def _may_retry_on_error(response, retry_policy, wait): if response.status_code not in (301, 302, 401, 503): # Don't retry if we didn't get a status code that we can hope to recover from log.debug('No retry: wrong status code %s', response.status_code) return False if retry_policy.fail_fast: log.debug('No retry: no fail-fast policy') return False if wait > retry_policy.max_wait: # We lost patience. Session is cleaned up in outer loop raise RateLimitError( 'Max timeout reached', url=response.url, status_code=response.status_code, total_wait=wait) # The genericerrorpage.htm/internalerror.asp is ridiculous behaviour for random outages. Redirect to # '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS certificate # f*ckups on the Exchange server. if (response.status_code == 401) \ or (response.headers.get('connection') == 'close') \ or (response.status_code == 302 and response.headers.get('location', '').lower() == '/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx') \ or (response.status_code == 503): log.debug('Retry allowed: conditions met') return True return False def _need_new_credentials(response): return response.status_code == 401 \ and response.headers.get('TokenExpiredError') def _redirect_or_fail(response, redirects, allow_redirects): # Retry with no delay. If we let requests handle redirects automatically, it would issue a GET to that # URL. We still want to POST. try: redirect_url = get_redirect_url(response=response, allow_relative=False) except RelativeRedirect as e: log.debug("'allow_redirects' only supports relative redirects (%s -> %s)", response.url, e.value) raise RedirectError(url=e.value) if not allow_redirects: raise TransportError('Redirect not allowed but we were redirected (%s -> %s)' % (response.url, redirect_url)) log.debug('HTTP redirected to %s', redirect_url) redirects += 1 if redirects > MAX_REDIRECTS: raise TransportError('Max redirect count exceeded') return redirect_url, redirects def _raise_response_errors(response, protocol, log_msg, log_vals): cas_error = response.headers.get('X-CasErrorCode') if cas_error: if cas_error.startswith('CAS error:'): # Remove unnecessary text cas_error = cas_error.split(':', 1)[1].strip() raise CASError(cas_error=cas_error, response=response) if response.status_code == 500 and (b'The specified server version is invalid' in response.content or b'ErrorInvalidSchemaVersionForMailboxVersion' in response.content): raise ErrorInvalidSchemaVersionForMailboxVersion('Invalid server version') if b'The referenced account is currently locked out' in response.content: raise TransportError('The service account is currently locked out') if response.status_code == 401 and protocol.retry_policy.fail_fast: # This is a login failure raise UnauthorizedError('Wrong username or password for %s' % response.url) if 'TimeoutException' in response.headers: raise response.headers['TimeoutException'] # This could be anything. Let higher layers handle this. Add full context for better debugging. raise TransportError(str('Unknown failure\n') + log_msg % log_vals) exchangelib-3.1.1/exchangelib/version.py000066400000000000000000000303771361226005600203100ustar00rootroot00000000000000import logging import re from .errors import TransportError, ErrorInvalidSchemaVersionForMailboxVersion, ErrorInvalidServerVersion, \ ErrorIncorrectSchemaVersion, ResponseMessageError from .util import xml_to_str, TNS log = logging.getLogger(__name__) # Legend for dict: # Key: shortname # Values: (EWS API version ID, full name) # 'shortname' comes from types.xsd and is the official version of the server, corresponding to the version numbers # supplied in SOAP headers. 'API version' is the version name supplied in the RequestServerVersion element in SOAP # headers and describes the EWS API version the server implements. Valid values for this element are described here: # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion VERSIONS = { 'Exchange2007': ('Exchange2007', 'Microsoft Exchange Server 2007'), 'Exchange2007_SP1': ('Exchange2007_SP1', 'Microsoft Exchange Server 2007 SP1'), 'Exchange2007_SP2': ('Exchange2007_SP1', 'Microsoft Exchange Server 2007 SP2'), 'Exchange2007_SP3': ('Exchange2007_SP1', 'Microsoft Exchange Server 2007 SP3'), 'Exchange2010': ('Exchange2010', 'Microsoft Exchange Server 2010'), 'Exchange2010_SP1': ('Exchange2010_SP1', 'Microsoft Exchange Server 2010 SP1'), 'Exchange2010_SP2': ('Exchange2010_SP2', 'Microsoft Exchange Server 2010 SP2'), 'Exchange2010_SP3': ('Exchange2010_SP2', 'Microsoft Exchange Server 2010 SP3'), 'Exchange2013': ('Exchange2013', 'Microsoft Exchange Server 2013'), 'Exchange2013_SP1': ('Exchange2013_SP1', 'Microsoft Exchange Server 2013 SP1'), 'Exchange2015': ('Exchange2015', 'Microsoft Exchange Server 2015'), 'Exchange2015_SP1': ('Exchange2015_SP1', 'Microsoft Exchange Server 2015 SP1'), 'Exchange2016': ('Exchange2016', 'Microsoft Exchange Server 2016'), 'Exchange2019': ('Exchange2019', 'Microsoft Exchange Server 2019'), } # Build a list of unique API versions, used when guessing API version supported by the server. Use reverse order so we # get the newest API version supported by the server. API_VERSIONS = sorted({v[0] for v in VERSIONS.values()}, reverse=True) class Build: """ Holds methods for working with build numbers """ # List of build numbers here: https://docs.microsoft.com/en-us/exchange/new-features/build-numbers-and-release-dates API_VERSION_MAP = { 8: { 0: 'Exchange2007', 1: 'Exchange2007_SP1', 2: 'Exchange2007_SP1', 3: 'Exchange2007_SP1', }, 14: { 0: 'Exchange2010', 1: 'Exchange2010_SP1', 2: 'Exchange2010_SP2', 3: 'Exchange2010_SP2', }, 15: { 0: 'Exchange2013', # Minor builds starting from 847 are Exchange2013_SP1, see api_version() 1: 'Exchange2016', 2: 'Exchange2019', 20: 'Exchange2016', # This is Office365. See issue #221 }, } __slots__ = ('major_version', 'minor_version', 'major_build', 'minor_build') def __init__(self, major_version, minor_version, major_build=0, minor_build=0): if not isinstance(major_version, int): raise ValueError("'major_version' must be an integer") if not isinstance(minor_version, int): raise ValueError("'minor_version' must be an integer") if not isinstance(major_build, int): raise ValueError("'major_build' must be an integer") if not isinstance(minor_build, int): raise ValueError("'minor_build' must be an integer") self.major_version = major_version self.minor_version = minor_version self.major_build = major_build self.minor_build = minor_build if major_version < 8: raise ValueError("Exchange major versions below 8 don't support EWS (%s)" % self) @classmethod def from_xml(cls, elem): xml_elems_map = { 'major_version': 'MajorVersion', 'minor_version': 'MinorVersion', 'major_build': 'MajorBuildNumber', 'minor_build': 'MinorBuildNumber', } kwargs = {} for k, xml_elem in xml_elems_map.items(): v = elem.get(xml_elem) if v is None: raise ValueError() kwargs[k] = int(v) # Also raises ValueError return cls(**kwargs) @classmethod def from_hex_string(cls, s): """Parse a server version string as returned in an autodiscover response. The process is described here: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/serverversion-pox#example The string is a hex string that, converted to a 32-bit binary, encodes the server version. The rules are: * The first 4 bits contain the version number structure version. Can be ignored * The next 6 bits contain the major version number * The next 6 bits contain the minor version number * The next bit contains a flag. Can be ignored * The next 15 bits contain the major build number """ bin_s = '{:032b}'.format(int(s, 16)) # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) return cls(major_version=major_version, minor_version=minor_version, major_build=build_number) def api_version(self): if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016: return 'Exchange2013_SP1' try: return self.API_VERSION_MAP[self.major_version][self.minor_version] except KeyError: raise ValueError('API version for build %s is unknown' % self) def __cmp__(self, other): # __cmp__ is not a magic method in Python3. We'll just use it here to implement comparison operators c = (self.major_version > other.major_version) - (self.major_version < other.major_version) if c != 0: return c c = (self.minor_version > other.minor_version) - (self.minor_version < other.minor_version) if c != 0: return c c = (self.major_build > other.major_build) - (self.major_build < other.major_build) if c != 0: return c return (self.minor_build > other.minor_build) - (self.minor_build < other.minor_build) def __eq__(self, other): return self.__cmp__(other) == 0 def __hash__(self): return hash(repr(self)) def __ne__(self, other): return self.__cmp__(other) != 0 def __lt__(self, other): return self.__cmp__(other) < 0 def __le__(self, other): return self.__cmp__(other) <= 0 def __gt__(self, other): return self.__cmp__(other) > 0 def __ge__(self, other): return self.__cmp__(other) >= 0 def __str__(self): return '%s.%s.%s.%s' % (self.major_version, self.minor_version, self.major_build, self.minor_build) def __repr__(self): return self.__class__.__name__ \ + repr((self.major_version, self.minor_version, self.major_build, self.minor_build)) # Helpers for comparison operations elsewhere in this package EXCHANGE_2007 = Build(8, 0) EXCHANGE_2007_SP1 = Build(8, 1) EXCHANGE_2010 = Build(14, 0) EXCHANGE_2010_SP1 = Build(14, 1) EXCHANGE_2010_SP2 = Build(14, 2) EXCHANGE_2013 = Build(15, 0) EXCHANGE_2013_SP1 = Build(15, 0, 847) EXCHANGE_2016 = Build(15, 1) EXCHANGE_2019 = Build(15, 2) EXCHANGE_O365 = Build(15, 20) class Version: """ Holds information about the server version """ __slots__ = ('build', 'api_version') def __init__(self, build, api_version=None): if not isinstance(build, (Build, type(None))): raise ValueError("'build' must be a Build instance") self.build = build if api_version is None: self.api_version = build.api_version() else: if not isinstance(api_version, str): raise ValueError("'api_version' must be a string") self.api_version = api_version @property def fullname(self): return VERSIONS[self.api_version][1] @classmethod def guess(cls, protocol, api_version_hint=None): """ Tries to ask the server which version it has. We haven't set up an Account object yet, so we generate requests by hand. We only need a response header containing a ServerVersionInfo element. To get API version and build numbers from the server, we need to send a valid SOAP request. We can't do that without a valid API version. To solve this chicken-and-egg problem, we try all possible API versions that this package supports, until we get a valid response. """ from .services import ResolveNames # The protocol doesn't have a version yet, so default to latest supported version if we don't have a hint. api_version = api_version_hint or API_VERSIONS[0] log.debug('Asking server for version info using API version %s', api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also # dangerous. Make sure the call to ResolveNames does not require a version build. protocol.config.version = Version(build=None, api_version=api_version) # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames # will try to guess the version automatically. name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else 'DUMMY' try: list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) except (ErrorInvalidSchemaVersionForMailboxVersion, ErrorInvalidServerVersion, ErrorIncorrectSchemaVersion): raise TransportError('Unable to guess version') except ResponseMessageError: # We survived long enough to get a new version pass if not protocol.version.build: raise AttributeError('Protocol should have a build number at this point') return protocol.version @staticmethod def _is_invalid_version_string(version): # Check if a version string is bogus, e.g. V2_, V2015_ or V2018_ return re.match(r'V[0-9]{1,4}_.*', version) @classmethod def from_soap_header(cls, requested_api_version, header): info = header.find('{%s}ServerVersionInfo' % TNS) if info is None: raise TransportError('No ServerVersionInfo in header: %r' % xml_to_str(header)) try: build = Build.from_xml(elem=info) except ValueError: raise TransportError('Bad ServerVersionInfo in response: %r' % xml_to_str(header)) # Not all Exchange servers send the Version element api_version_from_server = info.get('Version') or build.api_version() if api_version_from_server != requested_api_version: if cls._is_invalid_version_string(api_version_from_server): # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request. # Detect these so we can fallback to a valid version string. log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, api_version_from_server, requested_api_version) api_version_from_server = requested_api_version else: # Trust API version from server response log.info('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, api_version_from_server, api_version_from_server) return cls(build=build, api_version=api_version_from_server) def __eq__(self, other): if self.api_version != other.api_version: return False if self.build and not other.build: return False if other.build and not self.build: return False return self.build == other.build def __repr__(self): return self.__class__.__name__ + repr((self.build, self.api_version)) def __str__(self): return 'Build=%s, API=%s, Fullname=%s' % (self.build, self.api_version, self.fullname) exchangelib-3.1.1/exchangelib/winzone.py000066400000000000000000000700171361226005600203070ustar00rootroot00000000000000""" A dict to translate from pytz location name to Windows timezone name. Translations taken from http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml """ import requests from .util import to_xml CLDR_WINZONE_URL = 'https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/windowsZones.xml' DEFAULT_TERRITORY = '001' def generate_map(timeout=10): """ Helper method to update the map if the CLDR database is updated """ r = requests.get(CLDR_WINZONE_URL, timeout=timeout) if r.status_code != 200: raise ValueError('Unexpected response: %s' % r) tz_map = {} for e in to_xml(r.content).find('windowsZones').find('mapTimezones').findall('mapZone'): for location in e.get('type').split(' '): if e.get('territory') == DEFAULT_TERRITORY or location not in tz_map: # Prefer default territory. This is so MS_TIMEZONE_TO_PYTZ_MAP maps from MS timezone ID back to the # "preferred" region/location timezone name. tz_map[location] = e.get('other'), e.get('territory') return tz_map # This map is generated irregularly from generate_map(). Do not edit manually - make corrections to # PYTZ_TO_MS_TIMEZONE_MAP instead. We provide this map to avoid hammering the CLDR_WINZONE_URL. # # This list was generated from CLDR_WINZONE_URL version 2019b. CLDR_TO_MS_TIMEZONE_MAP = { 'Africa/Abidjan': ('Greenwich Standard Time', 'CI'), 'Africa/Accra': ('Greenwich Standard Time', 'GH'), 'Africa/Addis_Ababa': ('E. Africa Standard Time', 'ET'), 'Africa/Algiers': ('W. Central Africa Standard Time', 'DZ'), 'Africa/Asmera': ('E. Africa Standard Time', 'ER'), 'Africa/Bamako': ('Greenwich Standard Time', 'ML'), 'Africa/Bangui': ('W. Central Africa Standard Time', 'CF'), 'Africa/Banjul': ('Greenwich Standard Time', 'GM'), 'Africa/Bissau': ('Greenwich Standard Time', 'GW'), 'Africa/Blantyre': ('South Africa Standard Time', 'MW'), 'Africa/Brazzaville': ('W. Central Africa Standard Time', 'CG'), 'Africa/Bujumbura': ('South Africa Standard Time', 'BI'), 'Africa/Cairo': ('Egypt Standard Time', '001'), 'Africa/Casablanca': ('Morocco Standard Time', '001'), 'Africa/Ceuta': ('Romance Standard Time', 'ES'), 'Africa/Conakry': ('Greenwich Standard Time', 'GN'), 'Africa/Dakar': ('Greenwich Standard Time', 'SN'), 'Africa/Dar_es_Salaam': ('E. Africa Standard Time', 'TZ'), 'Africa/Djibouti': ('E. Africa Standard Time', 'DJ'), 'Africa/Douala': ('W. Central Africa Standard Time', 'CM'), 'Africa/El_Aaiun': ('Morocco Standard Time', 'EH'), 'Africa/Freetown': ('Greenwich Standard Time', 'SL'), 'Africa/Gaborone': ('South Africa Standard Time', 'BW'), 'Africa/Harare': ('South Africa Standard Time', 'ZW'), 'Africa/Johannesburg': ('South Africa Standard Time', '001'), 'Africa/Juba': ('E. Africa Standard Time', 'SS'), 'Africa/Kampala': ('E. Africa Standard Time', 'UG'), 'Africa/Khartoum': ('Sudan Standard Time', '001'), 'Africa/Kigali': ('South Africa Standard Time', 'RW'), 'Africa/Kinshasa': ('W. Central Africa Standard Time', 'CD'), 'Africa/Lagos': ('W. Central Africa Standard Time', '001'), 'Africa/Libreville': ('W. Central Africa Standard Time', 'GA'), 'Africa/Lome': ('Greenwich Standard Time', 'TG'), 'Africa/Luanda': ('W. Central Africa Standard Time', 'AO'), 'Africa/Lubumbashi': ('South Africa Standard Time', 'CD'), 'Africa/Lusaka': ('South Africa Standard Time', 'ZM'), 'Africa/Malabo': ('W. Central Africa Standard Time', 'GQ'), 'Africa/Maputo': ('South Africa Standard Time', 'MZ'), 'Africa/Maseru': ('South Africa Standard Time', 'LS'), 'Africa/Mbabane': ('South Africa Standard Time', 'SZ'), 'Africa/Mogadishu': ('E. Africa Standard Time', 'SO'), 'Africa/Monrovia': ('Greenwich Standard Time', 'LR'), 'Africa/Nairobi': ('E. Africa Standard Time', '001'), 'Africa/Ndjamena': ('W. Central Africa Standard Time', 'TD'), 'Africa/Niamey': ('W. Central Africa Standard Time', 'NE'), 'Africa/Nouakchott': ('Greenwich Standard Time', 'MR'), 'Africa/Ouagadougou': ('Greenwich Standard Time', 'BF'), 'Africa/Porto-Novo': ('W. Central Africa Standard Time', 'BJ'), 'Africa/Sao_Tome': ('Sao Tome Standard Time', '001'), 'Africa/Tripoli': ('Libya Standard Time', '001'), 'Africa/Tunis': ('W. Central Africa Standard Time', 'TN'), 'Africa/Windhoek': ('Namibia Standard Time', '001'), 'America/Adak': ('Aleutian Standard Time', '001'), 'America/Anchorage': ('Alaskan Standard Time', '001'), 'America/Anguilla': ('SA Western Standard Time', 'AI'), 'America/Antigua': ('SA Western Standard Time', 'AG'), 'America/Araguaina': ('Tocantins Standard Time', '001'), 'America/Argentina/La_Rioja': ('Argentina Standard Time', 'AR'), 'America/Argentina/Rio_Gallegos': ('Argentina Standard Time', 'AR'), 'America/Argentina/Salta': ('Argentina Standard Time', 'AR'), 'America/Argentina/San_Juan': ('Argentina Standard Time', 'AR'), 'America/Argentina/San_Luis': ('Argentina Standard Time', 'AR'), 'America/Argentina/Tucuman': ('Argentina Standard Time', 'AR'), 'America/Argentina/Ushuaia': ('Argentina Standard Time', 'AR'), 'America/Aruba': ('SA Western Standard Time', 'AW'), 'America/Asuncion': ('Paraguay Standard Time', '001'), 'America/Bahia': ('Bahia Standard Time', '001'), 'America/Bahia_Banderas': ('Central Standard Time (Mexico)', 'MX'), 'America/Barbados': ('SA Western Standard Time', 'BB'), 'America/Belem': ('SA Eastern Standard Time', 'BR'), 'America/Belize': ('Central America Standard Time', 'BZ'), 'America/Blanc-Sablon': ('SA Western Standard Time', 'CA'), 'America/Boa_Vista': ('SA Western Standard Time', 'BR'), 'America/Bogota': ('SA Pacific Standard Time', '001'), 'America/Boise': ('Mountain Standard Time', 'US'), 'America/Buenos_Aires': ('Argentina Standard Time', '001'), 'America/Cambridge_Bay': ('Mountain Standard Time', 'CA'), 'America/Campo_Grande': ('Central Brazilian Standard Time', 'BR'), 'America/Cancun': ('Eastern Standard Time (Mexico)', '001'), 'America/Caracas': ('Venezuela Standard Time', '001'), 'America/Catamarca': ('Argentina Standard Time', 'AR'), 'America/Cayenne': ('SA Eastern Standard Time', '001'), 'America/Cayman': ('SA Pacific Standard Time', 'KY'), 'America/Chicago': ('Central Standard Time', '001'), 'America/Chihuahua': ('Mountain Standard Time (Mexico)', '001'), 'America/Coral_Harbour': ('SA Pacific Standard Time', 'CA'), 'America/Cordoba': ('Argentina Standard Time', 'AR'), 'America/Costa_Rica': ('Central America Standard Time', 'CR'), 'America/Creston': ('US Mountain Standard Time', 'CA'), 'America/Cuiaba': ('Central Brazilian Standard Time', '001'), 'America/Curacao': ('SA Western Standard Time', 'CW'), 'America/Danmarkshavn': ('UTC', 'GL'), 'America/Dawson': ('Pacific Standard Time', 'CA'), 'America/Dawson_Creek': ('US Mountain Standard Time', 'CA'), 'America/Denver': ('Mountain Standard Time', '001'), 'America/Detroit': ('Eastern Standard Time', 'US'), 'America/Dominica': ('SA Western Standard Time', 'DM'), 'America/Edmonton': ('Mountain Standard Time', 'CA'), 'America/Eirunepe': ('SA Pacific Standard Time', 'BR'), 'America/El_Salvador': ('Central America Standard Time', 'SV'), 'America/Fort_Nelson': ('US Mountain Standard Time', 'CA'), 'America/Fortaleza': ('SA Eastern Standard Time', 'BR'), 'America/Glace_Bay': ('Atlantic Standard Time', 'CA'), 'America/Godthab': ('Greenland Standard Time', '001'), 'America/Goose_Bay': ('Atlantic Standard Time', 'CA'), 'America/Grand_Turk': ('Turks And Caicos Standard Time', '001'), 'America/Grenada': ('SA Western Standard Time', 'GD'), 'America/Guadeloupe': ('SA Western Standard Time', 'GP'), 'America/Guatemala': ('Central America Standard Time', '001'), 'America/Guayaquil': ('SA Pacific Standard Time', 'EC'), 'America/Guyana': ('SA Western Standard Time', 'GY'), 'America/Halifax': ('Atlantic Standard Time', '001'), 'America/Havana': ('Cuba Standard Time', '001'), 'America/Hermosillo': ('US Mountain Standard Time', 'MX'), 'America/Indiana/Knox': ('Central Standard Time', 'US'), 'America/Indiana/Marengo': ('US Eastern Standard Time', 'US'), 'America/Indiana/Petersburg': ('Eastern Standard Time', 'US'), 'America/Indiana/Tell_City': ('Central Standard Time', 'US'), 'America/Indiana/Vevay': ('US Eastern Standard Time', 'US'), 'America/Indiana/Vincennes': ('Eastern Standard Time', 'US'), 'America/Indiana/Winamac': ('Eastern Standard Time', 'US'), 'America/Indianapolis': ('US Eastern Standard Time', '001'), 'America/Inuvik': ('Mountain Standard Time', 'CA'), 'America/Iqaluit': ('Eastern Standard Time', 'CA'), 'America/Jamaica': ('SA Pacific Standard Time', 'JM'), 'America/Jujuy': ('Argentina Standard Time', 'AR'), 'America/Juneau': ('Alaskan Standard Time', 'US'), 'America/Kentucky/Monticello': ('Eastern Standard Time', 'US'), 'America/Kralendijk': ('SA Western Standard Time', 'BQ'), 'America/La_Paz': ('SA Western Standard Time', '001'), 'America/Lima': ('SA Pacific Standard Time', 'PE'), 'America/Los_Angeles': ('Pacific Standard Time', '001'), 'America/Louisville': ('Eastern Standard Time', 'US'), 'America/Lower_Princes': ('SA Western Standard Time', 'SX'), 'America/Maceio': ('SA Eastern Standard Time', 'BR'), 'America/Managua': ('Central America Standard Time', 'NI'), 'America/Manaus': ('SA Western Standard Time', 'BR'), 'America/Marigot': ('SA Western Standard Time', 'MF'), 'America/Martinique': ('SA Western Standard Time', 'MQ'), 'America/Matamoros': ('Central Standard Time', 'MX'), 'America/Mazatlan': ('Mountain Standard Time (Mexico)', 'MX'), 'America/Mendoza': ('Argentina Standard Time', 'AR'), 'America/Menominee': ('Central Standard Time', 'US'), 'America/Merida': ('Central Standard Time (Mexico)', 'MX'), 'America/Metlakatla': ('Alaskan Standard Time', 'US'), 'America/Mexico_City': ('Central Standard Time (Mexico)', '001'), 'America/Miquelon': ('Saint Pierre Standard Time', '001'), 'America/Moncton': ('Atlantic Standard Time', 'CA'), 'America/Monterrey': ('Central Standard Time (Mexico)', 'MX'), 'America/Montevideo': ('Montevideo Standard Time', '001'), 'America/Montreal': ('Eastern Standard Time', 'CA'), 'America/Montserrat': ('SA Western Standard Time', 'MS'), 'America/Nassau': ('Eastern Standard Time', 'BS'), 'America/New_York': ('Eastern Standard Time', '001'), 'America/Nipigon': ('Eastern Standard Time', 'CA'), 'America/Nome': ('Alaskan Standard Time', 'US'), 'America/Noronha': ('UTC-02', 'BR'), 'America/North_Dakota/Beulah': ('Central Standard Time', 'US'), 'America/North_Dakota/Center': ('Central Standard Time', 'US'), 'America/North_Dakota/New_Salem': ('Central Standard Time', 'US'), 'America/Ojinaga': ('Mountain Standard Time', 'MX'), 'America/Panama': ('SA Pacific Standard Time', 'PA'), 'America/Pangnirtung': ('Eastern Standard Time', 'CA'), 'America/Paramaribo': ('SA Eastern Standard Time', 'SR'), 'America/Phoenix': ('US Mountain Standard Time', '001'), 'America/Port-au-Prince': ('Haiti Standard Time', '001'), 'America/Port_of_Spain': ('SA Western Standard Time', 'TT'), 'America/Porto_Velho': ('SA Western Standard Time', 'BR'), 'America/Puerto_Rico': ('SA Western Standard Time', 'PR'), 'America/Punta_Arenas': ('Magallanes Standard Time', '001'), 'America/Rainy_River': ('Central Standard Time', 'CA'), 'America/Rankin_Inlet': ('Central Standard Time', 'CA'), 'America/Recife': ('SA Eastern Standard Time', 'BR'), 'America/Regina': ('Canada Central Standard Time', '001'), 'America/Resolute': ('Central Standard Time', 'CA'), 'America/Rio_Branco': ('SA Pacific Standard Time', 'BR'), 'America/Santa_Isabel': ('Pacific Standard Time (Mexico)', 'MX'), 'America/Santarem': ('SA Eastern Standard Time', 'BR'), 'America/Santiago': ('Pacific SA Standard Time', '001'), 'America/Santo_Domingo': ('SA Western Standard Time', 'DO'), 'America/Sao_Paulo': ('E. South America Standard Time', '001'), 'America/Scoresbysund': ('Azores Standard Time', 'GL'), 'America/Sitka': ('Alaskan Standard Time', 'US'), 'America/St_Barthelemy': ('SA Western Standard Time', 'BL'), 'America/St_Johns': ('Newfoundland Standard Time', '001'), 'America/St_Kitts': ('SA Western Standard Time', 'KN'), 'America/St_Lucia': ('SA Western Standard Time', 'LC'), 'America/St_Thomas': ('SA Western Standard Time', 'VI'), 'America/St_Vincent': ('SA Western Standard Time', 'VC'), 'America/Swift_Current': ('Canada Central Standard Time', 'CA'), 'America/Tegucigalpa': ('Central America Standard Time', 'HN'), 'America/Thule': ('Atlantic Standard Time', 'GL'), 'America/Thunder_Bay': ('Eastern Standard Time', 'CA'), 'America/Tijuana': ('Pacific Standard Time (Mexico)', '001'), 'America/Toronto': ('Eastern Standard Time', 'CA'), 'America/Tortola': ('SA Western Standard Time', 'VG'), 'America/Vancouver': ('Pacific Standard Time', 'CA'), 'America/Whitehorse': ('Pacific Standard Time', 'CA'), 'America/Winnipeg': ('Central Standard Time', 'CA'), 'America/Yakutat': ('Alaskan Standard Time', 'US'), 'America/Yellowknife': ('Mountain Standard Time', 'CA'), 'Antarctica/Casey': ('Singapore Standard Time', 'AQ'), 'Antarctica/Davis': ('SE Asia Standard Time', 'AQ'), 'Antarctica/DumontDUrville': ('West Pacific Standard Time', 'AQ'), 'Antarctica/Macquarie': ('Central Pacific Standard Time', 'AU'), 'Antarctica/Mawson': ('West Asia Standard Time', 'AQ'), 'Antarctica/McMurdo': ('New Zealand Standard Time', 'AQ'), 'Antarctica/Palmer': ('SA Eastern Standard Time', 'AQ'), 'Antarctica/Rothera': ('SA Eastern Standard Time', 'AQ'), 'Antarctica/Syowa': ('E. Africa Standard Time', 'AQ'), 'Antarctica/Vostok': ('Central Asia Standard Time', 'AQ'), 'Arctic/Longyearbyen': ('W. Europe Standard Time', 'SJ'), 'Asia/Aden': ('Arab Standard Time', 'YE'), 'Asia/Almaty': ('Central Asia Standard Time', '001'), 'Asia/Amman': ('Jordan Standard Time', '001'), 'Asia/Anadyr': ('Russia Time Zone 11', 'RU'), 'Asia/Aqtau': ('West Asia Standard Time', 'KZ'), 'Asia/Aqtobe': ('West Asia Standard Time', 'KZ'), 'Asia/Ashgabat': ('West Asia Standard Time', 'TM'), 'Asia/Atyrau': ('West Asia Standard Time', 'KZ'), 'Asia/Baghdad': ('Arabic Standard Time', '001'), 'Asia/Bahrain': ('Arab Standard Time', 'BH'), 'Asia/Baku': ('Azerbaijan Standard Time', '001'), 'Asia/Bangkok': ('SE Asia Standard Time', '001'), 'Asia/Barnaul': ('Altai Standard Time', '001'), 'Asia/Beirut': ('Middle East Standard Time', '001'), 'Asia/Bishkek': ('Central Asia Standard Time', 'KG'), 'Asia/Brunei': ('Singapore Standard Time', 'BN'), 'Asia/Calcutta': ('India Standard Time', '001'), 'Asia/Chita': ('Transbaikal Standard Time', '001'), 'Asia/Choibalsan': ('Ulaanbaatar Standard Time', 'MN'), 'Asia/Colombo': ('Sri Lanka Standard Time', '001'), 'Asia/Damascus': ('Syria Standard Time', '001'), 'Asia/Dhaka': ('Bangladesh Standard Time', '001'), 'Asia/Dili': ('Tokyo Standard Time', 'TL'), 'Asia/Dubai': ('Arabian Standard Time', '001'), 'Asia/Dushanbe': ('West Asia Standard Time', 'TJ'), 'Asia/Famagusta': ('GTB Standard Time', 'CY'), 'Asia/Gaza': ('West Bank Standard Time', 'PS'), 'Asia/Hebron': ('West Bank Standard Time', '001'), 'Asia/Hong_Kong': ('China Standard Time', 'HK'), 'Asia/Hovd': ('W. Mongolia Standard Time', '001'), 'Asia/Irkutsk': ('North Asia East Standard Time', '001'), 'Asia/Jakarta': ('SE Asia Standard Time', 'ID'), 'Asia/Jayapura': ('Tokyo Standard Time', 'ID'), 'Asia/Jerusalem': ('Israel Standard Time', '001'), 'Asia/Kabul': ('Afghanistan Standard Time', '001'), 'Asia/Kamchatka': ('Russia Time Zone 11', '001'), 'Asia/Karachi': ('Pakistan Standard Time', '001'), 'Asia/Katmandu': ('Nepal Standard Time', '001'), 'Asia/Khandyga': ('Yakutsk Standard Time', 'RU'), 'Asia/Krasnoyarsk': ('North Asia Standard Time', '001'), 'Asia/Kuala_Lumpur': ('Singapore Standard Time', 'MY'), 'Asia/Kuching': ('Singapore Standard Time', 'MY'), 'Asia/Kuwait': ('Arab Standard Time', 'KW'), 'Asia/Macau': ('China Standard Time', 'MO'), 'Asia/Magadan': ('Magadan Standard Time', '001'), 'Asia/Makassar': ('Singapore Standard Time', 'ID'), 'Asia/Manila': ('Singapore Standard Time', 'PH'), 'Asia/Muscat': ('Arabian Standard Time', 'OM'), 'Asia/Nicosia': ('GTB Standard Time', 'CY'), 'Asia/Novokuznetsk': ('North Asia Standard Time', 'RU'), 'Asia/Novosibirsk': ('N. Central Asia Standard Time', '001'), 'Asia/Omsk': ('Omsk Standard Time', '001'), 'Asia/Oral': ('West Asia Standard Time', 'KZ'), 'Asia/Phnom_Penh': ('SE Asia Standard Time', 'KH'), 'Asia/Pontianak': ('SE Asia Standard Time', 'ID'), 'Asia/Pyongyang': ('North Korea Standard Time', '001'), 'Asia/Qatar': ('Arab Standard Time', 'QA'), 'Asia/Qostanay': ('Central Asia Standard Time', 'KZ'), 'Asia/Qyzylorda': ('Qyzylorda Standard Time', '001'), 'Asia/Rangoon': ('Myanmar Standard Time', '001'), 'Asia/Riyadh': ('Arab Standard Time', '001'), 'Asia/Saigon': ('SE Asia Standard Time', 'VN'), 'Asia/Sakhalin': ('Sakhalin Standard Time', '001'), 'Asia/Samarkand': ('West Asia Standard Time', 'UZ'), 'Asia/Seoul': ('Korea Standard Time', '001'), 'Asia/Shanghai': ('China Standard Time', '001'), 'Asia/Singapore': ('Singapore Standard Time', '001'), 'Asia/Srednekolymsk': ('Russia Time Zone 10', '001'), 'Asia/Taipei': ('Taipei Standard Time', '001'), 'Asia/Tashkent': ('West Asia Standard Time', '001'), 'Asia/Tbilisi': ('Georgian Standard Time', '001'), 'Asia/Tehran': ('Iran Standard Time', '001'), 'Asia/Thimphu': ('Bangladesh Standard Time', 'BT'), 'Asia/Tokyo': ('Tokyo Standard Time', '001'), 'Asia/Tomsk': ('Tomsk Standard Time', '001'), 'Asia/Ulaanbaatar': ('Ulaanbaatar Standard Time', '001'), 'Asia/Urumqi': ('Central Asia Standard Time', 'CN'), 'Asia/Ust-Nera': ('Vladivostok Standard Time', 'RU'), 'Asia/Vientiane': ('SE Asia Standard Time', 'LA'), 'Asia/Vladivostok': ('Vladivostok Standard Time', '001'), 'Asia/Yakutsk': ('Yakutsk Standard Time', '001'), 'Asia/Yekaterinburg': ('Ekaterinburg Standard Time', '001'), 'Asia/Yerevan': ('Caucasus Standard Time', '001'), 'Atlantic/Azores': ('Azores Standard Time', '001'), 'Atlantic/Bermuda': ('Atlantic Standard Time', 'BM'), 'Atlantic/Canary': ('GMT Standard Time', 'ES'), 'Atlantic/Cape_Verde': ('Cape Verde Standard Time', '001'), 'Atlantic/Faeroe': ('GMT Standard Time', 'FO'), 'Atlantic/Madeira': ('GMT Standard Time', 'PT'), 'Atlantic/Reykjavik': ('Greenwich Standard Time', '001'), 'Atlantic/South_Georgia': ('UTC-02', 'GS'), 'Atlantic/St_Helena': ('Greenwich Standard Time', 'SH'), 'Atlantic/Stanley': ('SA Eastern Standard Time', 'FK'), 'Australia/Adelaide': ('Cen. Australia Standard Time', '001'), 'Australia/Brisbane': ('E. Australia Standard Time', '001'), 'Australia/Broken_Hill': ('Cen. Australia Standard Time', 'AU'), 'Australia/Currie': ('Tasmania Standard Time', 'AU'), 'Australia/Darwin': ('AUS Central Standard Time', '001'), 'Australia/Eucla': ('Aus Central W. Standard Time', '001'), 'Australia/Hobart': ('Tasmania Standard Time', '001'), 'Australia/Lindeman': ('E. Australia Standard Time', 'AU'), 'Australia/Lord_Howe': ('Lord Howe Standard Time', '001'), 'Australia/Melbourne': ('AUS Eastern Standard Time', 'AU'), 'Australia/Perth': ('W. Australia Standard Time', '001'), 'Australia/Sydney': ('AUS Eastern Standard Time', '001'), 'CST6CDT': ('Central Standard Time', 'ZZ'), 'EST5EDT': ('Eastern Standard Time', 'ZZ'), 'Etc/GMT': ('UTC', '001'), 'Etc/GMT+1': ('Cape Verde Standard Time', 'ZZ'), 'Etc/GMT+10': ('Hawaiian Standard Time', 'ZZ'), 'Etc/GMT+11': ('UTC-11', '001'), 'Etc/GMT+12': ('Dateline Standard Time', '001'), 'Etc/GMT+2': ('UTC-02', '001'), 'Etc/GMT+3': ('SA Eastern Standard Time', 'ZZ'), 'Etc/GMT+4': ('SA Western Standard Time', 'ZZ'), 'Etc/GMT+5': ('SA Pacific Standard Time', 'ZZ'), 'Etc/GMT+6': ('Central America Standard Time', 'ZZ'), 'Etc/GMT+7': ('US Mountain Standard Time', 'ZZ'), 'Etc/GMT+8': ('UTC-08', '001'), 'Etc/GMT+9': ('UTC-09', '001'), 'Etc/GMT-1': ('W. Central Africa Standard Time', 'ZZ'), 'Etc/GMT-10': ('West Pacific Standard Time', 'ZZ'), 'Etc/GMT-11': ('Central Pacific Standard Time', 'ZZ'), 'Etc/GMT-12': ('UTC+12', '001'), 'Etc/GMT-13': ('UTC+13', '001'), 'Etc/GMT-14': ('Line Islands Standard Time', 'ZZ'), 'Etc/GMT-2': ('South Africa Standard Time', 'ZZ'), 'Etc/GMT-3': ('E. Africa Standard Time', 'ZZ'), 'Etc/GMT-4': ('Arabian Standard Time', 'ZZ'), 'Etc/GMT-5': ('West Asia Standard Time', 'ZZ'), 'Etc/GMT-6': ('Central Asia Standard Time', 'ZZ'), 'Etc/GMT-7': ('SE Asia Standard Time', 'ZZ'), 'Etc/GMT-8': ('Singapore Standard Time', 'ZZ'), 'Etc/GMT-9': ('Tokyo Standard Time', 'ZZ'), 'Etc/UTC': ('UTC', 'ZZ'), 'Europe/Amsterdam': ('W. Europe Standard Time', 'NL'), 'Europe/Andorra': ('W. Europe Standard Time', 'AD'), 'Europe/Astrakhan': ('Astrakhan Standard Time', '001'), 'Europe/Athens': ('GTB Standard Time', 'GR'), 'Europe/Belgrade': ('Central Europe Standard Time', 'RS'), 'Europe/Berlin': ('W. Europe Standard Time', '001'), 'Europe/Bratislava': ('Central Europe Standard Time', 'SK'), 'Europe/Brussels': ('Romance Standard Time', 'BE'), 'Europe/Bucharest': ('GTB Standard Time', '001'), 'Europe/Budapest': ('Central Europe Standard Time', '001'), 'Europe/Busingen': ('W. Europe Standard Time', 'DE'), 'Europe/Chisinau': ('E. Europe Standard Time', '001'), 'Europe/Copenhagen': ('Romance Standard Time', 'DK'), 'Europe/Dublin': ('GMT Standard Time', 'IE'), 'Europe/Gibraltar': ('W. Europe Standard Time', 'GI'), 'Europe/Guernsey': ('GMT Standard Time', 'GG'), 'Europe/Helsinki': ('FLE Standard Time', 'FI'), 'Europe/Isle_of_Man': ('GMT Standard Time', 'IM'), 'Europe/Istanbul': ('Turkey Standard Time', '001'), 'Europe/Jersey': ('GMT Standard Time', 'JE'), 'Europe/Kaliningrad': ('Kaliningrad Standard Time', '001'), 'Europe/Kiev': ('FLE Standard Time', '001'), 'Europe/Kirov': ('Russian Standard Time', 'RU'), 'Europe/Lisbon': ('GMT Standard Time', 'PT'), 'Europe/Ljubljana': ('Central Europe Standard Time', 'SI'), 'Europe/London': ('GMT Standard Time', '001'), 'Europe/Luxembourg': ('W. Europe Standard Time', 'LU'), 'Europe/Madrid': ('Romance Standard Time', 'ES'), 'Europe/Malta': ('W. Europe Standard Time', 'MT'), 'Europe/Mariehamn': ('FLE Standard Time', 'AX'), 'Europe/Minsk': ('Belarus Standard Time', '001'), 'Europe/Monaco': ('W. Europe Standard Time', 'MC'), 'Europe/Moscow': ('Russian Standard Time', '001'), 'Europe/Oslo': ('W. Europe Standard Time', 'NO'), 'Europe/Paris': ('Romance Standard Time', '001'), 'Europe/Podgorica': ('Central Europe Standard Time', 'ME'), 'Europe/Prague': ('Central Europe Standard Time', 'CZ'), 'Europe/Riga': ('FLE Standard Time', 'LV'), 'Europe/Rome': ('W. Europe Standard Time', 'IT'), 'Europe/Samara': ('Russia Time Zone 3', '001'), 'Europe/San_Marino': ('W. Europe Standard Time', 'SM'), 'Europe/Sarajevo': ('Central European Standard Time', 'BA'), 'Europe/Saratov': ('Saratov Standard Time', '001'), 'Europe/Simferopol': ('Russian Standard Time', 'UA'), 'Europe/Skopje': ('Central European Standard Time', 'MK'), 'Europe/Sofia': ('FLE Standard Time', 'BG'), 'Europe/Stockholm': ('W. Europe Standard Time', 'SE'), 'Europe/Tallinn': ('FLE Standard Time', 'EE'), 'Europe/Tirane': ('Central Europe Standard Time', 'AL'), 'Europe/Ulyanovsk': ('Astrakhan Standard Time', 'RU'), 'Europe/Uzhgorod': ('FLE Standard Time', 'UA'), 'Europe/Vaduz': ('W. Europe Standard Time', 'LI'), 'Europe/Vatican': ('W. Europe Standard Time', 'VA'), 'Europe/Vienna': ('W. Europe Standard Time', 'AT'), 'Europe/Vilnius': ('FLE Standard Time', 'LT'), 'Europe/Volgograd': ('Volgograd Standard Time', '001'), 'Europe/Warsaw': ('Central European Standard Time', '001'), 'Europe/Zagreb': ('Central European Standard Time', 'HR'), 'Europe/Zaporozhye': ('FLE Standard Time', 'UA'), 'Europe/Zurich': ('W. Europe Standard Time', 'CH'), 'Indian/Antananarivo': ('E. Africa Standard Time', 'MG'), 'Indian/Chagos': ('Central Asia Standard Time', 'IO'), 'Indian/Christmas': ('SE Asia Standard Time', 'CX'), 'Indian/Cocos': ('Myanmar Standard Time', 'CC'), 'Indian/Comoro': ('E. Africa Standard Time', 'KM'), 'Indian/Kerguelen': ('West Asia Standard Time', 'TF'), 'Indian/Mahe': ('Mauritius Standard Time', 'SC'), 'Indian/Maldives': ('West Asia Standard Time', 'MV'), 'Indian/Mauritius': ('Mauritius Standard Time', '001'), 'Indian/Mayotte': ('E. Africa Standard Time', 'YT'), 'Indian/Reunion': ('Mauritius Standard Time', 'RE'), 'MST7MDT': ('Mountain Standard Time', 'ZZ'), 'PST8PDT': ('Pacific Standard Time', 'ZZ'), 'Pacific/Apia': ('Samoa Standard Time', '001'), 'Pacific/Auckland': ('New Zealand Standard Time', '001'), 'Pacific/Bougainville': ('Bougainville Standard Time', '001'), 'Pacific/Chatham': ('Chatham Islands Standard Time', '001'), 'Pacific/Easter': ('Easter Island Standard Time', '001'), 'Pacific/Efate': ('Central Pacific Standard Time', 'VU'), 'Pacific/Enderbury': ('UTC+13', 'KI'), 'Pacific/Fakaofo': ('UTC+13', 'TK'), 'Pacific/Fiji': ('Fiji Standard Time', '001'), 'Pacific/Funafuti': ('UTC+12', 'TV'), 'Pacific/Galapagos': ('Central America Standard Time', 'EC'), 'Pacific/Gambier': ('UTC-09', 'PF'), 'Pacific/Guadalcanal': ('Central Pacific Standard Time', '001'), 'Pacific/Guam': ('West Pacific Standard Time', 'GU'), 'Pacific/Honolulu': ('Hawaiian Standard Time', '001'), 'Pacific/Johnston': ('Hawaiian Standard Time', 'UM'), 'Pacific/Kiritimati': ('Line Islands Standard Time', '001'), 'Pacific/Kosrae': ('Central Pacific Standard Time', 'FM'), 'Pacific/Kwajalein': ('UTC+12', 'MH'), 'Pacific/Majuro': ('UTC+12', 'MH'), 'Pacific/Marquesas': ('Marquesas Standard Time', '001'), 'Pacific/Midway': ('UTC-11', 'UM'), 'Pacific/Nauru': ('UTC+12', 'NR'), 'Pacific/Niue': ('UTC-11', 'NU'), 'Pacific/Norfolk': ('Norfolk Standard Time', '001'), 'Pacific/Noumea': ('Central Pacific Standard Time', 'NC'), 'Pacific/Pago_Pago': ('UTC-11', 'AS'), 'Pacific/Palau': ('Tokyo Standard Time', 'PW'), 'Pacific/Pitcairn': ('UTC-08', 'PN'), 'Pacific/Ponape': ('Central Pacific Standard Time', 'FM'), 'Pacific/Port_Moresby': ('West Pacific Standard Time', '001'), 'Pacific/Rarotonga': ('Hawaiian Standard Time', 'CK'), 'Pacific/Saipan': ('West Pacific Standard Time', 'MP'), 'Pacific/Tahiti': ('Hawaiian Standard Time', 'PF'), 'Pacific/Tarawa': ('UTC+12', 'KI'), 'Pacific/Tongatapu': ('Tonga Standard Time', '001'), 'Pacific/Truk': ('West Pacific Standard Time', 'FM'), 'Pacific/Wake': ('UTC+12', 'UM'), 'Pacific/Wallis': ('UTC+12', 'WF'), } # Add timezone names used by pytz (which gets timezone names from # IANA) that are not found in the CLDR. # Use 'noterritory' unless you want to override the standard mapping # (in which case, '001'). # TODO: A full list of the IANA names missing in CLDR can be found with: # # sorted(set(pytz.all_timezones) - set(CLDR_TO_MS_TIMEZONE_MAP)) # PYTZ_TO_MS_TIMEZONE_MAP = dict( CLDR_TO_MS_TIMEZONE_MAP, **{ 'Asia/Kolkata': ('India Standard Time', 'noterritory'), 'GMT': ('UTC', 'noterritory'), 'UTC': ('UTC', 'noterritory'), } ) # Reverse map from Microsoft timezone ID to pytz timezone name. Non-CLDR timezone ID's can be added here. MS_TIMEZONE_TO_PYTZ_MAP = dict( {v[0]: k for k, v in PYTZ_TO_MS_TIMEZONE_MAP.items() if v[1] == DEFAULT_TERRITORY}, **{ 'tzone://Microsoft/Utc': 'UTC', } ) exchangelib-3.1.1/scripts/000077500000000000000000000000001361226005600154555ustar00rootroot00000000000000exchangelib-3.1.1/scripts/notifier.py000066400000000000000000000060701361226005600176510ustar00rootroot00000000000000""" This script is an example of 'exchangelib' usage. It will give you email and appointment notifications from your Exchange account on your Ubuntu desktop. Usage: notifier.py [notify_interval] You need to install the `libxml2-dev` `libxslt1-dev` packages for 'exchangelib' to work on Ubuntu. Login and password is fetched from `~/.netrc`. Add an entry like this: machine office365 login MY_INITIALS@example.com password MY_PASSWORD You can keep the notifier running by adding this to your shell startup script: start-stop-daemon \ --pidfile ~/office365-notifier/notify.pid \ --make-pidfile --start --background \ --startas ~/office365-notifier/notify.sh Where `~/office365-notifier/notify.sh` contains this: cd "$( dirname "$0" )" if [ ! -d "office365_env" ]; then virtualenv -p python3 office365_env fi source office365_env/bin/activate pip3 install sh exchangelib > /dev/null sleep=${1:-600} while true do python3 notifier.py $sleep sleep $sleep done """ from datetime import timedelta from netrc import netrc import sys import warnings from exchangelib import DELEGATE, Credentials, Account, EWSTimeZone, UTC_NOW import sh if '--insecure' in sys.argv: # Disable TLS when Office365 can't get their certificate act together from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter # Disable insecure TLS warnings warnings.filterwarnings("ignore") # Use notify-send for email notifications and zenity for calendar notifications notify = sh.Command('/usr/bin/notify-send') zenity = sh.Command('/usr/bin/zenity') # Get the local timezone tz = EWSTimeZone.localzone() sleep = int(sys.argv[1]) # 1st arg to this script is the number of seconds to look back in the inbox now = UTC_NOW() emails_since = now - timedelta(seconds=sleep) cal_items_before = now + timedelta(seconds=sleep * 4) # Longer notice of upcoming appointments than new emails username, _, password = netrc().authenticators('office365') c = Credentials(username, password) a = Account(primary_smtp_address=c.username, credentials=c, access_type=DELEGATE, autodiscover=True) for msg in a.calendar.view(start=now, end=cal_items_before)\ .only('start', 'end', 'subject', 'location')\ .order_by('start', 'end'): if msg.start < now: continue minutes_to_appointment = int((msg.start - now).total_seconds() / 60) subj = 'You have a meeting in %s minutes' % minutes_to_appointment body = '%s-%s: %s\n%s' % ( msg.start.astimezone(tz).strftime('%H:%M'), msg.end.astimezone(tz).strftime('%H:%M'), msg.subject[:150], msg.location ) zenity(**{'info': None, 'no-markup': None, 'title': subj, 'text': body}) for msg in a.inbox.filter(datetime_received__gt=emails_since, is_read=False)\ .only('datetime_received', 'subject', 'text_body')\ .order_by('datetime_received')[:10]: subj = 'New mail: %s' % msg.subject clean_body = '\n'.join(l for l in msg.text_body.split('\n') if l) notify(subj, clean_body[:200]) exchangelib-3.1.1/scripts/optimize.py000077500000000000000000000056751361226005600177070ustar00rootroot00000000000000#!/usr/bin/env python # Measures bulk create and delete performance for different session pool sizes and payload chunksizes import copy import logging import os import time from yaml import safe_load from exchangelib import DELEGATE, Configuration, Account, EWSDateTime, EWSTimeZone, CalendarItem, Credentials, \ FaultTolerance logging.basicConfig(level=logging.WARNING) try: with open(os.path.join(os.path.dirname(__file__), '../settings.yml')) as f: settings = safe_load(f) except FileNotFoundError: print('Copy settings.yml.sample to settings.yml and enter values for your test server') raise categories = ['perftest'] tz = EWSTimeZone.timezone('America/New_York') verify_ssl = settings.get('verify_ssl', True) if not verify_ssl: from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter config = Configuration( server=settings['server'], credentials=Credentials(settings['username'], settings['password']), retry_policy=FaultTolerance(), ) print('Exchange server: %s' % config.service_endpoint) account = Account(config=config, primary_smtp_address=settings['account'], access_type=DELEGATE) # Remove leftovers from earlier tests account.calendar.filter(categories__contains=categories).delete() # Calendar item generator def generate_items(count): start = tz.localize(EWSDateTime(2000, 3, 1, 8, 30, 0)) end = tz.localize(EWSDateTime(2000, 3, 1, 9, 15, 0)) tpl_item = CalendarItem( start=start, end=end, body='This is a performance optimization test of server %s intended to find the optimal batch size and ' 'concurrent connection pool size of this server.' % account.protocol.server, location="It's safe to delete this", categories=categories, ) for j in range(count): item = copy.copy(tpl_item) item.subject = 'Performance optimization test %s by exchangelib' % j, yield item # Worker def test(items, chunk_size): t1 = time.monotonic() ids = account.calendar.bulk_create(items=items, chunk_size=chunk_size) t2 = time.monotonic() account.bulk_delete(ids=ids, chunk_size=chunk_size) t3 = time.monotonic() delta1 = t2 - t1 rate1 = len(ids) / delta1 delta2 = t3 - t2 rate2 = len(ids) / delta2 print(('Time to process %s items (batchsize %s, poolsize %s): %s / %s (%s / %s per sec)' % ( len(ids), chunk_size, account.protocol.poolsize, delta1, delta2, rate1, rate2))) # Generate items calitems = list(generate_items(500)) print('\nTesting batch size') for i in range(1, 11): chunk_size = 25 * i account.protocol.poolsize = 5 test(calitems, chunk_size) time.sleep(60) # Sleep 1 minute. Performance will deteriorate over time if we give the server tie to recover print('\nTesting pool size') for i in range(1, 11): chunk_size = 10 account.protocol.poolsize = i test(calitems, chunk_size) time.sleep(60) exchangelib-3.1.1/scripts/wipe_test_account.py000066400000000000000000000001251361226005600215440ustar00rootroot00000000000000from tests.common import EWSTest t = EWSTest() t.setUpClass() t.wipe_test_account() exchangelib-3.1.1/settings.yml.enc000066400000000000000000000004201361226005600171110ustar00rootroot00000000000000>e `WXG5+2ijt=5gT*4)JQCD#!m90zI /4_eyCgYdzl qB|v>i()kjZ)FQ5|6kvơsyҕCwq0 *b(o-5Ҩ۱2{a3fkɧ0iD}PBw[ kO1aVWm1 ½Z[|tQŖNW8#OŕhBU!AM;exchangelib-3.1.1/settings.yml.sample000066400000000000000000000003741361226005600176350ustar00rootroot00000000000000server: 'example.com' autodiscover_server: 'example.com' username: 'MYWINDOMAIN\myusername' password: 'topsecret' account: 'john.doe@example.com' # Don't use an account containing valuable data! We're polite, but things may go wrong. verify_ssl: True exchangelib-3.1.1/setup.cfg000066400000000000000000000000771361226005600156130ustar00rootroot00000000000000[bdist_wheel] universal = 1 [metadata] license_file = LICENSE exchangelib-3.1.1/setup.py000077500000000000000000000041331361226005600155040ustar00rootroot00000000000000#!/usr/bin/env python """ Release notes: * Bump version in exchangelib/__init__.py * Bump version in CHANGELOG.md * Commit and push changes * Build package: rm -rf dist/* && python setup.py sdist bdist_wheel * Push to PyPI: twine upload dist/* * Create release on GitHub """ import io import os from setuptools import setup, find_packages __version__ = None with io.open(os.path.join(os.path.dirname(__file__), 'exchangelib/__init__.py'), encoding='utf-8') as f: for l in f: if not l.startswith('__version__'): continue __version__ = l.split('=')[1].strip(' "\'\n') break def read(file_name): with io.open(os.path.join(os.path.dirname(__file__), file_name), encoding='utf-8') as f: return f.read() setup( name='exchangelib', version=__version__, author='Erik Cederstrand', author_email='erik@cederstrand.dk', description='Client for Microsoft Exchange Web Services (EWS)', long_description=read('README.md'), long_description_content_type='text/markdown', license='BSD', keywords='ews exchange autodiscover microsoft outlook exchange-web-services o365 office365', install_requires=['requests>=2.7', 'requests_ntlm>=0.2.0', 'dnspython>=1.14.0', 'pytz', 'lxml>3.0', 'cached_property', 'tzlocal', 'python-dateutil', 'pygments', 'defusedxml>=0.6.0', 'isodate', 'oauthlib', 'requests_oauthlib'], extras_require={ 'kerberos': ['requests_kerberos'], 'sspi': ['requests_negotiate_sspi'], # Only for Win32 environments 'complete': ['requests_kerberos', 'requests_negotiate_sspi'], # Only for Win32 environments }, packages=find_packages(exclude=('tests',)), tests_require=['PyYAML', 'requests_mock', 'psutil', 'flake8'], python_requires=">=3.5", test_suite='tests', zip_safe=False, url='https://github.com/ecederstrand/exchangelib', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Topic :: Communications', 'License :: OSI Approved :: BSD License', 'Programming Language :: Python :: 3', ], ) exchangelib-3.1.1/tests/000077500000000000000000000000001361226005600151305ustar00rootroot00000000000000exchangelib-3.1.1/tests/__init__.py000066400000000000000000000005441361226005600172440ustar00rootroot00000000000000import logging import sys import unittest from exchangelib.util import PrettyXmlHandler # Always show full repr() output for object instances in unittest error messages unittest.util._MAX_LENGTH = 2000 if '-v' in sys.argv: logging.basicConfig(level=logging.DEBUG, handlers=[PrettyXmlHandler()]) else: logging.basicConfig(level=logging.CRITICAL) exchangelib-3.1.1/tests/common.py000066400000000000000000000347541361226005600170070ustar00rootroot00000000000000from collections import namedtuple import datetime from decimal import Decimal import os import random import string import time import unittest import unittest.util import pytz from yaml import safe_load from exchangelib.account import Account from exchangelib.attachments import FileAttachment from exchangelib.configuration import Configuration from exchangelib.credentials import DELEGATE, Credentials from exchangelib.errors import UnknownTimeZone, AmbiguousTimeError, NonExistentTimeError from exchangelib.ewsdatetime import EWSDateTime, EWSDate, EWSTimeZone, UTC from exchangelib.fields import BooleanField, IntegerField, DecimalField, TextField, EmailAddressField, URIField, \ ChoiceField, BodyField, DateTimeField, Base64Field, PhoneNumberField, EmailAddressesField, TimeZoneField, \ PhysicalAddressField, ExtendedPropertyField, MailboxField, AttendeesField, AttachmentField, CharListField, \ MailboxListField, EWSElementField, CultureField, CharField, TextListField, PermissionSetField, MimeContentField from exchangelib.indexed_properties import EmailAddress, PhysicalAddress, PhoneNumber from exchangelib.properties import Attendee, Mailbox, PermissionSet, Permission, UserId from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter, FaultTolerance from exchangelib.recurrence import Recurrence, DailyPattern mock_account = namedtuple('mock_account', ('protocol', 'version')) mock_protocol = namedtuple('mock_protocol', ('version', 'service_endpoint')) mock_version = namedtuple('mock_version', ('build',)) def mock_post(url, status_code, headers, text=''): req = namedtuple('request', ['headers'])(headers={}) c = text.encode('utf-8') return lambda **kwargs: namedtuple( 'response', ['status_code', 'headers', 'text', 'content', 'request', 'history', 'url'] )(status_code=status_code, headers=headers, text=text, content=c, request=req, history=None, url=url) def mock_session_exception(exc_cls): def raise_exc(**kwargs): raise exc_cls() return raise_exc class MockResponse: def __init__(self, c): self.c = c def iter_content(self): return self.c class TimedTestCase(unittest.TestCase): SLOW_TEST_DURATION = 5 # Log tests that are slower than this value (in seconds) def setUp(self): self.maxDiff = None self.t1 = time.monotonic() def tearDown(self): t2 = time.monotonic() - self.t1 if t2 > self.SLOW_TEST_DURATION: print("{:07.3f} : {}".format(t2, self.id())) class EWSTest(TimedTestCase): @classmethod def setUpClass(cls): # There's no official Exchange server we can test against, and we can't really provide credentials for our # own test server to everyone on the Internet. Travis-CI uses the encrypted settings.yml.enc for testing. # # If you want to test against your own server and account, create your own settings.yml with credentials for # that server. 'settings.yml.sample' is provided as a template. try: with open(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'settings.yml')) as f: settings = safe_load(f) except FileNotFoundError: print('Skipping %s - no settings.yml file found' % cls.__name__) print('Copy settings.yml.sample to settings.yml and enter values for your test server') raise unittest.SkipTest('Skipping %s - no settings.yml file found' % cls.__name__) cls.settings = settings cls.verify_ssl = settings.get('verify_ssl', True) if not cls.verify_ssl: # Allow unverified TLS if requested in settings file BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter # Create an account shared by all tests tz = EWSTimeZone.timezone('Europe/Copenhagen') cls.retry_policy = FaultTolerance(max_wait=600) config = Configuration( server=settings['server'], credentials=Credentials(settings['username'], settings['password']), retry_policy=cls.retry_policy, ) cls.account = Account(primary_smtp_address=settings['account'], access_type=DELEGATE, config=config, locale='da_DK', default_timezone=tz) def setUp(self): super().setUp() # Create a random category for each test to avoid crosstalk self.categories = [get_random_string(length=16, spaces=False, special=False)] def wipe_test_account(self): # Deletes up all deleteable items in the test account. Not run in a normal test run self.account.root.wipe(page_size=100) def bulk_delete(self, ids): # Clean up items and check return values for res in self.account.bulk_delete(ids): self.assertEqual(res, True) def random_val(self, field): if isinstance(field, ExtendedPropertyField): if field.value_cls.property_type == 'StringArray': return [get_random_string(255) for _ in range(random.randint(1, 4))] if field.value_cls.property_type == 'IntegerArray': return [get_random_int(0, 256) for _ in range(random.randint(1, 4))] if field.value_cls.property_type == 'BinaryArray': return [get_random_string(255).encode() for _ in range(random.randint(1, 4))] if field.value_cls.property_type == 'String': return get_random_string(255) if field.value_cls.property_type == 'Integer': return get_random_int(0, 256) if field.value_cls.property_type == 'Binary': # In the test_extended_distinguished_property test, EWS rull return 4 NULL bytes after char 16 if we # send a longer bytes sequence. return get_random_string(16).encode() raise ValueError('Unsupported field %s' % field) if isinstance(field, URIField): return get_random_url() if isinstance(field, EmailAddressField): return get_random_email() if isinstance(field, ChoiceField): return get_random_choice(field.supported_choices(version=self.account.version)) if isinstance(field, CultureField): return get_random_choice(['da-DK', 'de-DE', 'en-US', 'es-ES', 'fr-CA', 'nl-NL', 'ru-RU', 'sv-SE']) if isinstance(field, BodyField): return get_random_string(400) if isinstance(field, CharListField): return [get_random_string(16) for _ in range(random.randint(1, 4))] if isinstance(field, TextListField): return [get_random_string(400) for _ in range(random.randint(1, 4))] if isinstance(field, CharField): return get_random_string(field.max_length) if isinstance(field, TextField): return get_random_string(400) if isinstance(field, MimeContentField): return get_random_bytes(400) if isinstance(field, Base64Field): return get_random_bytes(400) if isinstance(field, BooleanField): return get_random_bool() if isinstance(field, DecimalField): return get_random_decimal(field.min or 1, field.max or 99) if isinstance(field, IntegerField): return get_random_int(field.min or 0, field.max or 256) if isinstance(field, DateTimeField): return get_random_datetime(tz=self.account.default_timezone) if isinstance(field, AttachmentField): return [FileAttachment(name='my_file.txt', content=get_random_bytes(400))] if isinstance(field, MailboxListField): # email_address must be a real account on the server(?) # TODO: Mailbox has multiple optional args but vals must match server account, so we can't easily test if get_random_bool(): return [Mailbox(email_address=self.account.primary_smtp_address)] else: return [self.account.primary_smtp_address] if isinstance(field, MailboxField): # email_address must be a real account on the server(?) # TODO: Mailbox has multiple optional args but vals must match server account, so we can't easily test if get_random_bool(): return Mailbox(email_address=self.account.primary_smtp_address) else: return self.account.primary_smtp_address if isinstance(field, AttendeesField): # Attendee must refer to a real mailbox on the server(?). We're only sure to have one if get_random_bool(): mbx = Mailbox(email_address=self.account.primary_smtp_address) else: mbx = self.account.primary_smtp_address with_last_response_time = get_random_bool() if with_last_response_time: return [ Attendee(mailbox=mbx, response_type='Accept', last_response_time=get_random_datetime(tz=self.account.default_timezone)) ] else: if get_random_bool(): return [Attendee(mailbox=mbx, response_type='Accept')] else: return [self.account.primary_smtp_address] if isinstance(field, EmailAddressesField): addrs = [] for label in EmailAddress.get_field_by_fieldname('label').supported_choices(version=self.account.version): addr = EmailAddress(email=get_random_email()) addr.label = label addrs.append(addr) return addrs if isinstance(field, PhysicalAddressField): addrs = [] for label in PhysicalAddress.get_field_by_fieldname('label')\ .supported_choices(version=self.account.version): addr = PhysicalAddress(street=get_random_string(32), city=get_random_string(32), state=get_random_string(32), country=get_random_string(32), zipcode=get_random_string(8)) addr.label = label addrs.append(addr) return addrs if isinstance(field, PhoneNumberField): pns = [] for label in PhoneNumber.get_field_by_fieldname('label').supported_choices(version=self.account.version): pn = PhoneNumber(phone_number=get_random_string(16)) pn.label = label pns.append(pn) return pns if isinstance(field, EWSElementField): if field.value_cls == Recurrence: return Recurrence(pattern=DailyPattern(interval=5), start=get_random_date(), number=7) if isinstance(field, TimeZoneField): while True: try: return EWSTimeZone.timezone(random.choice(pytz.all_timezones)) except UnknownTimeZone: pass if isinstance(field, PermissionSetField): return PermissionSet( permissions=[ Permission( user_id=UserId(primary_smtp_address=self.account.primary_smtp_address), ) ] ) raise ValueError('Unknown field %s' % field) def get_random_bool(): return bool(random.randint(0, 1)) def get_random_int(min_val=0, max_val=2147483647): return random.randint(min_val, max_val) def get_random_decimal(min_val=0, max_val=100): precision = 2 val = get_random_int(min_val, max_val * 10**precision) / 10.0**precision return Decimal('{:.2f}'.format(val)) def get_random_choice(choices): return random.sample(choices, 1)[0] def get_random_string(length, spaces=True, special=True): chars = string.ascii_letters + string.digits if special: chars += ':.-_' if spaces: chars += ' ' # We want random strings that don't end in spaces - Exchange strips these res = ''.join(map(lambda i: random.choice(chars), range(length))).strip() if len(res) < length: # If strip() made the string shorter, make sure to fill it up res += get_random_string(length - len(res), spaces=False) return res def get_random_bytes(*args, **kwargs): return get_random_string(*args, **kwargs).encode('utf-8') def get_random_url(): path_len = random.randint(1, 16) domain_len = random.randint(1, 30) tld_len = random.randint(2, 4) return 'http://%s.%s/%s.html' % tuple(map( lambda i: get_random_string(i, spaces=False, special=False).lower(), (domain_len, tld_len, path_len) )) def get_random_email(): account_len = random.randint(1, 6) domain_len = random.randint(1, 30) tld_len = random.randint(2, 4) return '%s@%s.%s' % tuple(map( lambda i: get_random_string(i, spaces=False, special=False).lower(), (account_len, domain_len, tld_len) )) # The timezone we're testing (CET/CEST) had a DST date change in 1996 (see # https://en.wikipedia.org/wiki/Summer_Time_in_Europe). The Microsoft timezone definition on the server # does not observe that, but pytz does. So random datetimes before 1996 will fail tests randomly. def get_random_date(start_date=EWSDate(1996, 1, 1), end_date=EWSDate(2030, 1, 1)): # Keep with a reasonable date range. A wider date range is unstable WRT timezones return EWSDate.fromordinal(random.randint(start_date.toordinal(), end_date.toordinal())) def get_random_datetime(start_date=EWSDate(1996, 1, 1), end_date=EWSDate(2030, 1, 1), tz=UTC): # Create a random datetime with minute precision. Both dates are inclusive. # Keep with a reasonable date range. A wider date range than the default values is unstable WRT timezones. while True: try: random_date = get_random_date(start_date=start_date, end_date=end_date) random_datetime = datetime.datetime.combine(random_date, datetime.time.min) \ + datetime.timedelta(minutes=random.randint(0, 60 * 24)) return tz.localize(EWSDateTime.from_datetime(random_datetime), is_dst=None) except (AmbiguousTimeError, NonExistentTimeError): pass def get_random_datetime_range(start_date=EWSDate(1996, 1, 1), end_date=EWSDate(2030, 1, 1), tz=UTC): # Create two random datetimes. Both dates are inclusive. # Keep with a reasonable date range. A wider date range than the default values is unstable WRT timezones. # Calendar items raise ErrorCalendarDurationIsTooLong if duration is > 5 years. return sorted([ get_random_datetime(start_date=start_date, end_date=end_date, tz=tz), get_random_datetime(start_date=start_date, end_date=end_date, tz=tz), ]) exchangelib-3.1.1/tests/test_account.py000066400000000000000000000251421361226005600202010ustar00rootroot00000000000000from collections import namedtuple import pickle from exchangelib import Account, Credentials, FaultTolerance, Message, FileAttachment, DELEGATE, Configuration from exchangelib.errors import ErrorAccessDenied, ErrorFolderNotFound, UnauthorizedError from exchangelib.folders import Calendar from exchangelib.properties import DelegateUser, UserId, DelegatePermissions from exchangelib.protocol import Protocol from exchangelib.services import GetDelegate from .common import EWSTest, MockResponse class AccountTest(EWSTest): def test_magic(self): self.account.fullname = 'John Doe' self.assertIn(self.account.primary_smtp_address, str(self.account)) self.assertIn(self.account.fullname, str(self.account)) def test_validation(self): with self.assertRaises(ValueError) as e: # Must have valid email address Account(primary_smtp_address='blah') self.assertEqual(str(e.exception), "primary_smtp_address 'blah' is not an email address") with self.assertRaises(AttributeError) as e: # Non-autodiscover requires a config Account(primary_smtp_address='blah@example.com', autodiscover=False) self.assertEqual(str(e.exception), 'non-autodiscover requires a config') with self.assertRaises(ValueError) as e: # access type must be one of ACCESS_TYPES Account(primary_smtp_address='blah@example.com', access_type=123) self.assertEqual(str(e.exception), "'access_type' 123 must be one of ('impersonation', 'delegate')") with self.assertRaises(ValueError) as e: # locale must be a string Account(primary_smtp_address='blah@example.com', locale=123) self.assertEqual(str(e.exception), "Expected 'locale' to be a string, got 123") with self.assertRaises(ValueError) as e: # default timezone must be an EWSTimeZone Account(primary_smtp_address='blah@example.com', default_timezone=123) self.assertEqual(str(e.exception), "Expected 'default_timezone' to be an EWSTimeZone, got 123") with self.assertRaises(ValueError) as e: # config must be a Configuration Account(primary_smtp_address='blah@example.com', config=123) self.assertEqual(str(e.exception), "Expected 'config' to be a Configuration, got 123") def test_get_default_folder(self): # Test a normal folder lookup with GetFolder folder = self.account.root.get_default_folder(Calendar) self.assertIsInstance(folder, Calendar) self.assertNotEqual(folder.id, None) self.assertEqual(folder.name.lower(), Calendar.localized_names(self.account.locale)[0]) class MockCalendar(Calendar): @classmethod def get_distinguished(cls, root): raise ErrorAccessDenied('foo') # Test an indirect folder lookup with FindItems folder = self.account.root.get_default_folder(MockCalendar) self.assertIsInstance(folder, MockCalendar) self.assertEqual(folder.id, None) self.assertEqual(folder.name, MockCalendar.DISTINGUISHED_FOLDER_ID) class MockCalendar(Calendar): @classmethod def get_distinguished(cls, root): raise ErrorFolderNotFound('foo') # Test using the one folder of this folder type with self.assertRaises(ErrorFolderNotFound): # This fails because there are no folders of type MockCalendar self.account.root.get_default_folder(MockCalendar) _orig = Calendar.get_distinguished try: Calendar.get_distinguished = MockCalendar.get_distinguished folder = self.account.root.get_default_folder(Calendar) self.assertIsInstance(folder, Calendar) self.assertNotEqual(folder.id, None) self.assertEqual(folder.name.lower(), MockCalendar.localized_names(self.account.locale)[0]) finally: Calendar.get_distinguished = _orig def test_pickle(self): # Test that we can pickle various objects item = Message(folder=self.account.inbox, subject='XXX', categories=self.categories).save() attachment = FileAttachment(name='pickle_me.txt', content=b'') for o in ( Credentials('XXX', 'YYY'), FaultTolerance(max_wait=3600), self.account.protocol, attachment, self.account.root, self.account.inbox, self.account, item, ): with self.subTest(o=o): pickled_o = pickle.dumps(o) unpickled_o = pickle.loads(pickled_o) self.assertIsInstance(unpickled_o, type(o)) if not isinstance(o, (Account, Protocol, FaultTolerance)): # __eq__ is not defined on some classes self.assertEqual(o, unpickled_o) def test_mail_tips(self): # Test that mail tips work self.assertEqual(self.account.mail_tips.recipient_address, self.account.primary_smtp_address) def test_delegate(self): # The test server does not have any delegate info. Mock instead. xml = b''' NoError NoError SOME_SID foo@example.com Foo Bar Author Reviewer false true DelegatesAndMe ''' MockTZ = namedtuple('EWSTimeZone', ['ms_id']) MockAccount = namedtuple('Account', ['access_type', 'primary_smtp_address', 'default_timezone', 'protocol']) a = MockAccount(DELEGATE, 'foo@example.com', MockTZ('XXX'), protocol='foo') ws = GetDelegate(account=a) header, body = ws._get_soap_parts(response=MockResponse(xml)) res = ws._get_elements_in_response(response=ws._get_soap_messages(body=body)) delegates = [DelegateUser.from_xml(elem=elem, account=a) for elem in res] self.assertListEqual( delegates, [ DelegateUser( user_id=UserId(sid='SOME_SID', primary_smtp_address='foo@example.com', display_name='Foo Bar'), delegate_permissions=DelegatePermissions( calendar_folder_permission_level='Author', inbox_folder_permission_level='Reviewer', contacts_folder_permission_level='None', notes_folder_permission_level='None', journal_folder_permission_level='None', tasks_folder_permission_level='None', ), receive_copies_of_meeting_messages=False, view_private_items=True, ) ] ) def test_login_failure_and_credentials_update(self): # Create an account that does not need to create any connections account = Account( primary_smtp_address=self.account.primary_smtp_address, access_type=DELEGATE, config=Configuration( service_endpoint=self.account.protocol.service_endpoint, credentials=Credentials(self.account.protocol.credentials.username, 'WRONG_PASSWORD'), version=self.account.version, auth_type=self.account.protocol.auth_type, retry_policy=self.retry_policy, ), autodiscover=False, locale='da_DK', ) # Should fail when credentials are wrong, but UnauthorizedError is caught and retried. Mock the needed methods import exchangelib.util _orig1 = exchangelib.util._may_retry_on_error _orig2 = exchangelib.util._raise_response_errors def _mock1(response, retry_policy, wait): if response.status_code == 401: return False return _orig1(response, retry_policy, wait) def _mock2(response, protocol, log_msg, log_vals): if response.status_code == 401: raise UnauthorizedError('Wrong username or password for %s' % response.url) return _orig2(response, protocol, log_msg, log_vals) exchangelib.util._may_retry_on_error = _mock1 exchangelib.util._raise_response_errors = _mock2 try: with self.assertRaises(UnauthorizedError): account.root.refresh() finally: exchangelib.util._may_retry_on_error = _orig1 exchangelib.util._raise_response_errors = _orig2 # Cannot update from Configuration object with self.assertRaises(AttributeError): account.protocol.config.credentials = self.account.protocol.credentials # Should succeed after credentials update account.protocol.credentials = self.account.protocol.credentials account.root.refresh() exchangelib-3.1.1/tests/test_attachments.py000066400000000000000000000242241361226005600210600ustar00rootroot00000000000000from exchangelib.attachments import FileAttachment, ItemAttachment, AttachmentId from exchangelib.errors import ErrorItemNotFound, ErrorInvalidIdMalformed from exchangelib.folders import Inbox from exchangelib.items import Item, Message from exchangelib.services import GetAttachment from exchangelib.util import chunkify, TNS from .test_items import BaseItemTest from .common import get_random_string class AttachmentsTest(BaseItemTest): TEST_FOLDER = 'inbox' FOLDER_CLASS = Inbox ITEM_CLASS = Message def test_attachment_failure(self): att1 = FileAttachment(name='my_file_1.txt', content='Hello from unicode æøå'.encode('utf-8')) att1.attachment_id = 'XXX' with self.assertRaises(ValueError): att1.attach() # Cannot have an attachment ID att1.attachment_id = None with self.assertRaises(ValueError): att1.attach() # Must have a parent item att1.parent_item = Item() with self.assertRaises(ValueError): att1.attach() # Parent item must have an account att1.parent_item = None with self.assertRaises(ValueError): att1.detach() # Must have an attachment ID att1.attachment_id = 'XXX' with self.assertRaises(ValueError): att1.detach() # Must have a parent item att1.parent_item = Item() with self.assertRaises(ValueError): att1.detach() # Parent item must have an account att1.parent_item = None att1.attachment_id = None def test_attachment_properties(self): binary_file_content = 'Hello from unicode æøå'.encode('utf-8') att1 = FileAttachment(name='my_file_1.txt', content=binary_file_content) self.assertIn("name='my_file_1.txt'", str(att1)) att1.content = binary_file_content # Test property setter self.assertEqual(att1.content, binary_file_content) # Test property getter att1.attachment_id = 'xxx' self.assertEqual(att1.content, binary_file_content) # Test property getter when attachment_id is set att1._content = None with self.assertRaises(ValueError): print(att1.content) # Test property getter when we need to fetch the content attached_item1 = self.get_test_item(folder=self.test_folder) att2 = ItemAttachment(name='attachment1', item=attached_item1) self.assertIn("name='attachment1'", str(att2)) att2.item = attached_item1 # Test property setter self.assertEqual(att2.item, attached_item1) # Test property getter self.assertEqual(att2.item, attached_item1) # Test property getter att2.attachment_id = 'xxx' self.assertEqual(att2.item, attached_item1) # Test property getter when attachment_id is set att2._item = None with self.assertRaises(ValueError): print(att2.item) # Test property getter when we need to fetch the item def test_file_attachments(self): item = self.get_test_item(folder=self.test_folder) # Test __init__(attachments=...) and attach() on new item binary_file_content = 'Hello from unicode æøå'.encode('utf-8') att1 = FileAttachment(name='my_file_1.txt', content=binary_file_content) self.assertEqual(len(item.attachments), 0) item.attach(att1) self.assertEqual(len(item.attachments), 1) item.save() fresh_item = list(self.account.fetch(ids=[item]))[0] self.assertEqual(len(fresh_item.attachments), 1) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) self.assertEqual(fresh_attachments[0].name, 'my_file_1.txt') self.assertEqual(fresh_attachments[0].content, binary_file_content) # Test raw call to service self.assertEqual( list(GetAttachment(account=item.account).call( items=[att1.attachment_id], include_mime_content=False) )[0].find('{%s}Content' % TNS).text, 'SGVsbG8gZnJvbSB1bmljb2RlIMOmw7jDpQ==') # Test attach on saved object att2 = FileAttachment(name='my_file_2.txt', content=binary_file_content) self.assertEqual(len(item.attachments), 1) item.attach(att2) self.assertEqual(len(item.attachments), 2) fresh_item = list(self.account.fetch(ids=[item]))[0] self.assertEqual(len(fresh_item.attachments), 2) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) self.assertEqual(fresh_attachments[0].name, 'my_file_1.txt') self.assertEqual(fresh_attachments[0].content, binary_file_content) self.assertEqual(fresh_attachments[1].name, 'my_file_2.txt') self.assertEqual(fresh_attachments[1].content, binary_file_content) # Test detach item.detach(att1) self.assertTrue(att1.attachment_id is None) self.assertTrue(att1.parent_item is None) fresh_item = list(self.account.fetch(ids=[item]))[0] self.assertEqual(len(fresh_item.attachments), 1) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) self.assertEqual(fresh_attachments[0].name, 'my_file_2.txt') self.assertEqual(fresh_attachments[0].content, binary_file_content) def test_streaming_file_attachments(self): item = self.get_test_item(folder=self.test_folder) large_binary_file_content = get_random_string(2**10).encode('utf-8') large_att = FileAttachment(name='my_large_file.txt', content=large_binary_file_content) item.attach(large_att) item.save() # Test streaming file content fresh_item = list(self.account.fetch(ids=[item]))[0] with fresh_item.attachments[0].fp as fp: self.assertEqual(fp.read(), large_binary_file_content) # Test partial reads of streaming file content fresh_item = list(self.account.fetch(ids=[item]))[0] with fresh_item.attachments[0].fp as fp: chunked_reads = [] buffer = fp.read(7) while buffer: chunked_reads.append(buffer) buffer = fp.read(7) self.assertListEqual(chunked_reads, list(chunkify(large_binary_file_content, 7))) def test_streaming_file_attachment_error(self): # Test that we can parse XML error responses in streaming mode. # Try to stram an attachment with malformed ID att = FileAttachment( parent_item=self.get_test_item(folder=self.test_folder), attachment_id=AttachmentId(id='AAMk='), name='dummy.txt', content=b'', ) with self.assertRaises(ErrorInvalidIdMalformed): with att.fp as fp: fp.read() # Try to stream a non-existent attachment att.attachment_id.id = \ 'AAMkADQyYzZmYmUxLTJiYjItNDg2Ny1iMzNjLTIzYWE1NDgxNmZhNABGAAAAAADUebQDarW2Q7G2Ji8hKofPBwAl9iKCsfCfS' \ 'a9cmjh+JCrCAAPJcuhjAABioKiOUTCQRI6Q5sRzi0pJAAHnDV3CAAABEgAQAN0zlxDrzlxAteU+kt84qOM=' with self.assertRaises(ErrorItemNotFound): with att.fp as fp: fp.read() def test_empty_file_attachment(self): item = self.get_test_item(folder=self.test_folder) att1 = FileAttachment(name='empty_file.txt', content=b'') item.attach(att1) item.save() fresh_item = list(self.account.fetch(ids=[item]))[0] self.assertEqual( fresh_item.attachments[0].content, b'' ) def test_both_attachment_types(self): item = self.get_test_item(folder=self.test_folder) attached_item = self.get_test_item(folder=self.test_folder).save() item_attachment = ItemAttachment(name='item_attachment', item=attached_item) file_attachment = FileAttachment(name='file_attachment', content=b'file_attachment') item.attach(item_attachment) item.attach(file_attachment) item.save() fresh_item = list(self.account.fetch(ids=[item]))[0] self.assertSetEqual( {a.name for a in fresh_item.attachments}, {'item_attachment', 'file_attachment'} ) def test_recursive_attachments(self): # Test that we can handle an item which has an attached item, which has an attached item... item = self.get_test_item(folder=self.test_folder) attached_item_level_1 = self.get_test_item(folder=self.test_folder) attached_item_level_2 = self.get_test_item(folder=self.test_folder) attached_item_level_3 = self.get_test_item(folder=self.test_folder) attached_item_level_3.save() attachment_level_3 = ItemAttachment(name='attached_item_level_3', item=attached_item_level_3) attached_item_level_2.attach(attachment_level_3) attached_item_level_2.save() attachment_level_2 = ItemAttachment(name='attached_item_level_2', item=attached_item_level_2) attached_item_level_1.attach(attachment_level_2) attached_item_level_1.save() attachment_level_1 = ItemAttachment(name='attached_item_level_1', item=attached_item_level_1) item.attach(attachment_level_1) item.save() self.assertEqual( item.attachments[0].item.attachments[0].item.attachments[0].item.subject, attached_item_level_3.subject ) # Also test a fresh item new_item = self.test_folder.get(id=item.id, changekey=item.changekey) self.assertEqual( new_item.attachments[0].item.attachments[0].item.attachments[0].item.subject, attached_item_level_3.subject ) def test_detach_all(self): # Make sure that we can detach all by passing item.attachments item = self.get_test_item(folder=self.test_folder).save() item.attach([FileAttachment(name='empty_file.txt', content=b'') for _ in range(6)]) self.assertEqual(len(item.attachments), 6) item.detach(item.attachments) self.assertEqual(len(item.attachments), 0) def test_detach_with_refresh(self): # Make sure that we can detach after refresh item = self.get_test_item(folder=self.test_folder).save() item.attach(FileAttachment(name='empty_file.txt', content=b'')) item.refresh() item.detach(item.attachments) exchangelib-3.1.1/tests/test_autodiscover.py000066400000000000000000000642751361226005600212660ustar00rootroot00000000000000from collections import namedtuple import glob from types import MethodType import dns import requests_mock from exchangelib import DELEGATE import exchangelib.autodiscover.discovery from exchangelib import Credentials, NTLM, FailFast, Configuration, Account from exchangelib.autodiscover import close_connections, clear_cache, autodiscover_cache, AutodiscoverProtocol, \ Autodiscovery from exchangelib.autodiscover.properties import Autodiscover from exchangelib.errors import ErrorNonExistentMailbox, AutoDiscoverCircularRedirect, AutoDiscoverFailed from exchangelib.protocol import FaultTolerance from exchangelib.util import get_domain from .common import EWSTest class AutodiscoverTest(EWSTest): def setUp(self): super().setUp() # Enable retries, to make tests more robust Autodiscovery.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=30) Autodiscovery.RETRY_WAIT = 5 # Each test should start with a clean autodiscover cache clear_cache() # Some mocking helpers self.domain = get_domain(self.account.primary_smtp_address) self.dummy_ad_endpoint = 'https://%s/Autodiscover/Autodiscover.xml' % self.domain self.dummy_ews_endpoint = 'https://expr.example.com/EWS/Exchange.asmx' self.dummy_ad_response = b'''\ %s email settings EXPR %s ''' % (self.account.primary_smtp_address.encode(), self.dummy_ews_endpoint.encode()) self.dummy_ews_response = b'''\ NoError ''' @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_magic(self, m): # Just test we don't fail when calling repr() and str(). Insert a dummy cache entry for testing clear_cache() c = Credentials('leet_user', 'cannaguess') autodiscover_cache[('example.com', c)] = AutodiscoverProtocol(config=Configuration( service_endpoint='https://example.com/Autodiscover/Autodiscover.xml', credentials=c, auth_type=NTLM, retry_policy=FailFast(), )) self.assertEqual(len(autodiscover_cache), 1) str(autodiscover_cache) repr(autodiscover_cache) for protocol in autodiscover_cache._protocols.values(): str(protocol) repr(protocol) def test_autodiscover_empty_cache(self): # A live test of the entire process with an empty cache clear_cache() ad_response, protocol = exchangelib.autodiscover.discovery.discover( email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ) self.assertEqual(ad_response.autodiscover_smtp_address, self.account.primary_smtp_address) self.assertEqual(protocol.service_endpoint.lower(), self.account.protocol.service_endpoint.lower()) self.assertEqual(protocol.version.build, self.account.protocol.version.build) def test_autodiscover_failure(self): # A live test that errors can be raised. Here, we try to aútodiscover a non-existing email address if not self.settings.get('autodiscover_server'): self.skipTest("Skipping %s - no 'autodiscover_server' entry in settings.yml" % self.__class__.__name__) # Autodiscovery may take a long time. Prime the cache with the autodiscover server from the config file ad_endpoint = 'https://%s/Autodiscover/Autodiscover.xml' % self.settings['autodiscover_server'] cache_key = (self.domain, self.account.protocol.credentials) autodiscover_cache[cache_key] = AutodiscoverProtocol(config=Configuration( service_endpoint=ad_endpoint, credentials=self.account.protocol.credentials, auth_type=NTLM, retry_policy=self.retry_policy, )) with self.assertRaises(ErrorNonExistentMailbox): exchangelib.autodiscover.discovery.discover( email='XXX.' + self.account.primary_smtp_address, credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ) def test_failed_login_via_account(self): Autodiscovery.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=10) clear_cache() with self.assertRaises(AutoDiscoverFailed): Account( primary_smtp_address=self.account.primary_smtp_address, access_type=DELEGATE, credentials=Credentials(self.account.protocol.credentials.username, 'WRONG_PASSWORD'), autodiscover=True, locale='da_DK', ) @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_close_autodiscover_connections(self, m): # A live test that we can close TCP connections clear_cache() c = Credentials('leet_user', 'cannaguess') autodiscover_cache[('example.com', c)] = AutodiscoverProtocol(config=Configuration( service_endpoint='https://example.com/Autodiscover/Autodiscover.xml', credentials=c, auth_type=NTLM, retry_policy=FailFast(), )) self.assertEqual(len(autodiscover_cache), 1) close_connections() @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_autodiscover_direct_gc(self, m): # Test garbage collection of the autodiscover cache clear_cache() c = Credentials('leet_user', 'cannaguess') autodiscover_cache[('example.com', c)] = AutodiscoverProtocol(config=Configuration( service_endpoint='https://example.com/Autodiscover/Autodiscover.xml', credentials=c, auth_type=NTLM, retry_policy=FailFast(), )) self.assertEqual(len(autodiscover_cache), 1) autodiscover_cache.__del__() @requests_mock.mock(real_http=False) def test_autodiscover_cache(self, m): # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery m.post(self.dummy_ews_endpoint, status_code=200) discovery = Autodiscovery( email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ) # Not cached self.assertNotIn(discovery._cache_key, autodiscover_cache) discovery.discover() # Now it's cached self.assertIn(discovery._cache_key, autodiscover_cache) # Make sure the cache can be looked by value, not by id(). This is important for multi-threading/processing self.assertIn(( self.account.primary_smtp_address.split('@')[1], Credentials(self.account.protocol.credentials.username, self.account.protocol.credentials.password), True ), autodiscover_cache) # Poison the cache with a failing autodiscover endpoint. discover() must handle this and rebuild the cache autodiscover_cache[discovery._cache_key] = AutodiscoverProtocol(config=Configuration( service_endpoint='https://example.com/Autodiscover/Autodiscover.xml', credentials=Credentials('leet_user', 'cannaguess'), auth_type=NTLM, retry_policy=FailFast(), )) m.post('https://example.com/Autodiscover/Autodiscover.xml', status_code=404) discovery.discover() self.assertIn(discovery._cache_key, autodiscover_cache) # Make sure that the cache is actually used on the second call to discover() _orig = discovery._step_1 def _mock(slf, *args, **kwargs): raise NotImplementedError() discovery._step_1 = MethodType(_mock, discovery) discovery.discover() # Fake that another thread added the cache entry into the persistent storage but we don't have it in our # in-memory cache. The cache should work anyway. autodiscover_cache._protocols.clear() discovery.discover() discovery._step_1 = _orig # Make sure we can delete cache entries even though we don't have it in our in-memory cache autodiscover_cache._protocols.clear() del autodiscover_cache[discovery._cache_key] # This should also work if the cache does not contain the entry anymore del autodiscover_cache[discovery._cache_key] @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_corrupt_autodiscover_cache(self, m): # Insert a fake Protocol instance into the cache and test that we can recover key = (2, 'foo', 4) autodiscover_cache[key] = namedtuple('P', ['service_endpoint', 'auth_type', 'retry_policy'])(1, 'bar', 'baz') # Check that it exists. 'in' goes directly to the file self.assertTrue(key in autodiscover_cache) # Destroy the backing cache file(s) for db_file in glob.glob(autodiscover_cache._storage_file + '*'): with open(db_file, 'w') as f: f.write('XXX') # Check that we can recover from a destroyed file and that the entry no longer exists self.assertFalse(key in autodiscover_cache) @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_autodiscover_from_account(self, m): # Test that autodiscovery via account creation works clear_cache() # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery m.post(self.dummy_ews_endpoint, status_code=200, content=self.dummy_ews_response) self.assertEqual(len(autodiscover_cache), 0) account = Account( primary_smtp_address=self.account.primary_smtp_address, config=Configuration( credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ), autodiscover=True, locale='da_DK', ) self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) self.assertEqual(account.protocol.service_endpoint.lower(), self.dummy_ews_endpoint.lower()) # Make sure cache is full self.assertEqual(len(autodiscover_cache), 1) self.assertTrue((account.domain, self.account.protocol.credentials, True) in autodiscover_cache) # Test that autodiscover works with a full cache account = Account( primary_smtp_address=self.account.primary_smtp_address, config=Configuration( credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ), autodiscover=True, locale='da_DK', ) self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) # Test cache manipulation key = (account.domain, self.account.protocol.credentials, True) self.assertTrue(key in autodiscover_cache) del autodiscover_cache[key] self.assertFalse(key in autodiscover_cache) @requests_mock.mock(real_http=False) def test_autodiscover_redirect(self, m): # Test various aspects of autodiscover redirection. Mock all HTTP responses because we can't force a live server # to send us into the correct code paths. clear_cache() # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery m.post(self.dummy_ews_endpoint, status_code=200) discovery = Autodiscovery( email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ) discovery.discover() # Make sure we discover a different return address m.post(self.dummy_ad_endpoint, status_code=200, content=b'''\ john@example.com email settings EXPR https://expr.example.com/EWS/Exchange.asmx ''') # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery m.post('https://expr.example.com/EWS/Exchange.asmx', status_code=200) ad_response, p = discovery.discover() self.assertEqual(ad_response.autodiscover_smtp_address, 'john@example.com') # Make sure we discover an address redirect to the same domain. We have to mock the same URL with two different # responses. We do that with a response list. redirect_addr_content = b'''\ redirectAddr redirect_me@%s ''' % self.domain.encode() settings_content = b'''\ redirected@%s email settings EXPR https://redirected.%s/EWS/Exchange.asmx ''' % (self.domain.encode(), self.domain.encode()) # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery m.post('https://redirected.%s/EWS/Exchange.asmx' % self.domain, status_code=200) m.post(self.dummy_ad_endpoint, [ dict(status_code=200, content=redirect_addr_content), dict(status_code=200, content=settings_content), ]) ad_response, p = discovery.discover() self.assertEqual(ad_response.autodiscover_smtp_address, 'redirected@%s' % self.domain) self.assertEqual(ad_response.protocol.ews_url, 'https://redirected.%s/EWS/Exchange.asmx' % self.domain) # Test that we catch circular redirects on the same domain with a primed cache. Just mock the endpoint to # return the same redirect response on every request. self.assertEqual(len(autodiscover_cache), 1) m.post(self.dummy_ad_endpoint, status_code=200, content=b'''\ redirectAddr foo@%s ''' % self.domain.encode()) self.assertEqual(len(autodiscover_cache), 1) with self.assertRaises(AutoDiscoverCircularRedirect): discovery.discover() # Test that we also catch circular redirects when cache is empty clear_cache() self.assertEqual(len(autodiscover_cache), 0) with self.assertRaises(AutoDiscoverCircularRedirect): discovery.discover() # Test that we can handle being asked to redirect to an address on a different domain m.post(self.dummy_ad_endpoint, status_code=200, content=b'''\ redirectAddr john@example.com ''') m.post('https://example.com/Autodiscover/Autodiscover.xml', status_code=200, content=b'''\ john@redirected.example.com email settings EXPR https://redirected.example.com/EWS/Exchange.asmx ''') # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery m.post('https://redirected.example.com/EWS/Exchange.asmx', status_code=200) ad_response, p = discovery.discover() self.assertEqual(ad_response.autodiscover_smtp_address, 'john@redirected.example.com') self.assertEqual(ad_response.protocol.ews_url, 'https://redirected.example.com/EWS/Exchange.asmx') def test_get_srv_records(self): from exchangelib.autodiscover.discovery import _get_srv_records, SrvRecord # Unknown domain self.assertEqual(_get_srv_records('example.XXXXX'), []) # No SRV record self.assertEqual(_get_srv_records('example.com'), []) # Finding a real server that has a correct SRV record is not easy. Mock it _orig = dns.resolver.Resolver class _Mock1: def query(self, hostname, cat): class A: def to_text(self): # Return a valid record return '1 2 3 example.com.' return [A()] dns.resolver.Resolver = _Mock1 # Test a valid record self.assertEqual(_get_srv_records('example.com.'), [SrvRecord(priority=1, weight=2, port=3, srv='example.com')]) class _Mock2: def query(self, hostname, cat): class A: def to_text(self): # Return malformed data return 'XXXXXXX' return [A()] dns.resolver.Resolver = _Mock2 # Test an invalid record self.assertEqual(_get_srv_records('example.com'), []) dns.resolver.Resolver = _orig def test_select_srv_host(self): from exchangelib.autodiscover.discovery import _select_srv_host, SrvRecord with self.assertRaises(ValueError): # Empty list _select_srv_host([]) with self.assertRaises(ValueError): # No records with TLS port _select_srv_host([SrvRecord(priority=1, weight=2, port=3, srv='example.com')]) # One record self.assertEqual( _select_srv_host([SrvRecord(priority=1, weight=2, port=443, srv='example.com')]), 'example.com' ) # Highest priority record self.assertEqual( _select_srv_host([ SrvRecord(priority=10, weight=2, port=443, srv='10.example.com'), SrvRecord(priority=1, weight=2, port=443, srv='1.example.com'), ]), '10.example.com' ) # Highest priority record no matter how it's sorted self.assertEqual( _select_srv_host([ SrvRecord(priority=1, weight=2, port=443, srv='1.example.com'), SrvRecord(priority=10, weight=2, port=443, srv='10.example.com'), ]), '10.example.com' ) def test_parse_response(self): # Test parsing of various XML responses with self.assertRaises(ValueError): Autodiscover.from_bytes(b'XXX') # Invalid response xml = b'''bar''' with self.assertRaises(ValueError): Autodiscover.from_bytes(xml) # Invalid XML response # Redirect to different email address xml = b'''\ john@demo.affect-it.dk redirectAddr foo@example.com ''' self.assertEqual(Autodiscover.from_bytes(xml).response.redirect_address, 'foo@example.com') # Redirect to different URL xml = b'''\ john@demo.affect-it.dk redirectUrl https://example.com/foo.asmx ''' self.assertEqual(Autodiscover.from_bytes(xml).response.redirect_url, 'https://example.com/foo.asmx') # Select EXPR if it's there, and there are multiple available xml = b'''\ john@demo.affect-it.dk email settings EXCH https://exch.example.com/EWS/Exchange.asmx EXPR https://expr.example.com/EWS/Exchange.asmx ''' self.assertEqual( Autodiscover.from_bytes(xml).response.protocol.ews_url, 'https://expr.example.com/EWS/Exchange.asmx' ) # Select EXPR if EXPR is unavailable xml = b'''\ john@demo.affect-it.dk email settings EXCH https://exch.example.com/EWS/Exchange.asmx ''' self.assertEqual( Autodiscover.from_bytes(xml).response.protocol.ews_url, 'https://exch.example.com/EWS/Exchange.asmx' ) # Fail if neither EXPR nor EXPR are unavailable xml = b'''\ john@demo.affect-it.dk email settings XXX https://xxx.example.com/EWS/Exchange.asmx ''' with self.assertRaises(ValueError): Autodiscover.from_bytes(xml).response.protocol exchangelib-3.1.1/tests/test_build.py000066400000000000000000000034121361226005600176400ustar00rootroot00000000000000from exchangelib.version import Build from .common import TimedTestCase class BuildTest(TimedTestCase): def test_magic(self): with self.assertRaises(ValueError): Build(7, 0) self.assertEqual(str(Build(9, 8, 7, 6)), '9.8.7.6') def test_compare(self): self.assertEqual(Build(15, 0, 1, 2), Build(15, 0, 1, 2)) self.assertNotEqual(Build(15, 0, 1, 2), Build(15, 0, 1, 3)) self.assertLess(Build(15, 0, 1, 2), Build(15, 0, 1, 3)) self.assertLess(Build(15, 0, 1, 2), Build(15, 0, 2, 2)) self.assertLess(Build(15, 0, 1, 2), Build(15, 1, 1, 2)) self.assertLess(Build(15, 0, 1, 2), Build(16, 0, 1, 2)) self.assertLessEqual(Build(15, 0, 1, 2), Build(15, 0, 1, 2)) self.assertGreater(Build(15, 0, 1, 2), Build(15, 0, 1, 1)) self.assertGreater(Build(15, 0, 1, 2), Build(15, 0, 0, 2)) self.assertGreater(Build(15, 1, 1, 2), Build(15, 0, 1, 2)) self.assertGreater(Build(15, 0, 1, 2), Build(14, 0, 1, 2)) self.assertGreaterEqual(Build(15, 0, 1, 2), Build(15, 0, 1, 2)) def test_api_version(self): self.assertEqual(Build(8, 0).api_version(), 'Exchange2007') self.assertEqual(Build(8, 1).api_version(), 'Exchange2007_SP1') self.assertEqual(Build(8, 2).api_version(), 'Exchange2007_SP1') self.assertEqual(Build(8, 3).api_version(), 'Exchange2007_SP1') self.assertEqual(Build(15, 0, 1, 1).api_version(), 'Exchange2013') self.assertEqual(Build(15, 0, 1, 1).api_version(), 'Exchange2013') self.assertEqual(Build(15, 0, 847, 0).api_version(), 'Exchange2013_SP1') with self.assertRaises(ValueError): Build(16, 0).api_version() with self.assertRaises(ValueError): Build(15, 4).api_version() exchangelib-3.1.1/tests/test_configuration.py000066400000000000000000000070421361226005600214130ustar00rootroot00000000000000import datetime import math import time import requests_mock from exchangelib import Configuration, Credentials, NTLM, FailFast, FaultTolerance, Version, Build from exchangelib.transport import AUTH_TYPE_MAP from .common import TimedTestCase class ConfigurationTest(TimedTestCase): def test_init(self): with self.assertRaises(ValueError) as e: Configuration(credentials='foo') self.assertEqual(e.exception.args[0], "'credentials' 'foo' must be a Credentials instance") with self.assertRaises(AttributeError) as e: Configuration(server='foo', service_endpoint='bar') self.assertEqual(e.exception.args[0], "Only one of 'server' or 'service_endpoint' must be provided") with self.assertRaises(ValueError) as e: Configuration(auth_type='foo') self.assertEqual( e.exception.args[0], "'auth_type' 'foo' must be one of %s" % ', '.join("'%s'" % k for k in sorted(AUTH_TYPE_MAP.keys())) ) with self.assertRaises(ValueError) as e: Configuration(version='foo') self.assertEqual(e.exception.args[0], "'version' 'foo' must be a Version instance") with self.assertRaises(ValueError) as e: Configuration(retry_policy='foo') self.assertEqual(e.exception.args[0], "'retry_policy' 'foo' must be a RetryPolicy instance") def test_magic(self): config = Configuration( server='example.com', credentials=Credentials('foo', 'bar'), auth_type=NTLM, version=Version(build=Build(15, 1, 2, 3), api_version='foo'), ) # Just test that these work str(config) repr(config) @requests_mock.mock() # Just to make sure we don't make any requests def test_hardcode_all(self, m): # Test that we can hardcode everything without having a working server. This is useful if neither tasting or # guessing missing values works. Configuration( server='example.com', credentials=Credentials('foo', 'bar'), auth_type=NTLM, version=Version(build=Build(15, 1, 2, 3), api_version='foo'), ) def test_fail_fast_back_off(self): # Test that FailFast does not support back-off logic c = FailFast() self.assertIsNone(c.back_off_until) with self.assertRaises(AttributeError): c.back_off_until = 1 def test_service_account_back_off(self): # Test back-off logic in FaultTolerance sa = FaultTolerance() # Initially, the value is None self.assertIsNone(sa.back_off_until) # Test a non-expired back off value in_a_while = datetime.datetime.now() + datetime.timedelta(seconds=10) sa.back_off_until = in_a_while self.assertEqual(sa.back_off_until, in_a_while) # Test an expired back off value sa.back_off_until = datetime.datetime.now() time.sleep(0.001) self.assertIsNone(sa.back_off_until) # Test the back_off() helper sa.back_off(10) # This is not a precise test. Assuming fast computers, there should be less than 1 second between the two lines. self.assertEqual(int(math.ceil((sa.back_off_until - datetime.datetime.now()).total_seconds())), 10) # Test expiry sa.back_off(0) time.sleep(0.001) self.assertIsNone(sa.back_off_until) # Test default value sa.back_off(None) self.assertEqual(int(math.ceil((sa.back_off_until - datetime.datetime.now()).total_seconds())), 60) exchangelib-3.1.1/tests/test_credentials.py000066400000000000000000000016711361226005600210430ustar00rootroot00000000000000from exchangelib import Credentials from .common import TimedTestCase class CredentialsTest(TimedTestCase): def test_hash(self): # Test that we can use credentials as a dict key self.assertEqual(hash(Credentials('a', 'b')), hash(Credentials('a', 'b'))) self.assertNotEqual(hash(Credentials('a', 'b')), hash(Credentials('a', 'a'))) self.assertNotEqual(hash(Credentials('a', 'b')), hash(Credentials('b', 'b'))) def test_equality(self): self.assertEqual(Credentials('a', 'b'), Credentials('a', 'b')) self.assertNotEqual(Credentials('a', 'b'), Credentials('a', 'a')) self.assertNotEqual(Credentials('a', 'b'), Credentials('b', 'b')) def test_type(self): self.assertEqual(Credentials('a', 'b').type, Credentials.UPN) self.assertEqual(Credentials('a@example.com', 'b').type, Credentials.EMAIL) self.assertEqual(Credentials('a\\n', 'b').type, Credentials.DOMAIN) exchangelib-3.1.1/tests/test_ewsdatetime.py000066400000000000000000000223131361226005600210550ustar00rootroot00000000000000import datetime import pytz import requests_mock from exchangelib import EWSDateTime, EWSDate, EWSTimeZone, UTC from exchangelib.errors import NonExistentTimeError, AmbiguousTimeError, UnknownTimeZone, NaiveDateTimeNotAllowed from exchangelib.winzone import generate_map, CLDR_TO_MS_TIMEZONE_MAP, CLDR_WINZONE_URL from exchangelib.util import CONNECTION_ERRORS from .common import TimedTestCase class EWSDateTimeTest(TimedTestCase): def test_super_methods(self): tz = EWSTimeZone.timezone('Europe/Copenhagen') self.assertIsInstance(EWSDateTime.now(), EWSDateTime) self.assertIsInstance(EWSDateTime.now(tz=tz), EWSDateTime) self.assertIsInstance(EWSDateTime.utcnow(), EWSDateTime) self.assertIsInstance(EWSDateTime.fromtimestamp(123456789), EWSDateTime) self.assertIsInstance(EWSDateTime.fromtimestamp(123456789, tz=tz), EWSDateTime) self.assertIsInstance(EWSDateTime.utcfromtimestamp(123456789), EWSDateTime) def test_ewstimezone(self): # Test autogenerated translations tz = EWSTimeZone.timezone('Europe/Copenhagen') self.assertIsInstance(tz, EWSTimeZone) self.assertEqual(tz.zone, 'Europe/Copenhagen') self.assertEqual(tz.ms_id, 'Romance Standard Time') # self.assertEqual(EWSTimeZone.timezone('Europe/Copenhagen').ms_name, '') # EWS works fine without the ms_name # Test localzone() tz = EWSTimeZone.localzone() self.assertIsInstance(tz, EWSTimeZone) # Test common helpers tz = EWSTimeZone.timezone('UTC') self.assertIsInstance(tz, EWSTimeZone) self.assertEqual(tz.zone, 'UTC') self.assertEqual(tz.ms_id, 'UTC') tz = EWSTimeZone.timezone('GMT') self.assertIsInstance(tz, EWSTimeZone) self.assertEqual(tz.zone, 'GMT') self.assertEqual(tz.ms_id, 'UTC') # Test mapper contents. Latest map from unicode.org has 394 entries self.assertGreater(len(EWSTimeZone.PYTZ_TO_MS_MAP), 300) for k, v in EWSTimeZone.PYTZ_TO_MS_MAP.items(): self.assertIsInstance(k, str) self.assertIsInstance(v, tuple) self.assertEqual(len(v), 2) self.assertIsInstance(v[0], str) # Test timezone unknown by pytz with self.assertRaises(UnknownTimeZone): EWSTimeZone.timezone('UNKNOWN') # Test timezone known by pytz but with no Winzone mapping tz = pytz.timezone('Africa/Tripoli') # This hack smashes the pytz timezone cache. Don't reuse the original timezone name for other tests tz.zone = 'UNKNOWN' with self.assertRaises(UnknownTimeZone): EWSTimeZone.from_pytz(tz) # Test __eq__ with non-EWSTimeZone compare self.assertFalse(EWSTimeZone.timezone('GMT') == pytz.utc) # Test from_ms_id() with non-standard MS ID self.assertEqual(EWSTimeZone.timezone('Europe/Copenhagen'), EWSTimeZone.from_ms_id('Europe/Copenhagen')) def test_localize(self): # Test some cornercases around DST tz = EWSTimeZone.timezone('Europe/Copenhagen') self.assertEqual( str(tz.localize(EWSDateTime(2023, 10, 29, 2, 36, 0))), '2023-10-29 02:36:00+01:00' ) with self.assertRaises(AmbiguousTimeError): tz.localize(EWSDateTime(2023, 10, 29, 2, 36, 0), is_dst=None) self.assertEqual( str(tz.localize(EWSDateTime(2023, 10, 29, 2, 36, 0), is_dst=True)), '2023-10-29 02:36:00+02:00' ) self.assertEqual( str(tz.localize(EWSDateTime(2023, 3, 26, 2, 36, 0))), '2023-03-26 02:36:00+01:00' ) with self.assertRaises(NonExistentTimeError): tz.localize(EWSDateTime(2023, 3, 26, 2, 36, 0), is_dst=None) self.assertEqual( str(tz.localize(EWSDateTime(2023, 3, 26, 2, 36, 0), is_dst=True)), '2023-03-26 02:36:00+02:00' ) def test_ewsdatetime(self): # Test a static timezone tz = EWSTimeZone.timezone('Etc/GMT-5') dt = tz.localize(EWSDateTime(2000, 1, 2, 3, 4, 5)) self.assertIsInstance(dt, EWSDateTime) self.assertIsInstance(dt.tzinfo, EWSTimeZone) self.assertEqual(dt.tzinfo.ms_id, tz.ms_id) self.assertEqual(dt.tzinfo.ms_name, tz.ms_name) self.assertEqual(str(dt), '2000-01-02 03:04:05+05:00') self.assertEqual( repr(dt), "EWSDateTime(2000, 1, 2, 3, 4, 5, tzinfo=)" ) # Test a DST timezone tz = EWSTimeZone.timezone('Europe/Copenhagen') dt = tz.localize(EWSDateTime(2000, 1, 2, 3, 4, 5)) self.assertIsInstance(dt, EWSDateTime) self.assertIsInstance(dt.tzinfo, EWSTimeZone) self.assertEqual(dt.tzinfo.ms_id, tz.ms_id) self.assertEqual(dt.tzinfo.ms_name, tz.ms_name) self.assertEqual(str(dt), '2000-01-02 03:04:05+01:00') self.assertEqual( repr(dt), "EWSDateTime(2000, 1, 2, 3, 4, 5, tzinfo=)" ) # Test from_string with self.assertRaises(NaiveDateTimeNotAllowed): EWSDateTime.from_string('2000-01-02T03:04:05') self.assertEqual( EWSDateTime.from_string('2000-01-02T03:04:05+01:00'), UTC.localize(EWSDateTime(2000, 1, 2, 2, 4, 5)) ) self.assertEqual( EWSDateTime.from_string('2000-01-02T03:04:05Z'), UTC.localize(EWSDateTime(2000, 1, 2, 3, 4, 5)) ) self.assertIsInstance(EWSDateTime.from_string('2000-01-02T03:04:05+01:00'), EWSDateTime) self.assertIsInstance(EWSDateTime.from_string('2000-01-02T03:04:05Z'), EWSDateTime) # Test addition, subtraction, summertime etc self.assertIsInstance(dt + datetime.timedelta(days=1), EWSDateTime) self.assertIsInstance(dt - datetime.timedelta(days=1), EWSDateTime) self.assertIsInstance(dt - EWSDateTime.now(tz=tz), datetime.timedelta) self.assertIsInstance(EWSDateTime.now(tz=tz), EWSDateTime) self.assertEqual(dt, EWSDateTime.from_datetime(tz.localize(datetime.datetime(2000, 1, 2, 3, 4, 5)))) self.assertEqual(dt.ewsformat(), '2000-01-02T03:04:05+01:00') utc_tz = EWSTimeZone.timezone('UTC') self.assertEqual(dt.astimezone(utc_tz).ewsformat(), '2000-01-02T02:04:05Z') # Test summertime dt = tz.localize(EWSDateTime(2000, 8, 2, 3, 4, 5)) self.assertEqual(dt.astimezone(utc_tz).ewsformat(), '2000-08-02T01:04:05Z') # Test normalize, for completeness self.assertEqual(tz.normalize(dt).ewsformat(), '2000-08-02T03:04:05+02:00') self.assertEqual(utc_tz.normalize(dt, is_dst=True).ewsformat(), '2000-08-02T01:04:05Z') # Test in-place add and subtract dt = tz.localize(EWSDateTime(2000, 1, 2, 3, 4, 5)) dt += datetime.timedelta(days=1) self.assertIsInstance(dt, EWSDateTime) self.assertEqual(dt, tz.localize(EWSDateTime(2000, 1, 3, 3, 4, 5))) dt = tz.localize(EWSDateTime(2000, 1, 2, 3, 4, 5)) dt -= datetime.timedelta(days=1) self.assertIsInstance(dt, EWSDateTime) self.assertEqual(dt, tz.localize(EWSDateTime(2000, 1, 1, 3, 4, 5))) # Test ewsformat() failure dt = EWSDateTime(2000, 1, 2, 3, 4, 5) with self.assertRaises(ValueError): dt.ewsformat() # Test wrong tzinfo type with self.assertRaises(ValueError): EWSDateTime(2000, 1, 2, 3, 4, 5, tzinfo=pytz.utc) with self.assertRaises(ValueError): EWSDateTime.from_datetime(EWSDateTime(2000, 1, 2, 3, 4, 5)) def test_generate(self): try: self.assertDictEqual(generate_map(), CLDR_TO_MS_TIMEZONE_MAP) except CONNECTION_ERRORS: # generate_map() requires access to unicode.org, which may be unavailable. Don't fail test, since this is # out of our control. pass @requests_mock.mock() def test_generate_failure(self, m): m.get(CLDR_WINZONE_URL, status_code=500) with self.assertRaises(ValueError): generate_map() def test_ewsdate(self): self.assertEqual(EWSDate(2000, 1, 1).ewsformat(), '2000-01-01') self.assertEqual(EWSDate.from_string('2000-01-01'), EWSDate(2000, 1, 1)) self.assertEqual(EWSDate.from_string('2000-01-01Z'), EWSDate(2000, 1, 1)) self.assertEqual(EWSDate.from_string('2000-01-01+01:00'), EWSDate(2000, 1, 1)) self.assertEqual(EWSDate.from_string('2000-01-01-01:00'), EWSDate(2000, 1, 1)) self.assertIsInstance(EWSDate(2000, 1, 2) - EWSDate(2000, 1, 1), datetime.timedelta) self.assertIsInstance(EWSDate(2000, 1, 2) + datetime.timedelta(days=1), EWSDate) self.assertIsInstance(EWSDate(2000, 1, 2) - datetime.timedelta(days=1), EWSDate) # Test in-place add and subtract dt = EWSDate(2000, 1, 2) dt += datetime.timedelta(days=1) self.assertIsInstance(dt, EWSDate) self.assertEqual(dt, EWSDate(2000, 1, 3)) dt = EWSDate(2000, 1, 2) dt -= datetime.timedelta(days=1) self.assertIsInstance(dt, EWSDate) self.assertEqual(dt, EWSDate(2000, 1, 1)) with self.assertRaises(ValueError): EWSDate.from_date(EWSDate(2000, 1, 2)) exchangelib-3.1.1/tests/test_extended_properties.py000066400000000000000000000264701361226005600226260ustar00rootroot00000000000000from exchangelib import Message, Mailbox, CalendarItem from exchangelib.extended_properties import ExtendedProperty from exchangelib.folders import Inbox from .common import get_random_int from .test_items import BaseItemTest class ExtendedPropertyTest(BaseItemTest): TEST_FOLDER = 'inbox' FOLDER_CLASS = Inbox ITEM_CLASS = Message def test_register(self): # Tests that we can register and de-register custom extended properties class TestProp(ExtendedProperty): property_set_id = 'deadbeaf-cafe-cafe-cafe-deadbeefcafe' property_name = 'Test Property' property_type = 'Integer' attr_name = 'dead_beef' # Before register self.assertNotIn(attr_name, {f.name for f in self.ITEM_CLASS.supported_fields(self.account.version)}) with self.assertRaises(ValueError): self.ITEM_CLASS.deregister(attr_name) # Not registered yet with self.assertRaises(ValueError): self.ITEM_CLASS.deregister('subject') # Not an extended property self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=TestProp) try: # After register self.assertEqual(TestProp.python_type(), int) self.assertIn(attr_name, {f.name for f in self.ITEM_CLASS.supported_fields(self.account.version)}) # Test item creation, refresh, and update item = self.get_test_item(folder=self.test_folder) prop_val = item.dead_beef self.assertTrue(isinstance(prop_val, int)) item.save() item.refresh() self.assertEqual(prop_val, item.dead_beef) new_prop_val = get_random_int(0, 256) item.dead_beef = new_prop_val item.save() item.refresh() self.assertEqual(new_prop_val, item.dead_beef) # Test deregister with self.assertRaises(ValueError): self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=TestProp) # Already registered with self.assertRaises(ValueError): self.ITEM_CLASS.register(attr_name='XXX', attr_cls=Mailbox) # Not an extended property finally: self.ITEM_CLASS.deregister(attr_name=attr_name) self.assertNotIn(attr_name, {f.name for f in self.ITEM_CLASS.supported_fields(self.account.version)}) def test_extended_property_arraytype(self): # Tests array type extended properties class TestArayProp(ExtendedProperty): property_set_id = 'deadcafe-beef-beef-beef-deadcafebeef' property_name = 'Test Array Property' property_type = 'IntegerArray' attr_name = 'dead_beef_array' self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=TestArayProp) try: # Test item creation, refresh, and update item = self.get_test_item(folder=self.test_folder) prop_val = item.dead_beef_array self.assertTrue(isinstance(prop_val, list)) item.save() item.refresh() self.assertEqual(prop_val, item.dead_beef_array) new_prop_val = self.random_val(self.ITEM_CLASS.get_field_by_fieldname(attr_name)) item.dead_beef_array = new_prop_val item.save() item.refresh() self.assertEqual(new_prop_val, item.dead_beef_array) finally: self.ITEM_CLASS.deregister(attr_name=attr_name) def test_extended_property_with_tag(self): class Flag(ExtendedProperty): property_tag = 0x1090 property_type = 'Integer' attr_name = 'my_flag' self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=Flag) try: # Test item creation, refresh, and update item = self.get_test_item(folder=self.test_folder) prop_val = item.my_flag self.assertTrue(isinstance(prop_val, int)) item.save() item.refresh() self.assertEqual(prop_val, item.my_flag) new_prop_val = self.random_val(self.ITEM_CLASS.get_field_by_fieldname(attr_name)) item.my_flag = new_prop_val item.save() item.refresh() self.assertEqual(new_prop_val, item.my_flag) finally: self.ITEM_CLASS.deregister(attr_name=attr_name) def test_extended_property_with_invalid_tag(self): class InvalidProp(ExtendedProperty): property_tag = '0x8000' property_type = 'Integer' with self.assertRaises(ValueError): InvalidProp('Foo').clean() # property_tag is in protected range def test_extended_property_with_string_tag(self): class Flag(ExtendedProperty): property_tag = '0x1090' property_type = 'Integer' attr_name = 'my_flag' self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=Flag) try: # Test item creation, refresh, and update item = self.get_test_item(folder=self.test_folder) prop_val = item.my_flag self.assertTrue(isinstance(prop_val, int)) item.save() item.refresh() self.assertEqual(prop_val, item.my_flag) new_prop_val = self.random_val(self.ITEM_CLASS.get_field_by_fieldname(attr_name)) item.my_flag = new_prop_val item.save() item.refresh() self.assertEqual(new_prop_val, item.my_flag) finally: self.ITEM_CLASS.deregister(attr_name=attr_name) def test_extended_distinguished_property(self): if self.ITEM_CLASS == CalendarItem: # MyMeeting is an extended prop version of the 'CalendarItem.uid' field. They don't work together. raise self.skipTest("This extendedproperty doesn't work on CalendarItems") class MyMeeting(ExtendedProperty): distinguished_property_set_id = 'Meeting' property_type = 'Binary' property_id = 3 attr_name = 'my_meeting' self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=MyMeeting) try: # Test item creation, refresh, and update item = self.get_test_item(folder=self.test_folder) prop_val = item.my_meeting self.assertTrue(isinstance(prop_val, bytes)) item.save() item = list(self.account.fetch(ids=[(item.id, item.changekey)]))[0] self.assertEqual(prop_val, item.my_meeting, (prop_val, item.my_meeting)) new_prop_val = self.random_val(self.ITEM_CLASS.get_field_by_fieldname(attr_name)) item.my_meeting = new_prop_val item.save() item = list(self.account.fetch(ids=[(item.id, item.changekey)]))[0] self.assertEqual(new_prop_val, item.my_meeting) finally: self.ITEM_CLASS.deregister(attr_name=attr_name) def test_extended_property_binary_array(self): class MyMeetingArray(ExtendedProperty): property_set_id = '00062004-0000-0000-C000-000000000046' property_type = 'BinaryArray' property_id = 32852 attr_name = 'my_meeting_array' self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=MyMeetingArray) try: # Test item creation, refresh, and update item = self.get_test_item(folder=self.test_folder) prop_val = item.my_meeting_array self.assertTrue(isinstance(prop_val, list)) item.save() item = list(self.account.fetch(ids=[(item.id, item.changekey)]))[0] self.assertEqual(prop_val, item.my_meeting_array) new_prop_val = self.random_val(self.ITEM_CLASS.get_field_by_fieldname(attr_name)) item.my_meeting_array = new_prop_val item.save() item = list(self.account.fetch(ids=[(item.id, item.changekey)]))[0] self.assertEqual(new_prop_val, item.my_meeting_array) finally: self.ITEM_CLASS.deregister(attr_name=attr_name) def test_extended_property_validation(self): """ if cls.property_type not in cls.PROPERTY_TYPES: raise ValueError( "'property_type' value '%s' must be one of %s" % (cls.property_type, sorted(cls.PROPERTY_TYPES)) ) """ # Must not have property_set_id or property_tag class TestProp(ExtendedProperty): distinguished_property_set_id = 'XXX' property_set_id = 'YYY' with self.assertRaises(ValueError): TestProp.validate_cls() # Must have property_id or property_name class TestProp(ExtendedProperty): distinguished_property_set_id = 'XXX' with self.assertRaises(ValueError): TestProp.validate_cls() # distinguished_property_set_id must have a valid value class TestProp(ExtendedProperty): distinguished_property_set_id = 'XXX' property_id = 'YYY' with self.assertRaises(ValueError): TestProp.validate_cls() # Must not have distinguished_property_set_id or property_tag class TestProp(ExtendedProperty): property_set_id = 'XXX' property_tag = 'YYY' with self.assertRaises(ValueError): TestProp.validate_cls() # Must have property_id or property_name class TestProp(ExtendedProperty): property_set_id = 'XXX' with self.assertRaises(ValueError): TestProp.validate_cls() # property_tag is only compatible with property_type class TestProp(ExtendedProperty): property_tag = 'XXX' property_set_id = 'YYY' with self.assertRaises(ValueError): TestProp.validate_cls() # property_tag must be an integer or string that can be converted to int class TestProp(ExtendedProperty): property_tag = 'XXX' with self.assertRaises(ValueError): TestProp.validate_cls() # property_tag must not be in the reserved range class TestProp(ExtendedProperty): property_tag = 0x8001 with self.assertRaises(ValueError): TestProp.validate_cls() # Must not have property_id or property_tag class TestProp(ExtendedProperty): property_name = 'XXX' property_id = 'YYY' with self.assertRaises(ValueError): TestProp.validate_cls() # Must have distinguished_property_set_id or property_set_id class TestProp(ExtendedProperty): property_name = 'XXX' with self.assertRaises(ValueError): TestProp.validate_cls() # Must not have property_name or property_tag class TestProp(ExtendedProperty): property_id = 'XXX' property_name = 'YYY' with self.assertRaises(ValueError): TestProp.validate_cls() # This actually hits the check on property_name values # Must have distinguished_property_set_id or property_set_id class TestProp(ExtendedProperty): property_id = 'XXX' with self.assertRaises(ValueError): TestProp.validate_cls() # property_type must be a valid value class TestProp(ExtendedProperty): property_id = 'XXX' property_set_id = 'YYY' property_type = 'ZZZ' with self.assertRaises(ValueError): TestProp.validate_cls() exchangelib-3.1.1/tests/test_field.py000066400000000000000000000266631361226005600176410ustar00rootroot00000000000000from collections import namedtuple from decimal import Decimal from exchangelib import Version, EWSDateTime, EWSTimeZone, UTC from exchangelib.errors import ErrorInvalidServerVersion from exchangelib.extended_properties import ExternId from exchangelib.fields import BooleanField, IntegerField, DecimalField, TextField, ChoiceField, DateTimeField, \ Base64Field, TimeZoneField, ExtendedPropertyField, CharListField, Choice, DateField, EnumField, EnumListField, \ CharField from exchangelib.indexed_properties import SingleFieldIndexedElement from exchangelib.version import EXCHANGE_2007, EXCHANGE_2010, EXCHANGE_2013 from exchangelib.util import to_xml, TNS from .common import TimedTestCase class FieldTest(TimedTestCase): def test_value_validation(self): field = TextField('foo', field_uri='bar', is_required=True, default=None) with self.assertRaises(ValueError) as e: field.clean(None) # Must have a default value on None input self.assertEqual(str(e.exception), "'foo' is a required field with no default") field = TextField('foo', field_uri='bar', is_required=True, default='XXX') self.assertEqual(field.clean(None), 'XXX') field = CharListField('foo', field_uri='bar') with self.assertRaises(ValueError) as e: field.clean('XXX') # Must be a list type self.assertEqual(str(e.exception), "Field 'foo' value 'XXX' must be a list") field = CharListField('foo', field_uri='bar') with self.assertRaises(TypeError) as e: field.clean([1, 2, 3]) # List items must be correct type self.assertEqual(str(e.exception), "Field 'foo' value 1 must be of type ") field = CharField('foo', field_uri='bar') with self.assertRaises(TypeError) as e: field.clean(1) # Value must be correct type self.assertEqual(str(e.exception), "Field 'foo' value 1 must be of type ") with self.assertRaises(ValueError) as e: field.clean('X' * 256) # Value length must be within max_length self.assertEqual( str(e.exception), "'foo' value 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' exceeds length 255" ) field = DateTimeField('foo', field_uri='bar') with self.assertRaises(ValueError) as e: field.clean(EWSDateTime(2017, 1, 1)) # Datetime values must be timezone aware self.assertEqual(str(e.exception), "Value '2017-01-01 00:00:00' on field 'foo' must be timezone aware") field = ChoiceField('foo', field_uri='bar', choices=[Choice('foo'), Choice('bar')]) with self.assertRaises(ValueError) as e: field.clean('XXX') # Value must be a valid choice self.assertEqual(str(e.exception), "Invalid choice 'XXX' for field 'foo'. Valid choices are: foo, bar") # A few tests on extended properties that override base methods field = ExtendedPropertyField('foo', value_cls=ExternId, is_required=True) with self.assertRaises(ValueError) as e: field.clean(None) # Value is required self.assertEqual(str(e.exception), "'foo' is a required field") with self.assertRaises(TypeError) as e: field.clean(123) # Correct type is required self.assertEqual(str(e.exception), "'ExternId' value 123 must be an instance of ") self.assertEqual(field.clean('XXX'), 'XXX') # We can clean a simple value and keep it as a simple value self.assertEqual(field.clean(ExternId('XXX')), ExternId('XXX')) # We can clean an ExternId instance as well class ExternIdArray(ExternId): property_type = 'StringArray' field = ExtendedPropertyField('foo', value_cls=ExternIdArray, is_required=True) with self.assertRaises(ValueError)as e: field.clean(None) # Value is required self.assertEqual(str(e.exception), "'foo' is a required field") with self.assertRaises(ValueError)as e: field.clean(123) # Must be an iterable self.assertEqual(str(e.exception), "'ExternIdArray' value 123 must be a list") with self.assertRaises(TypeError) as e: field.clean([123]) # Correct type is required self.assertEqual(str(e.exception), "'ExternIdArray' value element 123 must be an instance of ") # Test min/max on IntegerField field = IntegerField('foo', field_uri='bar', min=5, max=10) with self.assertRaises(ValueError) as e: field.clean(2) self.assertEqual(str(e.exception), "Value 2 on field 'foo' must be greater than 5") with self.assertRaises(ValueError)as e: field.clean(12) self.assertEqual(str(e.exception), "Value 12 on field 'foo' must be less than 10") # Test min/max on DecimalField field = DecimalField('foo', field_uri='bar', min=5, max=10) with self.assertRaises(ValueError) as e: field.clean(Decimal(2)) self.assertEqual(str(e.exception), "Value Decimal('2') on field 'foo' must be greater than 5") with self.assertRaises(ValueError)as e: field.clean(Decimal(12)) self.assertEqual(str(e.exception), "Value Decimal('12') on field 'foo' must be less than 10") # Test enum validation field = EnumField('foo', field_uri='bar', enum=['a', 'b', 'c']) with self.assertRaises(ValueError)as e: field.clean(0) # Enums start at 1 self.assertEqual(str(e.exception), "Value 0 on field 'foo' must be greater than 1") with self.assertRaises(ValueError) as e: field.clean(4) # Spills over list self.assertEqual(str(e.exception), "Value 4 on field 'foo' must be less than 3") with self.assertRaises(ValueError) as e: field.clean('d') # Value not in enum self.assertEqual(str(e.exception), "Value 'd' on field 'foo' must be one of ['a', 'b', 'c']") # Test enum list validation field = EnumListField('foo', field_uri='bar', enum=['a', 'b', 'c']) with self.assertRaises(ValueError)as e: field.clean([]) self.assertEqual(str(e.exception), "Value '[]' on field 'foo' must not be empty") with self.assertRaises(ValueError) as e: field.clean([0]) self.assertEqual(str(e.exception), "Value 0 on field 'foo' must be greater than 1") with self.assertRaises(ValueError) as e: field.clean([1, 1]) # Values must be unique self.assertEqual(str(e.exception), "List entries '[1, 1]' on field 'foo' must be unique") with self.assertRaises(ValueError) as e: field.clean(['d']) self.assertEqual(str(e.exception), "List value 'd' on field 'foo' must be one of ['a', 'b', 'c']") def test_garbage_input(self): # Test that we can survive garbage input for common field types tz = EWSTimeZone.timezone('Europe/Copenhagen') account = namedtuple('Account', ['default_timezone'])(default_timezone=tz) payload = b'''\ THIS_IS_GARBAGE ''' elem = to_xml(payload).find('{%s}Item' % TNS) for field_cls in (Base64Field, BooleanField, IntegerField, DateField, DateTimeField, DecimalField): field = field_cls('foo', field_uri='item:Foo', is_required=True, default='DUMMY') self.assertEqual(field.from_xml(elem=elem, account=account), None) # Test MS timezones payload = b'''\ ''' elem = to_xml(payload).find('{%s}Item' % TNS) field = TimeZoneField('foo', field_uri='item:Foo', default='DUMMY') self.assertEqual(field.from_xml(elem=elem, account=account), None) def test_versioned_field(self): field = TextField('foo', field_uri='bar', supported_from=EXCHANGE_2010) with self.assertRaises(ErrorInvalidServerVersion): field.clean('baz', version=Version(EXCHANGE_2007)) field.clean('baz', version=Version(EXCHANGE_2010)) field.clean('baz', version=Version(EXCHANGE_2013)) def test_versioned_choice(self): field = ChoiceField('foo', field_uri='bar', choices={ Choice('c1'), Choice('c2', supported_from=EXCHANGE_2010) }) with self.assertRaises(ValueError): field.clean('XXX') # Value must be a valid choice field.clean('c2', version=None) with self.assertRaises(ErrorInvalidServerVersion): field.clean('c2', version=Version(EXCHANGE_2007)) field.clean('c2', version=Version(EXCHANGE_2010)) field.clean('c2', version=Version(EXCHANGE_2013)) def test_naive_datetime(self): # Test that we can survive naive datetimes on a datetime field tz = EWSTimeZone.timezone('Europe/Copenhagen') account = namedtuple('Account', ['default_timezone'])(default_timezone=tz) default_value = tz.localize(EWSDateTime(2017, 1, 2, 3, 4)) field = DateTimeField('foo', field_uri='item:DateTimeSent', default=default_value) # TZ-aware datetime string payload = b'''\ 2017-06-21T18:40:02Z ''' elem = to_xml(payload).find('{%s}Item' % TNS) self.assertEqual(field.from_xml(elem=elem, account=account), UTC.localize(EWSDateTime(2017, 6, 21, 18, 40, 2))) # Naive datetime string is localized to tz of the account payload = b'''\ 2017-06-21T18:40:02 ''' elem = to_xml(payload).find('{%s}Item' % TNS) self.assertEqual(field.from_xml(elem=elem, account=account), tz.localize(EWSDateTime(2017, 6, 21, 18, 40, 2))) # Garbage string returns None payload = b'''\ THIS_IS_GARBAGE ''' elem = to_xml(payload).find('{%s}Item' % TNS) self.assertEqual(field.from_xml(elem=elem, account=account), None) # Element not found returns default value payload = b'''\ ''' elem = to_xml(payload).find('{%s}Item' % TNS) self.assertEqual(field.from_xml(elem=elem, account=account), default_value) def test_single_field_indexed_element(self): # A SingleFieldIndexedElement must have only one field defined class TestField(SingleFieldIndexedElement): FIELDS = [CharField('a'), CharField('b')] with self.assertRaises(ValueError): TestField.value_field() exchangelib-3.1.1/tests/test_folder.py000066400000000000000000000512271361226005600200230ustar00rootroot00000000000000from exchangelib import Q, Message, ExtendedProperty from exchangelib.errors import ErrorDeleteDistinguishedFolder, ErrorObjectTypeChanged, DoesNotExist, \ MultipleObjectsReturned from exchangelib.folders import Calendar, DeletedItems, Drafts, Inbox, Outbox, SentItems, JunkEmail, Messages, Tasks, \ Contacts, Folder, RecipientCache, GALContacts, System, AllContacts, MyContactsExtended, Reminders, Favorites, \ AllItems, ConversationSettings, Friends, RSSFeeds, Sharing, IMContactList, QuickContacts, Journal, Notes, \ SyncIssues, MyContacts, ToDoSearch, FolderCollection, DistinguishedFolderId, Files, \ DefaultFoldersChangeHistory, PassThroughSearchResults, SmsAndChatsSync, GraphAnalytics, Signal, \ PdpProfileV2Secured, VoiceMail, FolderQuerySet, SingleFolderQuerySet, SHALLOW, RootOfHierarchy from exchangelib.properties import Mailbox, InvalidField from exchangelib.services import GetFolder from .common import EWSTest, get_random_string class FolderTest(EWSTest): def test_folders(self): for f in self.account.root.walk(): if isinstance(f, System): # No access to system folder, apparently continue f.test_access() # Test shortcuts for f, cls in ( (self.account.trash, DeletedItems), (self.account.drafts, Drafts), (self.account.inbox, Inbox), (self.account.outbox, Outbox), (self.account.sent, SentItems), (self.account.junk, JunkEmail), (self.account.contacts, Contacts), (self.account.tasks, Tasks), (self.account.calendar, Calendar), ): with self.subTest(f=f, cls=cls): self.assertIsInstance(f, cls) f.test_access() # Test item field lookup self.assertEqual(f.get_item_field_by_fieldname('subject').name, 'subject') with self.assertRaises(ValueError): f.get_item_field_by_fieldname('XXX') def test_find_folders(self): folders = list(FolderCollection(account=self.account, folders=[self.account.root]).find_folders()) self.assertGreater(len(folders), 40, sorted(f.name for f in folders)) def test_find_folders_with_restriction(self): # Exact match folders = list(FolderCollection(account=self.account, folders=[self.account.root]) .find_folders(q=Q(name='Top of Information Store'))) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) # Startswith folders = list(FolderCollection(account=self.account, folders=[self.account.root]) .find_folders(q=Q(name__startswith='Top of '))) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) # Wrong case folders = list(FolderCollection(account=self.account, folders=[self.account.root]) .find_folders(q=Q(name__startswith='top of '))) self.assertEqual(len(folders), 0, sorted(f.name for f in folders)) # Case insensitive folders = list(FolderCollection(account=self.account, folders=[self.account.root]) .find_folders(q=Q(name__istartswith='top of '))) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) def test_get_folders(self): folders = list(FolderCollection(account=self.account, folders=[self.account.root]).get_folders()) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) # Test that GetFolder can handle FolderId instances folders = list(FolderCollection(account=self.account, folders=[DistinguishedFolderId( id=Inbox.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) )]).get_folders()) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) def test_get_folders_with_distinguished_id(self): # Test that we return an Inbox instance and not a generic Messages or Folder instance when we call GetFolder # with a DistinguishedFolderId instance with an ID of Inbox.DISTINGUISHED_FOLDER_ID. inbox = list(GetFolder(account=self.account).call( folders=[DistinguishedFolderId( id=Inbox.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address)) ], shape='IdOnly', additional_fields=[], ))[0] self.assertIsInstance(inbox, Inbox) def test_folder_grouping(self): # If you get errors here, you probably need to fill out [folder class].LOCALIZED_NAMES for your locale. for f in self.account.root.walk(): with self.subTest(f=f): if isinstance(f, ( Messages, DeletedItems, AllContacts, MyContactsExtended, Sharing, Favorites, SyncIssues, MyContacts )): self.assertEqual(f.folder_class, 'IPF.Note') elif isinstance(f, GALContacts): self.assertEqual(f.folder_class, 'IPF.Contact.GalContacts') elif isinstance(f, RecipientCache): self.assertEqual(f.folder_class, 'IPF.Contact.RecipientCache') elif isinstance(f, Contacts): self.assertEqual(f.folder_class, 'IPF.Contact') elif isinstance(f, Calendar): self.assertEqual(f.folder_class, 'IPF.Appointment') elif isinstance(f, (Tasks, ToDoSearch)): self.assertEqual(f.folder_class, 'IPF.Task') elif isinstance(f, Reminders): self.assertEqual(f.folder_class, 'Outlook.Reminder') elif isinstance(f, AllItems): self.assertEqual(f.folder_class, 'IPF') elif isinstance(f, ConversationSettings): self.assertEqual(f.folder_class, 'IPF.Configuration') elif isinstance(f, Files): self.assertEqual(f.folder_class, 'IPF.Files') elif isinstance(f, Friends): self.assertEqual(f.folder_class, 'IPF.Note') elif isinstance(f, RSSFeeds): self.assertEqual(f.folder_class, 'IPF.Note.OutlookHomepage') elif isinstance(f, IMContactList): self.assertEqual(f.folder_class, 'IPF.Contact.MOC.ImContactList') elif isinstance(f, QuickContacts): self.assertEqual(f.folder_class, 'IPF.Contact.MOC.QuickContacts') elif isinstance(f, Journal): self.assertEqual(f.folder_class, 'IPF.Journal') elif isinstance(f, Notes): self.assertEqual(f.folder_class, 'IPF.StickyNote') elif isinstance(f, DefaultFoldersChangeHistory): self.assertEqual(f.folder_class, 'IPM.DefaultFolderHistoryItem') elif isinstance(f, PassThroughSearchResults): self.assertEqual(f.folder_class, 'IPF.StoreItem.PassThroughSearchResults') elif isinstance(f, SmsAndChatsSync): self.assertEqual(f.folder_class, 'IPF.SmsAndChatsSync') elif isinstance(f, GraphAnalytics): self.assertEqual(f.folder_class, 'IPF.StoreItem.GraphAnalytics') elif isinstance(f, Signal): self.assertEqual(f.folder_class, 'IPF.StoreItem.Signal') elif isinstance(f, PdpProfileV2Secured): self.assertEqual(f.folder_class, 'IPF.StoreItem.PdpProfileSecured') elif isinstance(f, VoiceMail): self.assertEqual(f.folder_class, 'IPF.Note.Microsoft.Voicemail') else: self.assertIn(f.folder_class, (None, 'IPF'), (f.name, f.__class__.__name__, f.folder_class)) self.assertIsInstance(f, Folder) def test_counts(self): # Test count values on a folder f = Folder(parent=self.account.inbox, name=get_random_string(16)).save() f.refresh() self.assertEqual(f.total_count, 0) self.assertEqual(f.unread_count, 0) self.assertEqual(f.child_folder_count, 0) # Create some items items = [] for i in range(3): subject = 'Test Subject %s' % i item = Message(account=self.account, folder=f, is_read=False, subject=subject, categories=self.categories) item.save() items.append(item) # Refresh values and see that total_count and unread_count changes f.refresh() self.assertEqual(f.total_count, 3) self.assertEqual(f.unread_count, 3) self.assertEqual(f.child_folder_count, 0) for i in items: i.is_read = True i.save() # Refresh values and see that unread_count changes f.refresh() self.assertEqual(f.total_count, 3) self.assertEqual(f.unread_count, 0) self.assertEqual(f.child_folder_count, 0) self.bulk_delete(items) # Refresh values and see that total_count changes f.refresh() self.assertEqual(f.total_count, 0) self.assertEqual(f.unread_count, 0) self.assertEqual(f.child_folder_count, 0) # Create some subfolders subfolders = [] for i in range(3): subfolders.append(Folder(parent=f, name=get_random_string(16)).save()) # Refresh values and see that child_folder_count changes f.refresh() self.assertEqual(f.total_count, 0) self.assertEqual(f.unread_count, 0) self.assertEqual(f.child_folder_count, 3) for sub_f in subfolders: sub_f.delete() # Refresh values and see that child_folder_count changes f.refresh() self.assertEqual(f.total_count, 0) self.assertEqual(f.unread_count, 0) self.assertEqual(f.child_folder_count, 0) f.delete() def test_refresh(self): # Test that we can refresh folders for f in self.account.root.walk(): with self.subTest(f=f): if isinstance(f, System): # Can't refresh the 'System' folder for some reason continue old_values = {} for field in f.FIELDS: old_values[field.name] = getattr(f, field.name) if field.name in ('account', 'id', 'changekey', 'parent_folder_id'): # These are needed for a successful refresh() continue if field.is_read_only: continue setattr(f, field.name, self.random_val(field)) f.refresh() for field in f.FIELDS: if field.name == 'changekey': # folders may change while we're testing continue if field.is_read_only: # count values may change during the test continue self.assertEqual(getattr(f, field.name), old_values[field.name], (f, field.name)) # Test refresh of root all_folders = sorted(f.name for f in self.account.root.walk()) self.account.root.refresh() self.assertIsNone(self.account.root._subfolders) self.assertEqual( sorted(f.name for f in self.account.root.walk()), all_folders ) folder = Folder() with self.assertRaises(ValueError): folder.refresh() # Must have root folder folder.root = self.account.root with self.assertRaises(ValueError): folder.refresh() # Must have an id def test_parent(self): self.assertEqual( self.account.calendar.parent.name, 'Top of Information Store' ) self.assertEqual( self.account.calendar.parent.parent.name, 'root' ) def test_children(self): self.assertIn( 'Top of Information Store', [c.name for c in self.account.root.children] ) def test_parts(self): self.assertEqual( [p.name for p in self.account.calendar.parts], ['root', 'Top of Information Store', self.account.calendar.name] ) def test_absolute(self): self.assertEqual( self.account.calendar.absolute, '/root/Top of Information Store/' + self.account.calendar.name ) def test_walk(self): self.assertGreaterEqual(len(list(self.account.root.walk())), 20) self.assertGreaterEqual(len(list(self.account.contacts.walk())), 2) def test_tree(self): self.assertTrue(self.account.root.tree().startswith('root')) def test_glob(self): self.assertGreaterEqual(len(list(self.account.root.glob('*'))), 5) self.assertEqual(len(list(self.account.contacts.glob('GAL*'))), 1) self.assertGreaterEqual(len(list(self.account.contacts.glob('/'))), 5) self.assertGreaterEqual(len(list(self.account.contacts.glob('../*'))), 5) self.assertEqual(len(list(self.account.root.glob('**/%s' % self.account.contacts.name))), 1) self.assertEqual(len(list(self.account.root.glob('Top of*/%s' % self.account.contacts.name))), 1) def test_collection_filtering(self): self.assertGreaterEqual(self.account.root.tois.children.all().count(), 0) self.assertGreaterEqual(self.account.root.tois.walk().all().count(), 0) self.assertGreaterEqual(self.account.root.tois.glob('*').all().count(), 0) def test_empty_collections(self): self.assertEqual(self.account.trash.children.all().count(), 0) self.assertEqual(self.account.trash.walk().all().count(), 0) self.assertEqual(self.account.trash.glob('XXX').all().count(), 0) self.assertEqual(list(self.account.trash.glob('XXX').get_folders()), []) self.assertEqual(list(self.account.trash.glob('XXX').find_folders()), []) def test_div_navigation(self): self.assertEqual( (self.account.root / 'Top of Information Store' / self.account.calendar.name).id, self.account.calendar.id ) self.assertEqual( (self.account.root / 'Top of Information Store' / '..').id, self.account.root.id ) self.assertEqual( (self.account.root / '.').id, self.account.root.id ) def test_double_div_navigation(self): self.account.root.refresh() # Clear the cache # Test normal navigation self.assertEqual( (self.account.root // 'Top of Information Store' // self.account.calendar.name).id, self.account.calendar.id ) self.assertIsNone(self.account.root._subfolders) # Test parent ('..') syntax. Should not work with self.assertRaises(ValueError) as e: _ = self.account.root // 'Top of Information Store' // '..' self.assertEqual(e.exception.args[0], 'Cannot get parent without a folder cache') self.assertIsNone(self.account.root._subfolders) # Test self ('.') syntax self.assertEqual( (self.account.root // '.').id, self.account.root.id ) self.assertIsNone(self.account.root._subfolders) def test_extended_properties(self): # Test extended properties on folders and folder roots. This extended prop gets the size (in bytes) of a folder class FolderSize(ExtendedProperty): property_tag = 0x0e08 property_type = 'Integer' try: Folder.register('size', FolderSize) self.account.inbox.refresh() self.assertGreater(self.account.inbox.size, 0) finally: Folder.deregister('size') try: RootOfHierarchy.register('size', FolderSize) self.account.root.refresh() self.assertGreater(self.account.root.size, 0) finally: RootOfHierarchy.deregister('size') # Register is only allowed on Folder and RootOfHierarchy classes with self.assertRaises(TypeError): self.account.calendar.register(FolderSize) with self.assertRaises(TypeError): self.account.root.register(FolderSize) def test_create_update_empty_delete(self): f = Messages(parent=self.account.inbox, name=get_random_string(16)) f.save() self.assertIsNotNone(f.id) self.assertIsNotNone(f.changekey) new_name = get_random_string(16) f.name = new_name f.save() f.refresh() self.assertEqual(f.name, new_name) with self.assertRaises(ErrorObjectTypeChanged): # FolderClass may not be changed f.folder_class = get_random_string(16) f.save(update_fields=['folder_class']) # Create a subfolder Messages(parent=f, name=get_random_string(16)).save() self.assertEqual(len(list(f.children)), 1) f.empty() self.assertEqual(len(list(f.children)), 1) f.empty(delete_sub_folders=True) self.assertEqual(len(list(f.children)), 0) # Create a subfolder again, and delete it by wiping Messages(parent=f, name=get_random_string(16)).save() self.assertEqual(len(list(f.children)), 1) f.wipe() self.assertEqual(len(list(f.children)), 0) f.delete() with self.assertRaises(ValueError): # No longer has an ID f.refresh() # Delete all subfolders of inbox for c in self.account.inbox.children: c.delete() with self.assertRaises(ErrorDeleteDistinguishedFolder): self.account.inbox.delete() def test_generic_folder(self): f = Folder(parent=self.account.inbox, name=get_random_string(16)) f.save() f.name = get_random_string(16) f.save() f.delete() def test_folder_query_set(self): # Create a folder hierarchy and test a folder queryset # # -f0 # - f1 # - f2 # - f21 # - f22 f0 = Folder(parent=self.account.inbox, name=get_random_string(16)).save() f1 = Folder(parent=f0, name=get_random_string(16)).save() f2 = Folder(parent=f0, name=get_random_string(16)).save() f21 = Folder(parent=f2, name=get_random_string(16)).save() f22 = Folder(parent=f2, name=get_random_string(16)).save() folder_qs = SingleFolderQuerySet(account=self.account, folder=f0) try: # Test all() self.assertSetEqual( set(f.name for f in folder_qs.all()), {f.name for f in (f1, f2, f21, f22)} ) # Test only() self.assertSetEqual( set(f.name for f in folder_qs.only('name').all()), {f.name for f in (f1, f2, f21, f22)} ) self.assertSetEqual( set(f.child_folder_count for f in folder_qs.only('name').all()), {None} ) # Test depth() self.assertSetEqual( set(f.name for f in folder_qs.depth(SHALLOW).all()), {f.name for f in (f1, f2)} ) # Test filter() self.assertSetEqual( set(f.name for f in folder_qs.filter(name=f1.name)), {f.name for f in (f1,)} ) self.assertSetEqual( set(f.name for f in folder_qs.filter(name__in=[f1.name, f2.name])), {f.name for f in (f1, f2)} ) # Test get() self.assertEqual( folder_qs.get(name=f2.name).child_folder_count, 2 ) self.assertEqual( folder_qs.filter(name=f2.name).get().child_folder_count, 2 ) self.assertEqual( folder_qs.only('name').get(name=f2.name).name, f2.name ) self.assertEqual( folder_qs.only('name').get(name=f2.name).child_folder_count, None ) with self.assertRaises(DoesNotExist): folder_qs.get(name=get_random_string(16)) with self.assertRaises(MultipleObjectsReturned): folder_qs.get() finally: f0.wipe() f0.delete() def test_folder_query_set_failures(self): with self.assertRaises(ValueError): FolderQuerySet('XXX') fld_qs = SingleFolderQuerySet(account=self.account, folder=self.account.inbox) with self.assertRaises(InvalidField): fld_qs.only('XXX') with self.assertRaises(InvalidField): list(fld_qs.filter(XXX='XXX')) exchangelib-3.1.1/tests/test_items.py000066400000000000000000003441631361226005600176750ustar00rootroot00000000000000import datetime from decimal import Decimal from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from keyword import kwlist import time import unittest import unittest.util from dateutil.relativedelta import relativedelta from exchangelib.account import SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY from exchangelib.attachments import ItemAttachment from exchangelib.errors import ErrorItemNotFound, ErrorInvalidOperation, ErrorInvalidChangeKey, \ ErrorUnsupportedPathForQuery, ErrorInvalidValueForProperty, ErrorPropertyUpdate, ErrorInvalidPropertySet, \ ErrorInvalidIdMalformed from exchangelib.ewsdatetime import EWSDateTime, EWSTimeZone, UTC, UTC_NOW from exchangelib.extended_properties import ExtendedProperty, ExternId from exchangelib.fields import TextField, BodyField, ExtendedPropertyField, FieldPath, CultureField, IdField, \ CharField, ChoiceField, AttachmentField, BooleanField from exchangelib.folders import Calendar, Inbox, Tasks, Contacts, Folder, FolderCollection from exchangelib.indexed_properties import EmailAddress, PhysicalAddress, SingleFieldIndexedElement, \ MultiFieldIndexedElement from exchangelib.items import Item, CalendarItem, Message, Contact, Task, DistributionList, Persona, BaseItem, \ SHALLOW, ASSOCIATED from exchangelib.properties import Mailbox, Member, Attendee from exchangelib.queryset import QuerySet, DoesNotExist, MultipleObjectsReturned from exchangelib.restriction import Restriction, Q from exchangelib.services import GetPersona from exchangelib.util import value_to_xml_text from exchangelib.version import Build, EXCHANGE_2007, EXCHANGE_2013 from .common import EWSTest, get_random_string, get_random_datetime_range, get_random_date, \ get_random_email, get_random_decimal, get_random_choice, get_random_int, mock_version class BaseItemTest(EWSTest): TEST_FOLDER = None FOLDER_CLASS = None ITEM_CLASS = None @classmethod def setUpClass(cls): if cls is BaseItemTest: raise unittest.SkipTest("Skip BaseItemTest, it's only for inheritance") super().setUpClass() def setUp(self): super().setUp() self.test_folder = getattr(self.account, self.TEST_FOLDER) self.assertEqual(type(self.test_folder), self.FOLDER_CLASS) self.assertEqual(self.test_folder.DISTINGUISHED_FOLDER_ID, self.TEST_FOLDER) self.test_folder.filter(categories__contains=self.categories).delete() def tearDown(self): self.test_folder.filter(categories__contains=self.categories).delete() # Delete all delivery receipts self.test_folder.filter(subject__startswith='Delivered: Subject: ').delete() super().tearDown() def get_random_insert_kwargs(self): insert_kwargs = {} for f in self.ITEM_CLASS.FIELDS: if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): # Timezone fields will (and must) be populated automatically from the timestamp continue if f.is_read_only: # These cannot be created continue if f.name == 'mime_content': # This needs special formatting. See separate test_mime_content() test continue if f.name == 'attachments': # Testing attachments is heavy. Leave this to specific tests insert_kwargs[f.name] = [] continue if f.name == 'resources': # The test server doesn't have any resources insert_kwargs[f.name] = [] continue if f.name == 'optional_attendees': # 'optional_attendees' and 'required_attendees' are mutually exclusive insert_kwargs[f.name] = None continue if f.name == 'start': start = get_random_date() insert_kwargs[f.name], insert_kwargs['end'] = \ get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) insert_kwargs['recurrence'] = self.random_val(self.ITEM_CLASS.get_field_by_fieldname('recurrence')) insert_kwargs['recurrence'].boundary.start = insert_kwargs[f.name].date() continue if f.name == 'end': continue if f.name == 'is_all_day': # For CalendarItem instances, the 'is_all_day' attribute affects the 'start' and 'end' values. Changing # from 'false' to 'true' removes the time part of these datetimes. insert_kwargs['is_all_day'] = False continue if f.name == 'recurrence': continue if f.name == 'due_date': # start_date must be before due_date insert_kwargs['start_date'], insert_kwargs[f.name] = \ get_random_datetime_range(tz=self.account.default_timezone) continue if f.name == 'start_date': continue if f.name == 'status': # Start with an incomplete task status = get_random_choice(set(f.supported_choices(version=self.account.version)) - {Task.COMPLETED}) insert_kwargs[f.name] = status if status == Task.NOT_STARTED: insert_kwargs['percent_complete'] = Decimal(0) else: insert_kwargs['percent_complete'] = get_random_decimal(1, 99) continue if f.name == 'percent_complete': continue insert_kwargs[f.name] = self.random_val(f) return insert_kwargs def get_random_update_kwargs(self, item, insert_kwargs): update_kwargs = {} now = UTC_NOW() for f in self.ITEM_CLASS.FIELDS: if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): # Timezone fields will (and must) be populated automatically from the timestamp continue if f.is_read_only: # These cannot be changed continue if not item.is_draft and f.is_read_only_after_send: # These cannot be changed when the item is no longer a draft continue if f.name == 'message_id' and f.is_read_only_after_send: # Cannot be updated, regardless of draft status continue if f.name == 'attachments': # Testing attachments is heavy. Leave this to specific tests update_kwargs[f.name] = [] continue if f.name == 'resources': # The test server doesn't have any resources update_kwargs[f.name] = [] continue if isinstance(f, AttachmentField): # Attachments are handled separately continue if f.name == 'start': start = get_random_date(start_date=insert_kwargs['end'].date()) update_kwargs[f.name], update_kwargs['end'] = \ get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) update_kwargs['recurrence'] = self.random_val(self.ITEM_CLASS.get_field_by_fieldname('recurrence')) update_kwargs['recurrence'].boundary.start = update_kwargs[f.name].date() continue if f.name == 'end': continue if f.name == 'recurrence': continue if f.name == 'due_date': # start_date must be before due_date, and before complete_date which must be in the past update_kwargs['start_date'], update_kwargs[f.name] = \ get_random_datetime_range(end_date=now.date(), tz=self.account.default_timezone) continue if f.name == 'start_date': continue if f.name == 'status': # Update task to a completed state. complete_date must be a date in the past, and < than start_date update_kwargs[f.name] = Task.COMPLETED update_kwargs['percent_complete'] = Decimal(100) continue if f.name == 'percent_complete': continue if f.name == 'reminder_is_set': if self.ITEM_CLASS == Task: # Task type doesn't allow updating 'reminder_is_set' to True update_kwargs[f.name] = False else: update_kwargs[f.name] = not insert_kwargs[f.name] continue if isinstance(f, BooleanField): update_kwargs[f.name] = not insert_kwargs[f.name] continue if f.value_cls in (Mailbox, Attendee): if insert_kwargs[f.name] is None: update_kwargs[f.name] = self.random_val(f) else: update_kwargs[f.name] = None continue update_kwargs[f.name] = self.random_val(f) if update_kwargs.get('is_all_day', False): # For is_all_day items, EWS will remove the time part of start and end values update_kwargs['start'] = update_kwargs['start'].replace(hour=0, minute=0, second=0, microsecond=0) update_kwargs['end'] = \ update_kwargs['end'].replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1) if self.ITEM_CLASS == CalendarItem: # EWS always sets due date to 'start' update_kwargs['reminder_due_by'] = update_kwargs['start'] return update_kwargs def get_test_item(self, folder=None, categories=None): item_kwargs = self.get_random_insert_kwargs() item_kwargs['categories'] = categories or self.categories return self.ITEM_CLASS(folder=folder or self.test_folder, **item_kwargs) class ItemQuerySetTest(BaseItemTest): TEST_FOLDER = 'inbox' FOLDER_CLASS = Inbox ITEM_CLASS = Message def test_querysets(self): test_items = [] for i in range(4): item = self.get_test_item() item.subject = 'Item %s' % i item.save() test_items.append(item) qs = QuerySet( folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) ).filter(categories__contains=self.categories) test_cat = self.categories[0] self.assertEqual( set((i.subject, i.categories[0]) for i in qs), {('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)} ) self.assertEqual( [(i.subject, i.categories[0]) for i in qs.none()], [] ) self.assertEqual( [(i.subject, i.categories[0]) for i in qs.filter(subject__startswith='Item 2')], [('Item 2', test_cat)] ) self.assertEqual( set((i.subject, i.categories[0]) for i in qs.exclude(subject__startswith='Item 2')), {('Item 0', test_cat), ('Item 1', test_cat), ('Item 3', test_cat)} ) self.assertEqual( set((i.subject, i.categories) for i in qs.only('subject')), {('Item 0', None), ('Item 1', None), ('Item 2', None), ('Item 3', None)} ) self.assertEqual( [(i.subject, i.categories[0]) for i in qs.order_by('subject')], [('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)] ) self.assertEqual( # Test '-some_field' syntax for reverse sorting [(i.subject, i.categories[0]) for i in qs.order_by('-subject')], [('Item 3', test_cat), ('Item 2', test_cat), ('Item 1', test_cat), ('Item 0', test_cat)] ) self.assertEqual( # Test ordering on a field that we don't need to fetch [(i.subject, i.categories[0]) for i in qs.order_by('-subject').only('categories')], [(None, test_cat), (None, test_cat), (None, test_cat), (None, test_cat)] ) self.assertEqual( [(i.subject, i.categories[0]) for i in qs.order_by('subject').reverse()], [('Item 3', test_cat), ('Item 2', test_cat), ('Item 1', test_cat), ('Item 0', test_cat)] ) with self.assertRaises(ValueError): list(qs.values([])) self.assertEqual( [i for i in qs.order_by('subject').values('subject')], [{'subject': 'Item 0'}, {'subject': 'Item 1'}, {'subject': 'Item 2'}, {'subject': 'Item 3'}] ) # Test .values() in combinations of 'id' and 'changekey', which are handled specially self.assertEqual( list(qs.order_by('subject').values('id')), [{'id': i.id} for i in test_items] ) self.assertEqual( list(qs.order_by('subject').values('changekey')), [{'changekey': i.changekey} for i in test_items] ) self.assertEqual( list(qs.order_by('subject').values('id', 'changekey')), [{k: getattr(i, k) for k in ('id', 'changekey')} for i in test_items] ) self.assertEqual( set(i for i in qs.values_list('subject')), {('Item 0',), ('Item 1',), ('Item 2',), ('Item 3',)} ) # Test .values_list() in combinations of 'id' and 'changekey', which are handled specially self.assertEqual( list(qs.order_by('subject').values_list('id')), [(i.id,) for i in test_items] ) self.assertEqual( list(qs.order_by('subject').values_list('changekey')), [(i.changekey,) for i in test_items] ) self.assertEqual( list(qs.order_by('subject').values_list('id', 'changekey')), [(i.id, i.changekey) for i in test_items] ) self.assertEqual( set(i.subject for i in qs.only('subject')), {'Item 0', 'Item 1', 'Item 2', 'Item 3'} ) # Test .only() in combinations of 'id' and 'changekey', which are handled specially self.assertEqual( list((i.id,) for i in qs.order_by('subject').only('id')), [(i.id,) for i in test_items] ) self.assertEqual( list((i.changekey,) for i in qs.order_by('subject').only('changekey')), [(i.changekey,) for i in test_items] ) self.assertEqual( list((i.id, i.changekey) for i in qs.order_by('subject').only('id', 'changekey')), [(i.id, i.changekey) for i in test_items] ) with self.assertRaises(ValueError): list(qs.values_list('id', 'changekey', flat=True)) with self.assertRaises(AttributeError): list(qs.values_list('id', xxx=True)) self.assertEqual( list(qs.order_by('subject').values_list('id', flat=True)), [i.id for i in test_items] ) self.assertEqual( list(qs.order_by('subject').values_list('changekey', flat=True)), [i.changekey for i in test_items] ) self.assertEqual( set(i for i in qs.values_list('subject', flat=True)), {'Item 0', 'Item 1', 'Item 2', 'Item 3'} ) self.assertEqual( qs.values_list('subject', flat=True).get(subject='Item 2'), 'Item 2' ) self.assertEqual( set((i.subject, i.categories[0]) for i in qs.exclude(subject__startswith='Item 2')), {('Item 0', test_cat), ('Item 1', test_cat), ('Item 3', test_cat)} ) # Test that we can sort on a field that we don't want self.assertEqual( [i.categories[0] for i in qs.only('categories').order_by('subject')], [test_cat, test_cat, test_cat, test_cat] ) # Test iterator self.assertEqual( set((i.subject, i.categories[0]) for i in qs.iterator()), {('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)} ) # Test that iterator() preserves the result format self.assertEqual( set((i[0], i[1][0]) for i in qs.values_list('subject', 'categories').iterator()), {('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)} ) self.assertEqual(qs.get(subject='Item 3').subject, 'Item 3') with self.assertRaises(DoesNotExist): qs.get(subject='Item XXX') with self.assertRaises(MultipleObjectsReturned): qs.get(subject__startswith='Item') # len() and count() self.assertEqual(len(qs), 4) self.assertEqual(qs.count(), 4) # Indexing and slicing self.assertTrue(isinstance(qs[0], self.ITEM_CLASS)) self.assertEqual(len(list(qs[1:3])), 2) self.assertEqual(len(qs), 4) with self.assertRaises(IndexError): print(qs[99999]) # Exists self.assertEqual(qs.exists(), True) self.assertEqual(qs.filter(subject='Test XXX').exists(), False) self.assertEqual( qs.filter(subject__startswith='Item').delete(), [True, True, True, True] ) def test_queryset_failure(self): qs = QuerySet( folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) ).filter(categories__contains=self.categories) with self.assertRaises(ValueError): qs.order_by('XXX') with self.assertRaises(ValueError): qs.values('XXX') with self.assertRaises(ValueError): qs.values_list('XXX') with self.assertRaises(ValueError): qs.only('XXX') with self.assertRaises(ValueError): qs.reverse() # We can't reverse when we haven't defined an order yet def test_cached_queryset_corner_cases(self): test_items = [] for i in range(4): item = self.get_test_item() item.subject = 'Item %s' % i item.save() test_items.append(item) qs = QuerySet( folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) ).filter(categories__contains=self.categories).order_by('subject') for _ in qs: # Build up the cache pass self.assertEqual(len(qs._cache), 4) with self.assertRaises(MultipleObjectsReturned): qs.get() # Get with a full cache self.assertEqual(qs[2].subject, 'Item 2') # Index with a full cache self.assertEqual(qs[-2].subject, 'Item 2') # Negative index with a full cache qs.delete() # Delete with a full cache self.assertEqual(qs.count(), 0) # QuerySet is empty after delete self.assertEqual(list(qs.none()), []) def test_queryset_get_by_id(self): item = self.get_test_item().save() with self.assertRaises(ValueError): list(self.test_folder.filter(id__in=[item.id])) with self.assertRaises(ValueError): list(self.test_folder.get(id=item.id, changekey=item.changekey, subject='XXX')) with self.assertRaises(ValueError): list(self.test_folder.get(id=None, changekey=item.changekey)) # Test a simple get() get_item = self.test_folder.get(id=item.id, changekey=item.changekey) self.assertEqual(item.id, get_item.id) self.assertEqual(item.changekey, get_item.changekey) self.assertEqual(item.subject, get_item.subject) self.assertEqual(item.body, get_item.body) # Test get() with ID only get_item = self.test_folder.get(id=item.id) self.assertEqual(item.id, get_item.id) self.assertEqual(item.changekey, get_item.changekey) self.assertEqual(item.subject, get_item.subject) self.assertEqual(item.body, get_item.body) get_item = self.test_folder.get(id=item.id, changekey=None) self.assertEqual(item.id, get_item.id) self.assertEqual(item.changekey, get_item.changekey) self.assertEqual(item.subject, get_item.subject) self.assertEqual(item.body, get_item.body) # Test a get() from queryset get_item = self.test_folder.all().get(id=item.id, changekey=item.changekey) self.assertEqual(item.id, get_item.id) self.assertEqual(item.changekey, get_item.changekey) self.assertEqual(item.subject, get_item.subject) self.assertEqual(item.body, get_item.body) # Test a get() with only() get_item = self.test_folder.all().only('subject').get(id=item.id, changekey=item.changekey) self.assertEqual(item.id, get_item.id) self.assertEqual(item.changekey, get_item.changekey) self.assertEqual(item.subject, get_item.subject) self.assertIsNone(get_item.body) def test_paging(self): # Test that paging services work correctly. Default EWS paging size is 1000 items. Our default is 100 items. items = [] for _ in range(11): i = self.get_test_item() del i.attachments[:] items.append(i) self.test_folder.bulk_create(items=items) ids = self.test_folder.filter(categories__contains=self.categories).values_list('id', 'changekey') ids.page_size = 10 self.bulk_delete(ids.iterator()) def test_slicing(self): # Test that slicing works correctly items = [] for i in range(4): item = self.get_test_item() item.subject = 'Subj %s' % i del item.attachments[:] items.append(item) ids = self.test_folder.bulk_create(items=items) qs = self.test_folder.filter(categories__contains=self.categories).only('subject').order_by('subject') # Test positive index self.assertEqual( qs._copy_self()[0].subject, 'Subj 0' ) # Test positive index self.assertEqual( qs._copy_self()[3].subject, 'Subj 3' ) # Test negative index self.assertEqual( qs._copy_self()[-2].subject, 'Subj 2' ) # Test positive slice self.assertEqual( [i.subject for i in qs._copy_self()[0:2]], ['Subj 0', 'Subj 1'] ) # Test positive slice self.assertEqual( [i.subject for i in qs._copy_self()[2:4]], ['Subj 2', 'Subj 3'] ) # Test positive open slice self.assertEqual( [i.subject for i in qs._copy_self()[:2]], ['Subj 0', 'Subj 1'] ) # Test positive open slice self.assertEqual( [i.subject for i in qs._copy_self()[2:]], ['Subj 2', 'Subj 3'] ) # Test negative slice self.assertEqual( [i.subject for i in qs._copy_self()[-3:-1]], ['Subj 1', 'Subj 2'] ) # Test negative slice self.assertEqual( [i.subject for i in qs._copy_self()[1:-1]], ['Subj 1', 'Subj 2'] ) # Test negative open slice self.assertEqual( [i.subject for i in qs._copy_self()[:-2]], ['Subj 0', 'Subj 1'] ) # Test negative open slice self.assertEqual( [i.subject for i in qs._copy_self()[-2:]], ['Subj 2', 'Subj 3'] ) # Test positive slice with step self.assertEqual( [i.subject for i in qs._copy_self()[0:4:2]], ['Subj 0', 'Subj 2'] ) # Test negative slice with step self.assertEqual( [i.subject for i in qs._copy_self()[4:0:-2]], ['Subj 3', 'Subj 1'] ) def test_delete_via_queryset(self): self.get_test_item().save() qs = self.test_folder.filter(categories__contains=self.categories) self.assertEqual(qs.count(), 1) qs.delete() self.assertEqual(qs.count(), 0) def test_send_via_queryset(self): self.get_test_item().save() qs = self.test_folder.filter(categories__contains=self.categories) to_folder = self.account.sent to_folder_qs = to_folder.filter(categories__contains=self.categories) self.assertEqual(qs.count(), 1) self.assertEqual(to_folder_qs.count(), 0) qs.send(copy_to_folder=to_folder) time.sleep(5) # Requests are supposed to be transactional, but apparently not... self.assertEqual(qs.count(), 0) self.assertEqual(to_folder_qs.count(), 1) def test_send_with_no_copy_via_queryset(self): self.get_test_item().save() qs = self.test_folder.filter(categories__contains=self.categories) to_folder = self.account.sent to_folder_qs = to_folder.filter(categories__contains=self.categories) self.assertEqual(qs.count(), 1) self.assertEqual(to_folder_qs.count(), 0) qs.send(save_copy=False) time.sleep(5) # Requests are supposed to be transactional, but apparently not... self.assertEqual(qs.count(), 0) self.assertEqual(to_folder_qs.count(), 0) def test_copy_via_queryset(self): self.get_test_item().save() qs = self.test_folder.filter(categories__contains=self.categories) to_folder = self.account.trash to_folder_qs = to_folder.filter(categories__contains=self.categories) self.assertEqual(qs.count(), 1) self.assertEqual(to_folder_qs.count(), 0) qs.copy(to_folder=to_folder) self.assertEqual(qs.count(), 1) self.assertEqual(to_folder_qs.count(), 1) def test_move_via_queryset(self): self.get_test_item().save() qs = self.test_folder.filter(categories__contains=self.categories) to_folder = self.account.trash to_folder_qs = to_folder.filter(categories__contains=self.categories) self.assertEqual(qs.count(), 1) self.assertEqual(to_folder_qs.count(), 0) qs.move(to_folder=to_folder) self.assertEqual(qs.count(), 0) self.assertEqual(to_folder_qs.count(), 1) def test_depth(self): self.assertGreaterEqual(self.test_folder.all().depth(ASSOCIATED).count(), 0) self.assertGreaterEqual(self.test_folder.all().depth(SHALLOW).count(), 0) class ItemHelperTest(BaseItemTest): TEST_FOLDER = 'inbox' FOLDER_CLASS = Inbox ITEM_CLASS = Message def test_save_with_update_fields(self): item = self.get_test_item() with self.assertRaises(ValueError): item.save(update_fields=['subject']) # update_fields does not work on item creation item.save() item.subject = 'XXX' item.body = 'YYY' item.save(update_fields=['subject']) item.refresh() self.assertEqual(item.subject, 'XXX') self.assertNotEqual(item.body, 'YYY') # Test invalid 'update_fields' input with self.assertRaises(ValueError) as e: item.save(update_fields=['xxx']) self.assertEqual( e.exception.args[0], "Field name(s) 'xxx' are not valid for a '%s' item" % self.ITEM_CLASS.__name__ ) with self.assertRaises(ValueError) as e: item.save(update_fields='subject') self.assertEqual( e.exception.args[0], "Field name(s) 's', 'u', 'b', 'j', 'e', 'c', 't' are not valid for a '%s' item" % self.ITEM_CLASS.__name__ ) def test_soft_delete(self): # First, empty trash bin self.account.trash.filter(categories__contains=self.categories).delete() self.account.recoverable_items_deletions.filter(categories__contains=self.categories).delete() item = self.get_test_item().save() item_id = (item.id, item.changekey) # Soft delete item.soft_delete() for e in self.account.fetch(ids=[item_id]): # It's gone from the test folder self.assertIsInstance(e, ErrorItemNotFound) # Really gone, not just changed ItemId self.assertEqual(len(self.test_folder.filter(categories__contains=item.categories)), 0) self.assertEqual(len(self.account.trash.filter(categories__contains=item.categories)), 0) # But we can find it in the recoverable items folder self.assertEqual(len(self.account.recoverable_items_deletions.filter(categories__contains=item.categories)), 1) def test_move_to_trash(self): # First, empty trash bin self.account.trash.filter(categories__contains=self.categories).delete() item = self.get_test_item().save() item_id = (item.id, item.changekey) # Move to trash item.move_to_trash() for e in self.account.fetch(ids=[item_id]): # Not in the test folder anymore self.assertIsInstance(e, ErrorItemNotFound) # Really gone, not just changed ItemId self.assertEqual(len(self.test_folder.filter(categories__contains=item.categories)), 0) # Test that the item moved to trash item = self.account.trash.get(categories__contains=item.categories) moved_item = list(self.account.fetch(ids=[item]))[0] # The item was copied, so the ItemId has changed. Let's compare the subject instead self.assertEqual(item.subject, moved_item.subject) def test_copy(self): # First, empty trash bin self.account.trash.filter(categories__contains=self.categories).delete() item = self.get_test_item().save() # Copy to trash. We use trash because it can contain all item types. copy_item_id, copy_changekey = item.copy(to_folder=self.account.trash) # Test that the item still exists in the folder self.assertEqual(len(self.test_folder.filter(categories__contains=item.categories)), 1) # Test that the copied item exists in trash copied_item = self.account.trash.get(categories__contains=item.categories) self.assertNotEqual(item.id, copied_item.id) self.assertNotEqual(item.changekey, copied_item.changekey) self.assertEqual(copy_item_id, copied_item.id) self.assertEqual(copy_changekey, copied_item.changekey) def test_move(self): # First, empty trash bin self.account.trash.filter(categories__contains=self.categories).delete() item = self.get_test_item().save() item_id = (item.id, item.changekey) # Move to trash. We use trash because it can contain all item types. This changes the ItemId item.move(to_folder=self.account.trash) for e in self.account.fetch(ids=[item_id]): # original item ID no longer exists self.assertIsInstance(e, ErrorItemNotFound) # Test that the item moved to trash self.assertEqual(len(self.test_folder.filter(categories__contains=item.categories)), 0) moved_item = self.account.trash.get(categories__contains=item.categories) self.assertEqual(item.id, moved_item.id) self.assertEqual(item.changekey, moved_item.changekey) def test_refresh(self): # Test that we can refresh items, and that refresh fails if the item no longer exists on the server item = self.get_test_item().save() orig_subject = item.subject item.subject = 'XXX' item.refresh() self.assertEqual(item.subject, orig_subject) item.delete() with self.assertRaises(ValueError): # Item no longer has an ID item.refresh() class BulkMethodTest(BaseItemTest): TEST_FOLDER = 'inbox' FOLDER_CLASS = Inbox ITEM_CLASS = Message def test_fetch(self): item = self.get_test_item() self.test_folder.bulk_create(items=[item, item]) ids = self.test_folder.filter(categories__contains=item.categories) items = list(self.account.fetch(ids=ids)) for item in items: self.assertIsInstance(item, self.ITEM_CLASS) self.assertEqual(len(items), 2) items = list(self.account.fetch(ids=ids, only_fields=['subject'])) self.assertEqual(len(items), 2) items = list(self.account.fetch(ids=ids, only_fields=[FieldPath.from_string('subject', self.test_folder)])) self.assertEqual(len(items), 2) def test_empty_args(self): # We allow empty sequences for these methods self.assertEqual(self.test_folder.bulk_create(items=[]), []) self.assertEqual(list(self.account.fetch(ids=[])), []) self.assertEqual(self.account.bulk_create(folder=self.test_folder, items=[]), []) self.assertEqual(self.account.bulk_update(items=[]), []) self.assertEqual(self.account.bulk_delete(ids=[]), []) self.assertEqual(self.account.bulk_send(ids=[]), []) self.assertEqual(self.account.bulk_copy(ids=[], to_folder=self.account.trash), []) self.assertEqual(self.account.bulk_move(ids=[], to_folder=self.account.trash), []) self.assertEqual(self.account.upload(data=[]), []) self.assertEqual(self.account.export(items=[]), []) def test_qs_args(self): # We allow querysets for these methods qs = self.test_folder.none() self.assertEqual(list(self.account.fetch(ids=qs)), []) with self.assertRaises(ValueError): # bulk_update() does not allow queryset input self.assertEqual(self.account.bulk_update(items=qs), []) self.assertEqual(self.account.bulk_delete(ids=qs), []) self.assertEqual(self.account.bulk_send(ids=qs), []) self.assertEqual(self.account.bulk_copy(ids=qs, to_folder=self.account.trash), []) self.assertEqual(self.account.bulk_move(ids=qs, to_folder=self.account.trash), []) with self.assertRaises(ValueError): # upload() does not allow queryset input self.assertEqual(self.account.upload(data=qs), []) self.assertEqual(self.account.export(items=qs), []) def test_no_kwargs(self): self.assertEqual(self.test_folder.bulk_create([]), []) self.assertEqual(list(self.account.fetch([])), []) self.assertEqual(self.account.bulk_create(self.test_folder, []), []) self.assertEqual(self.account.bulk_update([]), []) self.assertEqual(self.account.bulk_delete([]), []) self.assertEqual(self.account.bulk_send([]), []) self.assertEqual(self.account.bulk_copy([], to_folder=self.account.trash), []) self.assertEqual(self.account.bulk_move([], to_folder=self.account.trash), []) self.assertEqual(self.account.upload([]), []) self.assertEqual(self.account.export([]), []) def test_invalid_bulk_args(self): # Test bulk_create with self.assertRaises(ValueError): # Folder must belong to account self.account.bulk_create(folder=Folder(root=None), items=[]) with self.assertRaises(AttributeError): # Must have folder on save self.account.bulk_create(folder=None, items=[], message_disposition=SAVE_ONLY) # Test that we can send_and_save with a default folder self.account.bulk_create(folder=None, items=[], message_disposition=SEND_AND_SAVE_COPY) with self.assertRaises(AttributeError): # Must not have folder on send-only self.account.bulk_create(folder=self.test_folder, items=[], message_disposition=SEND_ONLY) # Test bulk_update with self.assertRaises(ValueError): # Cannot update in send-only mode self.account.bulk_update(items=[], message_disposition=SEND_ONLY) def test_bulk_failure(self): # Test that bulk_* can handle EWS errors and return the errors in order without losing non-failure results items1 = [self.get_test_item().save() for _ in range(3)] items1[1].changekey = 'XXX' for i, res in enumerate(self.account.bulk_delete(items1)): if i == 1: self.assertIsInstance(res, ErrorInvalidChangeKey) else: self.assertEqual(res, True) items2 = [self.get_test_item().save() for _ in range(3)] items2[1].id = 'AAAA==' for i, res in enumerate(self.account.bulk_delete(items2)): if i == 1: self.assertIsInstance(res, ErrorInvalidIdMalformed) else: self.assertEqual(res, True) items3 = [self.get_test_item().save() for _ in range(3)] items3[1].id = items1[0].id for i, res in enumerate(self.account.fetch(items3)): if i == 1: self.assertIsInstance(res, ErrorItemNotFound) else: self.assertIsInstance(res, Item) class CommonItemTest(BaseItemTest): @classmethod def setUpClass(cls): if cls is CommonItemTest: raise unittest.SkipTest("Skip CommonItemTest, it's only for inheritance") super().setUpClass() def test_field_names(self): # Test that fieldnames don't clash with Python keywords for f in self.ITEM_CLASS.FIELDS: self.assertNotIn(f.name, kwlist) def test_magic(self): item = self.get_test_item() self.assertIn('subject=', str(item)) self.assertIn(item.__class__.__name__, repr(item)) def test_queryset_nonsearchable_fields(self): for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): if f.is_searchable or isinstance(f, IdField) or not f.supports_version(self.account.version): continue if f.name in ('percent_complete', 'allow_new_time_proposal'): # These fields don't raise an error when used in a filter, but also don't match anything in a filter continue try: filter_val = f.clean(self.random_val(f)) filter_kwargs = {'%s__in' % f.name: filter_val} if f.is_list else {f.name: filter_val} # We raise ValueError when searching on an is_searchable=False field with self.assertRaises(ValueError): list(self.test_folder.filter(**filter_kwargs)) # Make sure the is_searchable=False setting is correct by searching anyway and testing that this # fails server-side. This only works for values that we are actually able to convert to a search # string. try: value_to_xml_text(filter_val) except NotImplementedError: continue f.is_searchable = True if f.name in ('reminder_due_by',): # Filtering is accepted but doesn't work self.assertEqual( len(self.test_folder.filter(**filter_kwargs)), 0 ) else: with self.assertRaises((ErrorUnsupportedPathForQuery, ErrorInvalidValueForProperty)): list(self.test_folder.filter(**filter_kwargs)) finally: f.is_searchable = False def test_filter_on_all_fields(self): # Test that we can filter on all field names # TODO: Test filtering on subfields of IndexedField item = self.get_test_item().save() common_qs = self.test_folder.filter(categories__contains=self.categories) for f in self.ITEM_CLASS.FIELDS: if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue if not f.is_searchable: # Cannot be used in a QuerySet continue val = getattr(item, f.name) if val is None: # We cannot filter on None values continue if self.ITEM_CLASS == Contact and f.name in ('body', 'display_name'): # filtering 'body' or 'display_name' on Contact items doesn't work at all. Error in EWS? continue if f.is_list: # Filter multi-value fields with =, __in and __contains if issubclass(f.value_cls, MultiFieldIndexedElement): # For these, we need to filter on the subfield filter_kwargs = [] for v in val: for subfield in f.value_cls.supported_fields(version=self.account.version): field_path = FieldPath(field=f, label=v.label, subfield=subfield) path, subval = field_path.path, field_path.get_value(item) if subval is None: continue filter_kwargs.extend([ {path: subval}, {'%s__in' % path: [subval]}, {'%s__contains' % path: [subval]} ]) elif issubclass(f.value_cls, SingleFieldIndexedElement): # For these, we may filter by item or subfield value filter_kwargs = [] for v in val: for subfield in f.value_cls.supported_fields(version=self.account.version): field_path = FieldPath(field=f, label=v.label, subfield=subfield) path, subval = field_path.path, field_path.get_value(item) if subval is None: continue filter_kwargs.extend([ {f.name: v}, {path: subval}, {'%s__in' % path: [subval]}, {'%s__contains' % path: [subval]} ]) else: filter_kwargs = [{'%s__in' % f.name: val}, {'%s__contains' % f.name: val}] else: # Filter all others with =, __in and __contains. We could have more filters here, but these should # always match. filter_kwargs = [{f.name: val}, {'%s__in' % f.name: [val]}] if isinstance(f, TextField) and not isinstance(f, ChoiceField): # Choice fields cannot be filtered using __contains. Sort of makes sense. random_start = get_random_int(min_val=0, max_val=len(val)//2) random_end = get_random_int(min_val=len(val)//2+1, max_val=len(val)) filter_kwargs.append({'%s__contains' % f.name: val[random_start:random_end]}) for kw in filter_kwargs: with self.subTest(f=f, kw=kw): matches = len(common_qs.filter(**kw)) if isinstance(f, TextField) and f.is_complex: # Complex text fields sometimes fail a search using generated data. In production, # they almost always work anyway. Give it one more try after 10 seconds; it seems EWS does # some sort of indexing that needs to catch up. if not matches: time.sleep(10) matches = len(common_qs.filter(**kw)) if not matches and isinstance(f, BodyField): # The body field is particularly nasty in this area. Give up continue self.assertEqual(matches, 1, (f.name, val, kw)) def test_text_field_settings(self): # Test that the max_length and is_complex field settings are correctly set for text fields item = self.get_test_item().save() for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue if not isinstance(f, TextField): continue if isinstance(f, ChoiceField): # This one can't contain random values continue if isinstance(f, CultureField): # This one can't contain random values continue if f.is_read_only: continue if f.name == 'categories': # We're filtering on this one, so leave it alone continue old_max_length = getattr(f, 'max_length', None) old_is_complex = f.is_complex try: # Set a string long enough to not be handled by FindItems f.max_length = 4000 if f.is_list: setattr(item, f.name, [get_random_string(f.max_length) for _ in range(len(getattr(item, f.name)))]) else: setattr(item, f.name, get_random_string(f.max_length)) try: item.save(update_fields=[f.name]) except ErrorPropertyUpdate: # Some fields throw this error when updated to a huge value self.assertIn(f.name, ['given_name', 'middle_name', 'surname']) continue except ErrorInvalidPropertySet: # Some fields can not be updated after save self.assertTrue(f.is_read_only_after_send) continue # is_complex=True forces the query to use GetItems which will always get the full value f.is_complex = True new_full_item = self.test_folder.all().only(f.name).get(categories__contains=self.categories) new_full = getattr(new_full_item, f.name) if old_max_length: if f.is_list: for s in new_full: self.assertLessEqual(len(s), old_max_length, (f.name, len(s), old_max_length)) else: self.assertLessEqual(len(new_full), old_max_length, (f.name, len(new_full), old_max_length)) # is_complex=False forces the query to use FindItems which will only get the short value f.is_complex = False new_short_item = self.test_folder.all().only(f.name).get(categories__contains=self.categories) new_short = getattr(new_short_item, f.name) if not old_is_complex: self.assertEqual(new_short, new_full, (f.name, new_short, new_full)) finally: if old_max_length: f.max_length = old_max_length else: delattr(f, 'max_length') f.is_complex = old_is_complex def test_save_and_delete(self): # Test that we can create, update and delete single items using methods directly on the item. insert_kwargs = self.get_random_insert_kwargs() insert_kwargs['categories'] = self.categories item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs) self.assertIsNone(item.id) self.assertIsNone(item.changekey) # Create item.save() self.assertIsNotNone(item.id) self.assertIsNotNone(item.changekey) for k, v in insert_kwargs.items(): self.assertEqual(getattr(item, k), v, (k, getattr(item, k), v)) # Test that whatever we have locally also matches whatever is in the DB fresh_item = list(self.account.fetch(ids=[item]))[0] for f in item.FIELDS: with self.subTest(f=f): old, new = getattr(item, f.name), getattr(fresh_item, f.name) if f.is_read_only and old is None: # Some fields are automatically set server-side continue if f.name == 'reminder_due_by': # EWS sets a default value if it is not set on insert. Ignore continue if f.name == 'mime_content': # This will change depending on other contents fields continue if f.is_list: old, new = set(old or ()), set(new or ()) self.assertEqual(old, new, (f.name, old, new)) # Update update_kwargs = self.get_random_update_kwargs(item=item, insert_kwargs=insert_kwargs) for k, v in update_kwargs.items(): setattr(item, k, v) item.save() for k, v in update_kwargs.items(): self.assertEqual(getattr(item, k), v, (k, getattr(item, k), v)) # Test that whatever we have locally also matches whatever is in the DB fresh_item = list(self.account.fetch(ids=[item]))[0] for f in item.FIELDS: with self.subTest(f=f): old, new = getattr(item, f.name), getattr(fresh_item, f.name) if f.is_read_only and old is None: # Some fields are automatically updated server-side continue if f.name == 'mime_content': # This will change depending on other contents fields continue if f.name == 'reminder_due_by': if new is None: # EWS does not always return a value if reminder_is_set is False. continue if old is not None: # EWS sometimes randomly sets the new reminder due date to one month before or after we # wanted it, and sometimes 30 days before or after. But only sometimes... old_date = old.astimezone(self.account.default_timezone).date() new_date = new.astimezone(self.account.default_timezone).date() if relativedelta(month=1) + new_date == old_date: item.reminder_due_by = new continue if relativedelta(month=1) + old_date == new_date: item.reminder_due_by = new continue elif abs(old_date - new_date) == datetime.timedelta(days=30): item.reminder_due_by = new continue if f.is_list: old, new = set(old or ()), set(new or ()) self.assertEqual(old, new, (f.name, old, new)) # Hard delete item_id = (item.id, item.changekey) item.delete() for e in self.account.fetch(ids=[item_id]): # It's gone from the account self.assertIsInstance(e, ErrorItemNotFound) # Really gone, not just changed ItemId items = self.test_folder.filter(categories__contains=item.categories) self.assertEqual(len(items), 0) def test_item(self): # Test insert insert_kwargs = self.get_random_insert_kwargs() insert_kwargs['categories'] = self.categories item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs) # Test with generator as argument insert_ids = self.test_folder.bulk_create(items=(i for i in [item])) self.assertEqual(len(insert_ids), 1) self.assertIsInstance(insert_ids[0], BaseItem) find_ids = self.test_folder.filter(categories__contains=item.categories).values_list('id', 'changekey') self.assertEqual(len(find_ids), 1) self.assertEqual(len(find_ids[0]), 2, find_ids[0]) self.assertEqual(insert_ids, list(find_ids)) # Test with generator as argument item = list(self.account.fetch(ids=(i for i in find_ids)))[0] for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): # Timezone fields will (and must) be populated automatically from the timestamp continue if f.is_read_only: continue if f.name == 'reminder_due_by': # EWS sets a default value if it is not set on insert. Ignore continue if f.name == 'mime_content': # This will change depending on other contents fields continue old, new = getattr(item, f.name), insert_kwargs[f.name] if f.is_list: old, new = set(old or ()), set(new or ()) self.assertEqual(old, new, (f.name, old, new)) # Test update update_kwargs = self.get_random_update_kwargs(item=item, insert_kwargs=insert_kwargs) if self.ITEM_CLASS in (Contact, DistributionList): # Contact and DistributionList don't support mime_type updates at all update_kwargs.pop('mime_content', None) update_fieldnames = [f for f in update_kwargs.keys() if f != 'attachments'] for k, v in update_kwargs.items(): setattr(item, k, v) # Test with generator as argument update_ids = self.account.bulk_update(items=(i for i in [(item, update_fieldnames)])) self.assertEqual(len(update_ids), 1) self.assertEqual(len(update_ids[0]), 2, update_ids) self.assertEqual(insert_ids[0].id, update_ids[0][0]) # ID should be the same self.assertNotEqual(insert_ids[0].changekey, update_ids[0][1]) # Changekey should change when item is updated item = list(self.account.fetch(update_ids))[0] for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): # Timezone fields will (and must) be populated automatically from the timestamp continue if f.is_read_only or f.is_read_only_after_send: # These cannot be changed continue if f.name == 'mime_content': # This will change depending on other contents fields continue old, new = getattr(item, f.name), update_kwargs[f.name] if f.name == 'reminder_due_by': if old is None: # EWS does not always return a value if reminder_is_set is False. Set one now item.reminder_due_by = new continue elif old is not None and new is not None: # EWS sometimes randomly sets the new reminder due date to one month before or after we # wanted it, and sometimes 30 days before or after. But only sometimes... old_date = old.astimezone(self.account.default_timezone).date() new_date = new.astimezone(self.account.default_timezone).date() if relativedelta(month=1) + new_date == old_date: item.reminder_due_by = new continue if relativedelta(month=1) + old_date == new_date: item.reminder_due_by = new continue elif abs(old_date - new_date) == datetime.timedelta(days=30): item.reminder_due_by = new continue if f.is_list: old, new = set(old or ()), set(new or ()) self.assertEqual(old, new, (f.name, old, new)) # Test wiping or removing fields wipe_kwargs = {} for f in self.ITEM_CLASS.FIELDS: if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): # Timezone fields will (and must) be populated automatically from the timestamp continue if f.is_required or f.is_required_after_save: # These cannot be deleted continue if f.is_read_only or f.is_read_only_after_send: # These cannot be changed continue wipe_kwargs[f.name] = None for k, v in wipe_kwargs.items(): setattr(item, k, v) wipe_ids = self.account.bulk_update([(item, update_fieldnames), ]) self.assertEqual(len(wipe_ids), 1) self.assertEqual(len(wipe_ids[0]), 2, wipe_ids) self.assertEqual(insert_ids[0].id, wipe_ids[0][0]) # ID should be the same self.assertNotEqual(insert_ids[0].changekey, wipe_ids[0][1]) # Changekey should not be the same when item is updated item = list(self.account.fetch(wipe_ids))[0] for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): # Timezone fields will (and must) be populated automatically from the timestamp continue if f.is_required or f.is_required_after_save: continue if f.is_read_only or f.is_read_only_after_send: continue old, new = getattr(item, f.name), wipe_kwargs[f.name] if f.is_list: old, new = set(old or ()), set(new or ()) self.assertEqual(old, new, (f.name, old, new)) try: self.ITEM_CLASS.register('extern_id', ExternId) # Test extern_id = None, which deletes the extended property entirely extern_id = None item.extern_id = extern_id wipe2_ids = self.account.bulk_update([(item, ['extern_id']), ]) self.assertEqual(len(wipe2_ids), 1) self.assertEqual(len(wipe2_ids[0]), 2, wipe2_ids) self.assertEqual(insert_ids[0].id, wipe2_ids[0][0]) # ID must be the same self.assertNotEqual(insert_ids[0].changekey, wipe2_ids[0][1]) # Changekey must change when item is updated item = list(self.account.fetch(wipe2_ids))[0] self.assertEqual(item.extern_id, extern_id) finally: self.ITEM_CLASS.deregister('extern_id') class GenericItemTest(CommonItemTest): # Tests that don't need to be run for every single folder type TEST_FOLDER = 'inbox' FOLDER_CLASS = Inbox ITEM_CLASS = Message def test_validation(self): item = self.get_test_item() item.clean() for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): # Test field max_length if isinstance(f, CharField) and f.max_length: with self.assertRaises(ValueError): setattr(item, f.name, 'a' * (f.max_length + 1)) item.clean() setattr(item, f.name, 'a') def test_invalid_direct_args(self): with self.assertRaises(ValueError): item = self.get_test_item() item.account = None item.save() # Must have account on save with self.assertRaises(ValueError): item = self.get_test_item() item.id = 'XXX' # Fake a saved item item.account = None item.save() # Must have account on update with self.assertRaises(ValueError): item = self.get_test_item() item.save(update_fields=['foo', 'bar']) # update_fields is only valid on update with self.assertRaises(ValueError): item = self.get_test_item() item.account = None item.refresh() # Must have account on refresh with self.assertRaises(ValueError): item = self.get_test_item() item.refresh() # Refresh an item that has not been saved with self.assertRaises(ErrorItemNotFound): item = self.get_test_item() item.save() item_id, changekey = item.id, item.changekey item.delete() item.id, item.changekey = item_id, changekey item.refresh() # Refresh an item that doesn't exist with self.assertRaises(ValueError): item = self.get_test_item() item.account = None item.copy(to_folder=self.test_folder) # Must have an account on copy with self.assertRaises(ValueError): item = self.get_test_item() item.copy(to_folder=self.test_folder) # Must be an existing item with self.assertRaises(ErrorItemNotFound): item = self.get_test_item() item.save() item_id, changekey = item.id, item.changekey item.delete() item.id, item.changekey = item_id, changekey item.copy(to_folder=self.test_folder) # Item disappeared with self.assertRaises(ValueError): item = self.get_test_item() item.account = None item.move(to_folder=self.test_folder) # Must have an account on move with self.assertRaises(ValueError): item = self.get_test_item() item.move(to_folder=self.test_folder) # Must be an existing item with self.assertRaises(ErrorItemNotFound): item = self.get_test_item() item.save() item_id, changekey = item.id, item.changekey item.delete() item.id, item.changekey = item_id, changekey item.move(to_folder=self.test_folder) # Item disappeared with self.assertRaises(ValueError): item = self.get_test_item() item.account = None item.delete() # Must have an account with self.assertRaises(ValueError): item = self.get_test_item() item.delete() # Must be an existing item with self.assertRaises(ErrorItemNotFound): item = self.get_test_item() item.save() item_id, changekey = item.id, item.changekey item.delete() item.id, item.changekey = item_id, changekey item.delete() # Item disappeared def test_invalid_kwargs_on_send(self): # Only Message class has the send() method with self.assertRaises(ValueError): item = self.get_test_item() item.account = None item.send() # Must have account on send with self.assertRaises(ErrorItemNotFound): item = self.get_test_item() item.save() item_id, changekey = item.id, item.changekey item.delete() item.id, item.changekey = item_id, changekey item.send() # Item disappeared with self.assertRaises(AttributeError): item = self.get_test_item() item.send(copy_to_folder=self.account.trash, save_copy=False) # Inconsistent args def test_unsupported_fields(self): # Create a field that is not supported by any current versions. Test that we fail when using this field class UnsupportedProp(ExtendedProperty): property_set_id = 'deadcafe-beef-beef-beef-deadcafebeef' property_name = 'Unsupported Property' property_type = 'String' attr_name = 'unsupported_property' self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=UnsupportedProp) try: for f in self.ITEM_CLASS.FIELDS: if f.name == attr_name: f.supported_from = Build(99, 99, 99, 99) with self.assertRaises(ValueError): self.test_folder.get(**{attr_name: 'XXX'}) with self.assertRaises(ValueError): list(self.test_folder.filter(**{attr_name: 'XXX'})) with self.assertRaises(ValueError): list(self.test_folder.all().only(attr_name)) with self.assertRaises(ValueError): list(self.test_folder.all().values(attr_name)) with self.assertRaises(ValueError): list(self.test_folder.all().values_list(attr_name)) finally: self.ITEM_CLASS.deregister(attr_name=attr_name) def test_order_by(self): # Test order_by() on normal field test_items = [] for i in range(4): item = self.get_test_item() item.subject = 'Subj %s' % i test_items.append(item) self.test_folder.bulk_create(items=test_items) qs = QuerySet( folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) ).filter(categories__contains=self.categories) self.assertEqual( [i for i in qs.order_by('subject').values_list('subject', flat=True)], ['Subj 0', 'Subj 1', 'Subj 2', 'Subj 3'] ) self.assertEqual( [i for i in qs.order_by('-subject').values_list('subject', flat=True)], ['Subj 3', 'Subj 2', 'Subj 1', 'Subj 0'] ) self.bulk_delete(qs) try: self.ITEM_CLASS.register('extern_id', ExternId) # Test order_by() on ExtendedProperty test_items = [] for i in range(4): item = self.get_test_item() item.extern_id = 'ID %s' % i test_items.append(item) self.test_folder.bulk_create(items=test_items) qs = QuerySet( folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) ).filter(categories__contains=self.categories) self.assertEqual( [i for i in qs.order_by('extern_id').values_list('extern_id', flat=True)], ['ID 0', 'ID 1', 'ID 2', 'ID 3'] ) self.assertEqual( [i for i in qs.order_by('-extern_id').values_list('extern_id', flat=True)], ['ID 3', 'ID 2', 'ID 1', 'ID 0'] ) finally: self.ITEM_CLASS.deregister('extern_id') self.bulk_delete(qs) # Test sorting on multiple fields try: self.ITEM_CLASS.register('extern_id', ExternId) test_items = [] for i in range(2): for j in range(2): item = self.get_test_item() item.subject = 'Subj %s' % i item.extern_id = 'ID %s' % j test_items.append(item) self.test_folder.bulk_create(items=test_items) qs = QuerySet( folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) ).filter(categories__contains=self.categories) self.assertEqual( [i for i in qs.order_by('subject', 'extern_id').values('subject', 'extern_id')], [{'subject': 'Subj 0', 'extern_id': 'ID 0'}, {'subject': 'Subj 0', 'extern_id': 'ID 1'}, {'subject': 'Subj 1', 'extern_id': 'ID 0'}, {'subject': 'Subj 1', 'extern_id': 'ID 1'}] ) self.assertEqual( [i for i in qs.order_by('-subject', 'extern_id').values('subject', 'extern_id')], [{'subject': 'Subj 1', 'extern_id': 'ID 0'}, {'subject': 'Subj 1', 'extern_id': 'ID 1'}, {'subject': 'Subj 0', 'extern_id': 'ID 0'}, {'subject': 'Subj 0', 'extern_id': 'ID 1'}] ) self.assertEqual( [i for i in qs.order_by('subject', '-extern_id').values('subject', 'extern_id')], [{'subject': 'Subj 0', 'extern_id': 'ID 1'}, {'subject': 'Subj 0', 'extern_id': 'ID 0'}, {'subject': 'Subj 1', 'extern_id': 'ID 1'}, {'subject': 'Subj 1', 'extern_id': 'ID 0'}] ) self.assertEqual( [i for i in qs.order_by('-subject', '-extern_id').values('subject', 'extern_id')], [{'subject': 'Subj 1', 'extern_id': 'ID 1'}, {'subject': 'Subj 1', 'extern_id': 'ID 0'}, {'subject': 'Subj 0', 'extern_id': 'ID 1'}, {'subject': 'Subj 0', 'extern_id': 'ID 0'}] ) finally: self.ITEM_CLASS.deregister('extern_id') def test_finditems(self): now = UTC_NOW() # Test argument types item = self.get_test_item() ids = self.test_folder.bulk_create(items=[item]) # No arguments. There may be leftover items in the folder, so just make sure there's at least one. self.assertGreaterEqual( len(self.test_folder.filter()), 1 ) # Q object self.assertEqual( len(self.test_folder.filter(Q(subject=item.subject))), 1 ) # Multiple Q objects self.assertEqual( len(self.test_folder.filter(Q(subject=item.subject), ~Q(subject=item.subject[:-3] + 'XXX'))), 1 ) # Multiple Q object and kwargs self.assertEqual( len(self.test_folder.filter(Q(subject=item.subject), categories__contains=item.categories)), 1 ) self.bulk_delete(ids) # Test categories which are handled specially - only '__contains' and '__in' lookups are supported item = self.get_test_item(categories=['TestA', 'TestB']) ids = self.test_folder.bulk_create(items=[item]) common_qs = self.test_folder.filter(subject=item.subject) # Guard against other simultaneous runs self.assertEqual( len(common_qs.filter(categories__contains='ci6xahH1')), # Plain string 0 ) self.assertEqual( len(common_qs.filter(categories__contains=['ci6xahH1'])), # Same, but as list 0 ) self.assertEqual( len(common_qs.filter(categories__contains=['TestA', 'TestC'])), # One wrong category 0 ) self.assertEqual( len(common_qs.filter(categories__contains=['TESTA'])), # Test case insensitivity 1 ) self.assertEqual( len(common_qs.filter(categories__contains=['testa'])), # Test case insensitivity 1 ) self.assertEqual( len(common_qs.filter(categories__contains=['TestA'])), # Partial 1 ) self.assertEqual( len(common_qs.filter(categories__contains=item.categories)), # Exact match 1 ) with self.assertRaises(ValueError): len(common_qs.filter(categories__in='ci6xahH1')) # Plain string is not supported self.assertEqual( len(common_qs.filter(categories__in=['ci6xahH1'])), # Same, but as list 0 ) self.assertEqual( len(common_qs.filter(categories__in=['TestA', 'TestC'])), # One wrong category 1 ) self.assertEqual( len(common_qs.filter(categories__in=['TestA'])), # Partial 1 ) self.assertEqual( len(common_qs.filter(categories__in=item.categories)), # Exact match 1 ) self.bulk_delete(ids) common_qs = self.test_folder.filter(categories__contains=self.categories) one_hour = datetime.timedelta(hours=1) two_hours = datetime.timedelta(hours=2) # Test 'exists' ids = self.test_folder.bulk_create(items=[self.get_test_item()]) self.assertEqual( len(common_qs.filter(datetime_created__exists=True)), 1 ) self.assertEqual( len(common_qs.filter(datetime_created__exists=False)), 0 ) self.bulk_delete(ids) # Test 'range' ids = self.test_folder.bulk_create(items=[self.get_test_item()]) self.assertEqual( len(common_qs.filter(datetime_created__range=(now + one_hour, now + two_hours))), 0 ) self.assertEqual( len(common_qs.filter(datetime_created__range=(now - one_hour, now + one_hour))), 1 ) self.bulk_delete(ids) # Test '>' ids = self.test_folder.bulk_create(items=[self.get_test_item()]) self.assertEqual( len(common_qs.filter(datetime_created__gt=now + one_hour)), 0 ) self.assertEqual( len(common_qs.filter(datetime_created__gt=now - one_hour)), 1 ) self.bulk_delete(ids) # Test '>=' ids = self.test_folder.bulk_create(items=[self.get_test_item()]) self.assertEqual( len(common_qs.filter(datetime_created__gte=now + one_hour)), 0 ) self.assertEqual( len(common_qs.filter(datetime_created__gte=now - one_hour)), 1 ) self.bulk_delete(ids) # Test '<' ids = self.test_folder.bulk_create(items=[self.get_test_item()]) self.assertEqual( len(common_qs.filter(datetime_created__lt=now - one_hour)), 0 ) self.assertEqual( len(common_qs.filter(datetime_created__lt=now + one_hour)), 1 ) self.bulk_delete(ids) # Test '<=' ids = self.test_folder.bulk_create(items=[self.get_test_item()]) self.assertEqual( len(common_qs.filter(datetime_created__lte=now - one_hour)), 0 ) self.assertEqual( len(common_qs.filter(datetime_created__lte=now + one_hour)), 1 ) self.bulk_delete(ids) # Test '=' item = self.get_test_item() ids = self.test_folder.bulk_create(items=[item]) self.assertEqual( len(common_qs.filter(subject=item.subject[:-3] + 'XXX')), 0 ) self.assertEqual( len(common_qs.filter(subject=item.subject)), 1 ) self.bulk_delete(ids) # Test '!=' item = self.get_test_item() ids = self.test_folder.bulk_create(items=[item]) self.assertEqual( len(common_qs.filter(subject__not=item.subject)), 0 ) self.assertEqual( len(common_qs.filter(subject__not=item.subject[:-3] + 'XXX')), 1 ) self.bulk_delete(ids) # Test 'exact' item = self.get_test_item() item.subject = 'aA' + item.subject[2:] ids = self.test_folder.bulk_create(items=[item]) self.assertEqual( len(common_qs.filter(subject__exact=item.subject[:-3] + 'XXX')), 0 ) self.assertEqual( len(common_qs.filter(subject__exact=item.subject.lower())), 0 ) self.assertEqual( len(common_qs.filter(subject__exact=item.subject.upper())), 0 ) self.assertEqual( len(common_qs.filter(subject__exact=item.subject)), 1 ) self.bulk_delete(ids) # Test 'iexact' item = self.get_test_item() item.subject = 'aA' + item.subject[2:] ids = self.test_folder.bulk_create(items=[item]) self.assertEqual( len(common_qs.filter(subject__iexact=item.subject[:-3] + 'XXX')), 0 ) self.assertIn( len(common_qs.filter(subject__iexact=item.subject.lower())), (0, 1) # iexact search is broken on some EWS versions ) self.assertIn( len(common_qs.filter(subject__iexact=item.subject.upper())), (0, 1) # iexact search is broken on some EWS versions ) self.assertEqual( len(common_qs.filter(subject__iexact=item.subject)), 1 ) self.bulk_delete(ids) # Test 'contains' item = self.get_test_item() item.subject = item.subject[2:8] + 'aA' + item.subject[8:] ids = self.test_folder.bulk_create(items=[item]) self.assertEqual( len(common_qs.filter(subject__contains=item.subject[2:14] + 'XXX')), 0 ) self.assertEqual( len(common_qs.filter(subject__contains=item.subject[2:14].lower())), 0 ) self.assertEqual( len(common_qs.filter(subject__contains=item.subject[2:14].upper())), 0 ) self.assertEqual( len(common_qs.filter(subject__contains=item.subject[2:14])), 1 ) self.bulk_delete(ids) # Test 'icontains' item = self.get_test_item() item.subject = item.subject[2:8] + 'aA' + item.subject[8:] ids = self.test_folder.bulk_create(items=[item]) self.assertEqual( len(common_qs.filter(subject__icontains=item.subject[2:14] + 'XXX')), 0 ) self.assertIn( len(common_qs.filter(subject__icontains=item.subject[2:14].lower())), (0, 1) # icontains search is broken on some EWS versions ) self.assertIn( len(common_qs.filter(subject__icontains=item.subject[2:14].upper())), (0, 1) # icontains search is broken on some EWS versions ) self.assertEqual( len(common_qs.filter(subject__icontains=item.subject[2:14])), 1 ) self.bulk_delete(ids) # Test 'startswith' item = self.get_test_item() item.subject = 'aA' + item.subject[2:] ids = self.test_folder.bulk_create(items=[item]) self.assertEqual( len(common_qs.filter(subject__startswith='XXX' + item.subject[:12])), 0 ) self.assertEqual( len(common_qs.filter(subject__startswith=item.subject[:12].lower())), 0 ) self.assertEqual( len(common_qs.filter(subject__startswith=item.subject[:12].upper())), 0 ) self.assertEqual( len(common_qs.filter(subject__startswith=item.subject[:12])), 1 ) self.bulk_delete(ids) # Test 'istartswith' item = self.get_test_item() item.subject = 'aA' + item.subject[2:] ids = self.test_folder.bulk_create(items=[item]) self.assertEqual( len(common_qs.filter(subject__istartswith='XXX' + item.subject[:12])), 0 ) self.assertIn( len(common_qs.filter(subject__istartswith=item.subject[:12].lower())), (0, 1) # istartswith search is broken on some EWS versions ) self.assertIn( len(common_qs.filter(subject__istartswith=item.subject[:12].upper())), (0, 1) # istartswith search is broken on some EWS versions ) self.assertEqual( len(common_qs.filter(subject__istartswith=item.subject[:12])), 1 ) self.bulk_delete(ids) def test_filter_with_querystring(self): # QueryString is only supported from Exchange 2010 with self.assertRaises(NotImplementedError): Q('Subject:XXX').to_xml(self.test_folder, version=mock_version(build=EXCHANGE_2007), applies_to=Restriction.ITEMS) # We don't allow QueryString in combination with other restrictions with self.assertRaises(ValueError): self.test_folder.filter('Subject:XXX', foo='bar') with self.assertRaises(ValueError): self.test_folder.filter('Subject:XXX').filter(foo='bar') with self.assertRaises(ValueError): self.test_folder.filter(foo='bar').filter('Subject:XXX') item = self.get_test_item() item.subject = get_random_string(length=8, spaces=False, special=False) item.save() # For some reason, the querystring search doesn't work instantly. We may have to wait for up to 60 seconds. # I'm too impatient for that, so also allow empty results. This makes the test almost worthless but I blame EWS. self.assertIn( len(self.test_folder.filter('Subject:%s' % item.subject)), (0, 1) ) def test_complex_fields(self): # Test that complex fields can be fetched using only(). This is a test for #141. item = self.get_test_item().save() for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue if f.name in ('optional_attendees', 'required_attendees', 'resources'): continue if f.is_read_only: continue if f.name == 'reminder_due_by': # EWS sets a default value if it is not set on insert. Ignore continue if f.name == 'mime_content': # This will change depending on other contents fields continue old = getattr(item, f.name) # Test field as single element in only() fresh_item = self.test_folder.all().only(f.name).get(categories__contains=item.categories) new = getattr(fresh_item, f.name) if f.is_list: old, new = set(old or ()), set(new or ()) self.assertEqual(old, new, (f.name, old, new)) # Test field as one of the elements in only() fresh_item = self.test_folder.all().only('subject', f.name).get(categories__contains=item.categories) new = getattr(fresh_item, f.name) if f.is_list: old, new = set(old or ()), set(new or ()) self.assertEqual(old, new, (f.name, old, new)) def test_text_body(self): if self.account.version.build < EXCHANGE_2013: raise self.skipTest('Exchange version too old') item = self.get_test_item() item.body = 'X' * 500 # Make body longer than the normal 256 char text field limit item.save() fresh_item = self.test_folder.filter(categories__contains=item.categories).only('text_body')[0] self.assertEqual(fresh_item.text_body, item.body) def test_only_fields(self): item = self.get_test_item().save() item = self.test_folder.get(categories__contains=item.categories) self.assertIsInstance(item, self.ITEM_CLASS) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): self.assertTrue(hasattr(item, f.name)) if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue if f.name in ('optional_attendees', 'required_attendees', 'resources'): continue if f.name == 'reminder_due_by' and not item.reminder_is_set: # We delete the due date if reminder is not set continue elif f.is_read_only: continue self.assertIsNotNone(getattr(item, f.name), (f, getattr(item, f.name))) only_fields = ('subject', 'body', 'categories') item = self.test_folder.all().only(*only_fields).get(categories__contains=item.categories) self.assertIsInstance(item, self.ITEM_CLASS) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): self.assertTrue(hasattr(item, f.name)) if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue if f.name in only_fields: self.assertIsNotNone(getattr(item, f.name), (f.name, getattr(item, f.name))) elif f.is_required: v = getattr(item, f.name) if f.name == 'attachments': self.assertEqual(v, [], (f.name, v)) elif f.default is None: self.assertIsNone(v, (f.name, v)) else: self.assertEqual(v, f.default, (f.name, v)) def test_export_and_upload(self): # 15 new items which we will attempt to export and re-upload items = [self.get_test_item().save() for _ in range(15)] ids = [(i.id, i.changekey) for i in items] # re-fetch items because there will be some extra fields added by the server items = list(self.account.fetch(items)) # Try exporting and making sure we get the right response export_results = self.account.export(items) self.assertEqual(len(items), len(export_results)) for result in export_results: self.assertIsInstance(result, str) # Try reuploading our results upload_results = self.account.upload([(self.test_folder, data) for data in export_results]) self.assertEqual(len(items), len(upload_results), (items, upload_results)) for result in upload_results: # Must be a completely new ItemId self.assertIsInstance(result, tuple) self.assertNotIn(result, ids) # Check the items uploaded are the same as the original items def to_dict(item): dict_item = {} # fieldnames is everything except the ID so we'll use it to compare for f in item.FIELDS: # datetime_created and last_modified_time aren't copied, but instead are added to the new item after # uploading. This means mime_content and size can also change. Items also get new IDs on upload. And # meeting_count values are dependent on contents of current calendar. Form query strings contain the # item ID and will also change. if f.name in {'id', 'changekey', 'first_occurrence', 'last_occurrence', 'datetime_created', 'last_modified_time', 'mime_content', 'size', 'conversation_id', 'adjacent_meeting_count', 'conflicting_meeting_count', 'web_client_read_form_query_string', 'web_client_edit_form_query_string'}: continue dict_item[f.name] = getattr(item, f.name) if f.name == 'attachments': # Attachments get new IDs on upload. Wipe them here so we can compare the other fields for a in dict_item[f.name]: a.attachment_id = None return dict_item uploaded_items = sorted([to_dict(item) for item in self.account.fetch(upload_results)], key=lambda i: i['subject']) original_items = sorted([to_dict(item) for item in items], key=lambda i: i['subject']) self.assertListEqual(original_items, uploaded_items) def test_export_with_error(self): # 15 new items which we will attempt to export and re-upload items = [self.get_test_item().save() for _ in range(15)] # Use id tuples for export here because deleting an item clears it's # id. ids = [(item.id, item.changekey) for item in items] # Delete one of the items, this will cause an error items[3].delete() export_results = self.account.export(ids) self.assertEqual(len(items), len(export_results)) for idx, result in enumerate(export_results): if idx == 3: # If it is the one returning the error self.assertIsInstance(result, ErrorItemNotFound) else: self.assertIsInstance(result, str) # Clean up after yourself del ids[3] # Sending the deleted one through will cause an error def test_item_attachments(self): item = self.get_test_item(folder=self.test_folder) item.attachments = [] attached_item1 = self.get_test_item(folder=self.test_folder) attached_item1.attachments = [] attached_item1.save() attachment1 = ItemAttachment(name='attachment1', item=attached_item1) item.attach(attachment1) self.assertEqual(len(item.attachments), 1) item.save() fresh_item = list(self.account.fetch(ids=[item]))[0] self.assertEqual(len(fresh_item.attachments), 1) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) self.assertEqual(fresh_attachments[0].name, 'attachment1') self.assertIsInstance(fresh_attachments[0].item, self.ITEM_CLASS) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): # Normalize some values we don't control if f.is_read_only: continue if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): # Timezone fields will (and must) be populated automatically from the timestamp continue if isinstance(f, ExtendedPropertyField): # Attachments don't have these values. It may be possible to request it if we can find the FieldURI continue if f.name == 'is_read': # This is always true for item attachments? continue if f.name == 'reminder_due_by': # EWS sets a default value if it is not set on insert. Ignore continue if f.name == 'mime_content': # This will change depending on other contents fields continue old_val = getattr(attached_item1, f.name) new_val = getattr(fresh_attachments[0].item, f.name) if f.is_list: old_val, new_val = set(old_val or ()), set(new_val or ()) self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) # Test attach on saved object attached_item2 = self.get_test_item(folder=self.test_folder) attached_item2.attachments = [] attached_item2.save() attachment2 = ItemAttachment(name='attachment2', item=attached_item2) item.attach(attachment2) self.assertEqual(len(item.attachments), 2) fresh_item = list(self.account.fetch(ids=[item]))[0] self.assertEqual(len(fresh_item.attachments), 2) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) self.assertEqual(fresh_attachments[0].name, 'attachment1') self.assertIsInstance(fresh_attachments[0].item, self.ITEM_CLASS) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): # Normalize some values we don't control if f.is_read_only: continue if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): # Timezone fields will (and must) be populated automatically from the timestamp continue if isinstance(f, ExtendedPropertyField): # Attachments don't have these values. It may be possible to request it if we can find the FieldURI continue if f.name == 'reminder_due_by': # EWS sets a default value if it is not set on insert. Ignore continue if f.name == 'is_read': # This is always true for item attachments? continue if f.name == 'mime_content': # This will change depending on other contents fields continue old_val = getattr(attached_item1, f.name) new_val = getattr(fresh_attachments[0].item, f.name) if f.is_list: old_val, new_val = set(old_val or ()), set(new_val or ()) self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) self.assertEqual(fresh_attachments[1].name, 'attachment2') self.assertIsInstance(fresh_attachments[1].item, self.ITEM_CLASS) for f in self.ITEM_CLASS.FIELDS: # Normalize some values we don't control if f.is_read_only: continue if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): # Timezone fields will (and must) be populated automatically from the timestamp continue if isinstance(f, ExtendedPropertyField): # Attachments don't have these values. It may be possible to request it if we can find the FieldURI continue if f.name == 'reminder_due_by': # EWS sets a default value if it is not set on insert. Ignore continue if f.name == 'is_read': # This is always true for item attachments? continue if f.name == 'mime_content': # This will change depending on other contents fields continue old_val = getattr(attached_item2, f.name) new_val = getattr(fresh_attachments[1].item, f.name) if f.is_list: old_val, new_val = set(old_val or ()), set(new_val or ()) self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) # Test detach item.detach(attachment2) self.assertTrue(attachment2.attachment_id is None) self.assertTrue(attachment2.parent_item is None) fresh_item = list(self.account.fetch(ids=[item]))[0] self.assertEqual(len(fresh_item.attachments), 1) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): # Normalize some values we don't control if f.is_read_only: continue if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): # Timezone fields will (and must) be populated automatically from the timestamp continue if isinstance(f, ExtendedPropertyField): # Attachments don't have these values. It may be possible to request it if we can find the FieldURI continue if f.name == 'reminder_due_by': # EWS sets a default value if it is not set on insert. Ignore continue if f.name == 'is_read': # This is always true for item attachments? continue if f.name == 'mime_content': # This will change depending on other contents fields continue old_val = getattr(attached_item1, f.name) new_val = getattr(fresh_attachments[0].item, f.name) if f.is_list: old_val, new_val = set(old_val or ()), set(new_val or ()) self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) # Test attach with non-saved item attached_item3 = self.get_test_item(folder=self.test_folder) attached_item3.attachments = [] attachment3 = ItemAttachment(name='attachment2', item=attached_item3) item.attach(attachment3) item.detach(attachment3) class CalendarTest(CommonItemTest): TEST_FOLDER = 'calendar' FOLDER_CLASS = Calendar ITEM_CLASS = CalendarItem def test_updating_timestamps(self): # Test that we can update an item without changing anything, and maintain the hidden timezone fields as local # timezones, and that returned timestamps are in UTC. item = self.get_test_item() item.reminder_is_set = True item.is_all_day = False item.save() for i in self.account.calendar.filter(categories__contains=self.categories).only('start', 'end', 'categories'): self.assertEqual(i.start, item.start) self.assertEqual(i.start.tzinfo, UTC) self.assertEqual(i.end, item.end) self.assertEqual(i.end.tzinfo, UTC) self.assertEqual(i._start_timezone, self.account.default_timezone) self.assertEqual(i._end_timezone, self.account.default_timezone) i.save(update_fields=['start', 'end']) self.assertEqual(i.start, item.start) self.assertEqual(i.start.tzinfo, UTC) self.assertEqual(i.end, item.end) self.assertEqual(i.end.tzinfo, UTC) self.assertEqual(i._start_timezone, self.account.default_timezone) self.assertEqual(i._end_timezone, self.account.default_timezone) for i in self.account.calendar.filter(categories__contains=self.categories).only('start', 'end', 'categories'): self.assertEqual(i.start, item.start) self.assertEqual(i.start.tzinfo, UTC) self.assertEqual(i.end, item.end) self.assertEqual(i.end.tzinfo, UTC) self.assertEqual(i._start_timezone, self.account.default_timezone) self.assertEqual(i._end_timezone, self.account.default_timezone) i.delete() def test_update_to_non_utc_datetime(self): # Test updating with non-UTC datetime values. This is a separate code path in UpdateItem code item = self.get_test_item() item.reminder_is_set = True item.is_all_day = False item.save() # Update start, end and recurrence with timezoned datetimes. For some reason, EWS throws # 'ErrorOccurrenceTimeSpanTooBig' is we go back in time. start = get_random_date(start_date=item.start.date() + datetime.timedelta(days=1)) dt_start, dt_end = [ dt.astimezone(self.account.default_timezone) for dt in get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) ] item.start, item.end = dt_start, dt_end item.recurrence.boundary.start = dt_start.date() item.save() item.refresh() self.assertEqual(item.start, dt_start) self.assertEqual(item.end, dt_end) def test_all_day_datetimes(self): # Test that start and end datetimes for all-day items are returned in the datetime of the account. start = get_random_date() start_dt, end_dt = get_random_datetime_range( start_date=start, end_date=start + datetime.timedelta(days=365), tz=self.account.default_timezone ) item = self.ITEM_CLASS(folder=self.test_folder, start=start_dt, end=end_dt, is_all_day=True, categories=self.categories) item.save() item = self.test_folder.all().only('start', 'end').get(id=item.id, changekey=item.changekey) self.assertEqual(item.start.astimezone(self.account.default_timezone).time(), datetime.time(0, 0)) self.assertEqual(item.end.astimezone(self.account.default_timezone).time(), datetime.time(0, 0)) def test_view(self): item1 = self.ITEM_CLASS( account=self.account, folder=self.test_folder, subject=get_random_string(16), start=self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 8)), end=self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 10)), categories=self.categories, ) item2 = self.ITEM_CLASS( account=self.account, folder=self.test_folder, subject=get_random_string(16), start=self.account.default_timezone.localize(EWSDateTime(2016, 2, 1, 8)), end=self.account.default_timezone.localize(EWSDateTime(2016, 2, 1, 10)), categories=self.categories, ) self.test_folder.bulk_create(items=[item1, item2]) # Test missing args with self.assertRaises(TypeError): self.test_folder.view() # Test bad args with self.assertRaises(ValueError): list(self.test_folder.view(start=item1.end, end=item1.start)) with self.assertRaises(TypeError): list(self.test_folder.view(start='xxx', end=item1.end)) with self.assertRaises(ValueError): list(self.test_folder.view(start=item1.start, end=item1.end, max_items=0)) def match_cat(i): return set(i.categories) == set(self.categories) # Test dates self.assertEqual( len([i for i in self.test_folder.view(start=item1.start, end=item1.end) if match_cat(i)]), 1 ) self.assertEqual( len([i for i in self.test_folder.view(start=item1.start, end=item2.end) if match_cat(i)]), 2 ) # Edge cases. Get view from end of item1 to start of item2. Should logically return 0 items, but Exchange wants # it differently and returns item1 even though there is no overlap. self.assertEqual( len([i for i in self.test_folder.view(start=item1.end, end=item2.start) if match_cat(i)]), 1 ) self.assertEqual( len([i for i in self.test_folder.view(start=item1.start, end=item2.start) if match_cat(i)]), 1 ) # Test max_items self.assertEqual( len([i for i in self.test_folder.view(start=item1.start, end=item2.end, max_items=9999) if match_cat(i)]), 2 ) self.assertEqual( len(self.test_folder.view(start=item1.start, end=item2.end, max_items=1)), 1 ) # Test chaining qs = self.test_folder.view(start=item1.start, end=item2.end) self.assertTrue(qs.count() >= 2) with self.assertRaises(ErrorInvalidOperation): qs.filter(subject=item1.subject).count() # EWS does not allow restrictions self.assertListEqual( [i for i in qs.order_by('subject').values('subject') if i['subject'] in (item1.subject, item2.subject)], [{'subject': s} for s in sorted([item1.subject, item2.subject])] ) class MessagesTest(CommonItemTest): # Just test one of the Message-type folders TEST_FOLDER = 'inbox' FOLDER_CLASS = Inbox ITEM_CLASS = Message INCOMING_MESSAGE_TIMEOUT = 20 def get_incoming_message(self, subject): t1 = time.monotonic() while True: t2 = time.monotonic() if t2 - t1 > self.INCOMING_MESSAGE_TIMEOUT: raise self.skipTest('Too bad. Gave up in %s waiting for the incoming message to show up' % self.id()) try: return self.account.inbox.get(subject=subject) except DoesNotExist: time.sleep(5) def test_send(self): # Test that we can send (only) Message items item = self.get_test_item() item.folder = None item.send() self.assertIsNone(item.id) self.assertIsNone(item.changekey) self.assertEqual(len(self.test_folder.filter(categories__contains=item.categories)), 0) def test_send_and_save(self): # Test that we can send_and_save Message items item = self.get_test_item() item.send_and_save() self.assertIsNone(item.id) self.assertIsNone(item.changekey) time.sleep(5) # Requests are supposed to be transactional, but apparently not... # Also, the sent item may be followed by an automatic message with the same category self.assertGreaterEqual(len(self.test_folder.filter(categories__contains=item.categories)), 1) # Test update, although it makes little sense item = self.get_test_item() item.save() item.send_and_save() time.sleep(5) # Requests are supposed to be transactional, but apparently not... # Also, the sent item may be followed by an automatic message with the same category self.assertGreaterEqual(len(self.test_folder.filter(categories__contains=item.categories)), 1) def test_send_draft(self): item = self.get_test_item() item.folder = self.account.drafts item.is_draft = True item.save() # Save a draft item.send() # Send the draft self.assertIsNone(item.id) self.assertIsNone(item.changekey) self.assertIsNone(item.folder) self.assertEqual(len(self.test_folder.filter(categories__contains=item.categories)), 0) def test_send_and_copy_to_folder(self): item = self.get_test_item() item.send(save_copy=True, copy_to_folder=self.account.sent) # Send the draft and save to the sent folder self.assertIsNone(item.id) self.assertIsNone(item.changekey) self.assertEqual(item.folder, self.account.sent) time.sleep(5) # Requests are supposed to be transactional, but apparently not... self.assertEqual(len(self.account.sent.filter(categories__contains=item.categories)), 1) def test_bulk_send(self): with self.assertRaises(AttributeError): self.account.bulk_send(ids=[], save_copy=False, copy_to_folder=self.account.trash) item = self.get_test_item() item.save() for res in self.account.bulk_send(ids=[item]): self.assertEqual(res, True) time.sleep(10) # Requests are supposed to be transactional, but apparently not... # By default, sent items are placed in the sent folder ids = self.account.sent.filter(categories__contains=item.categories).values_list('id', 'changekey') self.assertEqual(len(ids), 1) def test_reply(self): # Test that we can reply to a Message item. EWS only allows items that have been sent to receive a reply item = self.get_test_item() item.folder = None item.send() # get_test_item() sets the to_recipients to the test account sent_item = self.get_incoming_message(item.subject) new_subject = ('Re: %s' % sent_item.subject)[:255] sent_item.reply(subject=new_subject, body='Hello reply', to_recipients=[item.author]) reply = self.get_incoming_message(new_subject) self.account.bulk_delete([sent_item, reply]) def test_reply_all(self): # Test that we can reply-all a Message item. EWS only allows items that have been sent to receive a reply item = self.get_test_item(folder=None) item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) new_subject = ('Re: %s' % sent_item.subject)[:255] sent_item.reply_all(subject=new_subject, body='Hello reply') reply = self.get_incoming_message(new_subject) self.account.bulk_delete([sent_item, reply]) def test_forward(self): # Test that we can forward a Message item. EWS only allows items that have been sent to receive a reply item = self.get_test_item(folder=None) item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) new_subject = ('Re: %s' % sent_item.subject)[:255] sent_item.forward(subject=new_subject, body='Hello reply', to_recipients=[item.author]) reply = self.get_incoming_message(new_subject) reply2 = sent_item.create_forward(subject=new_subject, body='Hello reply', to_recipients=[item.author]) reply2 = reply2.save(self.account.drafts) self.assertIsInstance(reply2, Message) self.account.bulk_delete([sent_item, reply, reply2]) def test_mime_content(self): # Tests the 'mime_content' field subject = get_random_string(16) msg = MIMEMultipart() msg['From'] = self.account.primary_smtp_address msg['To'] = self.account.primary_smtp_address msg['Subject'] = subject body = 'MIME test mail' msg.attach(MIMEText(body, 'plain', _charset='utf-8')) mime_content = msg.as_bytes() item = self.ITEM_CLASS( folder=self.test_folder, to_recipients=[self.account.primary_smtp_address], mime_content=mime_content, categories=self.categories, ).save() self.assertEqual(self.test_folder.get(subject=subject).body, body) class TasksTest(CommonItemTest): TEST_FOLDER = 'tasks' FOLDER_CLASS = Tasks ITEM_CLASS = Task def test_task_validation(self): tz = EWSTimeZone.timezone('Europe/Copenhagen') task = Task(due_date=tz.localize(EWSDateTime(2017, 1, 1)), start_date=tz.localize(EWSDateTime(2017, 2, 1))) task.clean() # We reset due date if it's before start date self.assertEqual(task.due_date, tz.localize(EWSDateTime(2017, 2, 1))) self.assertEqual(task.due_date, task.start_date) task = Task(complete_date=tz.localize(EWSDateTime(2099, 1, 1)), status=Task.NOT_STARTED) task.clean() # We reset status if complete_date is set self.assertEqual(task.status, Task.COMPLETED) # We also reset complete date to now() if it's in the future self.assertEqual(task.complete_date.date(), UTC_NOW().date()) task = Task(complete_date=tz.localize(EWSDateTime(2017, 1, 1)), start_date=tz.localize(EWSDateTime(2017, 2, 1))) task.clean() # We also reset complete date to start_date if it's before start_date self.assertEqual(task.complete_date, task.start_date) task = Task(percent_complete=Decimal('50.0'), status=Task.COMPLETED) task.clean() # We reset percent_complete to 100.0 if state is completed self.assertEqual(task.percent_complete, Decimal(100)) task = Task(percent_complete=Decimal('50.0'), status=Task.NOT_STARTED) task.clean() # We reset percent_complete to 0.0 if state is not_started self.assertEqual(task.percent_complete, Decimal(0)) def test_complete(self): item = self.get_test_item().save() item.refresh() self.assertNotEqual(item.status, Task.COMPLETED) self.assertNotEqual(item.percent_complete, Decimal(100)) item.complete() item.refresh() self.assertEqual(item.status, Task.COMPLETED) self.assertEqual(item.percent_complete, Decimal(100)) class ContactsTest(CommonItemTest): TEST_FOLDER = 'contacts' FOLDER_CLASS = Contacts ITEM_CLASS = Contact def test_order_by_on_indexed_field(self): # Test order_by() on IndexedField (simple and multi-subfield). Only Contact items have these test_items = [] label = self.random_val(EmailAddress.get_field_by_fieldname('label')) for i in range(4): item = self.get_test_item() item.email_addresses = [EmailAddress(email='%s@foo.com' % i, label=label)] test_items.append(item) self.test_folder.bulk_create(items=test_items) qs = QuerySet( folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) ).filter(categories__contains=self.categories) self.assertEqual( [i[0].email for i in qs.order_by('email_addresses__%s' % label) .values_list('email_addresses', flat=True)], ['0@foo.com', '1@foo.com', '2@foo.com', '3@foo.com'] ) self.assertEqual( [i[0].email for i in qs.order_by('-email_addresses__%s' % label) .values_list('email_addresses', flat=True)], ['3@foo.com', '2@foo.com', '1@foo.com', '0@foo.com'] ) self.bulk_delete(qs) test_items = [] label = self.random_val(PhysicalAddress.get_field_by_fieldname('label')) for i in range(4): item = self.get_test_item() item.physical_addresses = [PhysicalAddress(street='Elm St %s' % i, label=label)] test_items.append(item) self.test_folder.bulk_create(items=test_items) qs = QuerySet( folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) ).filter(categories__contains=self.categories) self.assertEqual( [i[0].street for i in qs.order_by('physical_addresses__%s__street' % label) .values_list('physical_addresses', flat=True)], ['Elm St 0', 'Elm St 1', 'Elm St 2', 'Elm St 3'] ) self.assertEqual( [i[0].street for i in qs.order_by('-physical_addresses__%s__street' % label) .values_list('physical_addresses', flat=True)], ['Elm St 3', 'Elm St 2', 'Elm St 1', 'Elm St 0'] ) self.bulk_delete(qs) def test_order_by_failure(self): # Test error handling on indexed properties with labels and subfields qs = QuerySet( folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) ).filter(categories__contains=self.categories) with self.assertRaises(ValueError): qs.order_by('email_addresses') # Must have label with self.assertRaises(ValueError): qs.order_by('email_addresses__FOO') # Must have a valid label with self.assertRaises(ValueError): qs.order_by('email_addresses__EmailAddress1__FOO') # Must not have a subfield with self.assertRaises(ValueError): qs.order_by('physical_addresses__Business') # Must have a subfield with self.assertRaises(ValueError): qs.order_by('physical_addresses__Business__FOO') # Must have a valid subfield def test_distribution_lists(self): dl = DistributionList(folder=self.test_folder, display_name=get_random_string(255), categories=self.categories) dl.save() new_dl = self.test_folder.get(categories__contains=dl.categories) self.assertEqual(new_dl.display_name, dl.display_name) self.assertEqual(new_dl.members, None) dl.refresh() dl.members = set( # We set mailbox_type to OneOff because otherwise the email address must be an actual account Member(mailbox=Mailbox(email_address=get_random_email(), mailbox_type='OneOff')) for _ in range(4) ) dl.save() new_dl = self.test_folder.get(categories__contains=dl.categories) self.assertEqual({m.mailbox.email_address for m in new_dl.members}, dl.members) dl.delete() def test_find_people(self): # The test server may not have any contacts. Just test that the FindPeople service and helpers work self.assertGreaterEqual(len(list(self.test_folder.people())), 0) self.assertGreaterEqual( len(list( self.test_folder.people().only('display_name').filter(display_name='john').order_by('display_name') )), 0 ) def test_get_persona(self): # The test server may not have any personas. Just test that the service response with something we can parse persona = Persona(id='AAA=', changekey='xxx') try: GetPersona(protocol=self.account.protocol).call(persona=persona) except ErrorInvalidIdMalformed: pass exchangelib-3.1.1/tests/test_properties.py000066400000000000000000000215551361226005600207450ustar00rootroot00000000000000from inspect import isclass from itertools import chain from exchangelib import Folder, HTMLBody, Body, Mailbox, DLMailbox, UID, ItemId, Version from exchangelib.fields import TextField from exchangelib.folders import RootOfHierarchy from exchangelib.indexed_properties import PhysicalAddress from exchangelib.items import Item, BulkCreateResult from exchangelib.properties import InvalidField, InvalidFieldForVersion, EWSElement, MessageHeader from exchangelib.util import to_xml, TNS from exchangelib.version import EXCHANGE_2010, EXCHANGE_2013 from .common import TimedTestCase class PropertiesTest(TimedTestCase): def test_unique_field_names(self): from exchangelib import attachments, properties, items, folders, indexed_properties, recurrence, settings for module in (attachments, properties, items, folders, indexed_properties, recurrence, settings): for cls in vars(module).values(): with self.subTest(cls=cls): if not isclass(cls) or not issubclass(cls, EWSElement): continue # Assert that all FIELDS names are unique on the model. Also assert that the class defines __slots__, # that all fields are mentioned in __slots__ and that __slots__ is unique. field_names = set() all_slots = tuple(chain(*(getattr(c, '__slots__', ()) for c in cls.__mro__))) self.assertEqual(len(all_slots), len(set(all_slots)), '__slots__ contains duplicates: %s' % sorted(all_slots)) for f in cls.FIELDS: with self.subTest(f=f): self.assertNotIn(f.name, field_names, 'Field name %r is not unique on model %r' % (f.name, cls.__name__)) self.assertIn(f.name, all_slots, 'Field name %s is not in __slots__ on model %s' % (f.name, cls.__name__)) field_names.add(f.name) # Finally, test that all models have a link to MSDN documentation if issubclass(cls, Folder): # We have a long list of folders subclasses. Don't require a docstring for each continue self.assertIsNotNone(cls.__doc__, '%s is missing a docstring' % cls) if cls in (DLMailbox, BulkCreateResult): # Some classes are just workarounds for other classes continue if cls.__doc__.startswith('Base class '): # Base classes don't have an MSDN link continue if issubclass(cls, RootOfHierarchy): # Root folders don't have an MSDN link continue # collapse multiline docstrings docstring = ' '.join(l.strip() for l in cls.__doc__.split('\n')) self.assertIn('MSDN: https://docs.microsoft.com', docstring, '%s is missing an MSDN link in the docstring' % cls) def test_uid(self): # Test translation of calendar UIDs. See #453 self.assertEqual( UID('261cbc18-1f65-5a0a-bd11-23b1e224cc2f'), b'\x04\x00\x00\x00\x82\x00\xe0\x00t\xc5\xb7\x10\x1a\x82\xe0\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x001\x00\x00\x00vCal-Uid\x01\x00\x00\x00' b'261cbc18-1f65-5a0a-bd11-23b1e224cc2f\x00' ) def test_internet_message_headers(self): # Message headers are read-only, and an integration test is difficult because we can't reliably AND quickly # generate emails that pass through some relay server that adds headers. Create a unit test instead. payload = b'''\ from foo by bar Hello from DKIM 1.0 Contoso Mail foo@example.com ''' headers_elem = to_xml(payload).find('{%s}InternetMessageHeaders' % TNS) headers = {} for elem in headers_elem.findall('{%s}InternetMessageHeader' % TNS): header = MessageHeader.from_xml(elem=elem, account=None) headers[header.name] = header.value self.assertDictEqual( headers, { 'Received': 'from foo by bar', 'DKIM-Signature': 'Hello from DKIM', 'MIME-Version': '1.0', 'X-Mailer': 'Contoso Mail', 'Return-Path': 'foo@example.com', } ) def test_physical_address(self): # Test that we can enter an integer zipcode and that it's converted to a string by clean() zipcode = 98765 addr = PhysicalAddress(zipcode=zipcode) addr.clean() self.assertEqual(addr.zipcode, str(zipcode)) def test_invalid_kwargs(self): with self.assertRaises(AttributeError): Mailbox(foo='XXX') def test_invalid_field(self): test_field = Item.get_field_by_fieldname(fieldname='text_body') self.assertIsInstance(test_field, TextField) self.assertEqual(test_field.name, 'text_body') with self.assertRaises(InvalidField): Item.get_field_by_fieldname(fieldname='xxx') Item.validate_field(field=test_field, version=Version(build=EXCHANGE_2013)) with self.assertRaises(InvalidFieldForVersion) as e: Item.validate_field(field=test_field, version=Version(build=EXCHANGE_2010)) self.assertEqual( e.exception.args[0], "Field 'text_body' is not supported on server version Build=14.0.0.0, API=Exchange2010, Fullname=Microsoft " "Exchange Server 2010 (supported from: 15.0.0.0, deprecated from: None)" ) def test_add_field(self): field = TextField('foo', field_uri='bar') Item.add_field(field, insert_after='subject') try: self.assertEqual(Item.get_field_by_fieldname('foo'), field) finally: Item.remove_field(field) def test_itemid_equality(self): self.assertEqual(ItemId('X', 'Y'), ItemId('X', 'Y')) self.assertNotEqual(ItemId('X', 'Y'), ItemId('X', 'Z')) self.assertNotEqual(ItemId('Z', 'Y'), ItemId('X', 'Y')) self.assertNotEqual(ItemId('X', 'Y'), ItemId('Z', 'Z')) self.assertNotEqual(ItemId('X', 'Y'), None) def test_mailbox(self): mbx = Mailbox(name='XXX') with self.assertRaises(ValueError): mbx.clean() # Must have either item_id or email_address set mbx = Mailbox(email_address='XXX') self.assertEqual(hash(mbx), hash('xxx')) mbx.item_id = 'YYY' self.assertEqual(hash(mbx), hash('YYY')) # If we have an item_id, use that for uniqueness def test_body(self): # Test that string formatting a Body and HTMLBody instance works and keeps the type self.assertEqual(str(Body('foo')), 'foo') self.assertEqual(str(Body('%s') % 'foo'), 'foo') self.assertEqual(str(Body('{}').format('foo')), 'foo') self.assertIsInstance(Body('foo'), Body) self.assertIsInstance(Body('') + 'foo', Body) foo = Body('') foo += 'foo' self.assertIsInstance(foo, Body) self.assertIsInstance(Body('%s') % 'foo', Body) self.assertIsInstance(Body('{}').format('foo'), Body) self.assertEqual(str(HTMLBody('foo')), 'foo') self.assertEqual(str(HTMLBody('%s') % 'foo'), 'foo') self.assertEqual(str(HTMLBody('{}').format('foo')), 'foo') self.assertIsInstance(HTMLBody('foo'), HTMLBody) self.assertIsInstance(HTMLBody('') + 'foo', HTMLBody) foo = HTMLBody('') foo += 'foo' self.assertIsInstance(foo, HTMLBody) self.assertIsInstance(HTMLBody('%s') % 'foo', HTMLBody) self.assertIsInstance(HTMLBody('{}').format('foo'), HTMLBody) def test_invalid_attribute(self): # For a random EWSElement subclass, test that we cannot assign an unsupported attribute item = ItemId(id='xxx', changekey='yyy') with self.assertRaises(AttributeError) as e: item.invalid_attr = 123 self.assertEqual( e.exception.args[0], "'invalid_attr' is not a valid attribute. See ItemId.FIELDS for valid field names" ) exchangelib-3.1.1/tests/test_protocol.py000066400000000000000000000565371361226005600204220ustar00rootroot00000000000000import datetime import os import socket import tempfile import warnings import psutil import requests_mock from exchangelib import Version, NTLM, FailFast, Credentials, Configuration, OofSettings, EWSTimeZone, EWSDateTime, \ EWSDate, Mailbox, DLMailbox, UTC, CalendarItem from exchangelib.errors import SessionPoolMinSizeReached, ErrorNameResolutionNoResults, ErrorAccessDenied, \ TransportError from exchangelib.properties import TimeZone, RoomList, FreeBusyView, Room, AlternateId, ID_FORMATS, EWS_ID from exchangelib.protocol import Protocol, BaseProtocol, NoVerifyHTTPAdapter from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames from exchangelib.transport import NOAUTH from exchangelib.version import Build from exchangelib.winzone import CLDR_TO_MS_TIMEZONE_MAP from .common import EWSTest, MockResponse, get_random_datetime_range class ProtocolTest(EWSTest): @requests_mock.mock() def test_session(self, m): m.get('https://example.com/EWS/types.xsd', status_code=200) protocol = Protocol(config=Configuration( service_endpoint='https://example.com/Foo.asmx', credentials=Credentials('A', 'B'), auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() )) session = protocol.create_session() new_session = protocol.renew_session(session) self.assertNotEqual(id(session), id(new_session)) @requests_mock.mock() def test_protocol_instance_caching(self, m): # Verify that we get the same Protocol instance for the same combination of (endpoint, credentials) m.get('https://example.com/EWS/types.xsd', status_code=200) base_p = Protocol(config=Configuration( service_endpoint='https://example.com/Foo.asmx', credentials=Credentials('A', 'B'), auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() )) for i in range(10): p = Protocol(config=Configuration( service_endpoint='https://example.com/Foo.asmx', credentials=Credentials('A', 'B'), auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() )) self.assertEqual(base_p, p) self.assertEqual(id(base_p), id(p)) self.assertEqual(hash(base_p), hash(p)) self.assertEqual(id(base_p.thread_pool), id(p.thread_pool)) self.assertEqual(id(base_p._session_pool), id(p._session_pool)) def test_close(self): proc = psutil.Process() ip_addresses = {info[4][0] for info in socket.getaddrinfo( 'example.com', 80, socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_IP )} self.assertGreater(len(ip_addresses), 0) protocol = Protocol(config=Configuration( service_endpoint='http://example.com', credentials=Credentials('A', 'B'), auth_type=NOAUTH, version=Version(Build(15, 1)), retry_policy=FailFast() )) session = protocol.get_session() session.get('http://example.com') self.assertEqual(len({p.raddr[0] for p in proc.connections() if p.raddr[0] in ip_addresses}), 1) protocol.release_session(session) protocol.close() self.assertEqual(len({p.raddr[0] for p in proc.connections() if p.raddr[0] in ip_addresses}), 0) def test_poolsize(self): self.assertEqual(self.account.protocol.SESSION_POOLSIZE, 4) def test_decrease_poolsize(self): protocol = Protocol(config=Configuration( service_endpoint='https://example.com/Foo.asmx', credentials=Credentials('A', 'B'), auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() )) self.assertEqual(protocol._session_pool.qsize(), Protocol.SESSION_POOLSIZE) protocol.decrease_poolsize() self.assertEqual(protocol._session_pool.qsize(), 3) protocol.decrease_poolsize() self.assertEqual(protocol._session_pool.qsize(), 2) protocol.decrease_poolsize() self.assertEqual(protocol._session_pool.qsize(), 1) with self.assertRaises(SessionPoolMinSizeReached): protocol.decrease_poolsize() self.assertEqual(protocol._session_pool.qsize(), 1) def test_get_timezones(self): ws = GetServerTimeZones(self.account.protocol) data = ws.call() self.assertAlmostEqual(len(list(data)), 130, delta=30, msg=data) # Test shortcut self.assertAlmostEqual(len(list(self.account.protocol.get_timezones())), 130, delta=30, msg=data) # Test translation to TimeZone objects for tz_id, tz_name, periods, transitions, transitionsgroups in self.account.protocol.get_timezones( return_full_timezone_data=True): TimeZone.from_server_timezone(periods=periods, transitions=transitions, transitionsgroups=transitionsgroups, for_year=2018) def test_get_free_busy_info(self): tz = EWSTimeZone.timezone('Europe/Copenhagen') server_timezones = list(self.account.protocol.get_timezones(return_full_timezone_data=True)) start = tz.localize(EWSDateTime.now()) end = tz.localize(EWSDateTime.now() + datetime.timedelta(hours=6)) accounts = [(self.account, 'Organizer', False)] with self.assertRaises(ValueError): self.account.protocol.get_free_busy_info(accounts=[('XXX', 'XXX', 'XXX')], start=0, end=0) with self.assertRaises(ValueError): self.account.protocol.get_free_busy_info(accounts=[(self.account, 'XXX', 'XXX')], start=0, end=0) with self.assertRaises(ValueError): self.account.protocol.get_free_busy_info(accounts=[(self.account, 'Organizer', 'XXX')], start=0, end=0) with self.assertRaises(ValueError): self.account.protocol.get_free_busy_info(accounts=accounts, start=end, end=start) with self.assertRaises(ValueError): self.account.protocol.get_free_busy_info(accounts=accounts, start=start, end=end, merged_free_busy_interval='XXX') with self.assertRaises(ValueError): self.account.protocol.get_free_busy_info(accounts=accounts, start=start, end=end, requested_view='XXX') for view_info in self.account.protocol.get_free_busy_info(accounts=accounts, start=start, end=end): self.assertIsInstance(view_info, FreeBusyView) self.assertIsInstance(view_info.working_hours_timezone, TimeZone) ms_id = view_info.working_hours_timezone.to_server_timezone(server_timezones, start.year) self.assertIn(ms_id, {t[0] for t in CLDR_TO_MS_TIMEZONE_MAP.values()}) def test_get_roomlists(self): # The test server is not guaranteed to have any room lists which makes this test less useful ws = GetRoomLists(self.account.protocol) roomlists = ws.call() self.assertEqual(list(roomlists), []) # Test shortcut self.assertEqual(list(self.account.protocol.get_roomlists()), []) def test_get_roomlists_parsing(self): # Test static XML since server has no roomlists ws = GetRoomLists(self.account.protocol) xml = b'''\ NoError Roomlist roomlist1@example.com SMTP PublicDL Roomlist roomlist2@example.com SMTP PublicDL ''' header, body = ws._get_soap_parts(response=MockResponse(xml)) res = ws._get_elements_in_response(response=ws._get_soap_messages(body=body)) self.assertSetEqual( {RoomList.from_xml(elem=elem, account=None).email_address for elem in res}, {'roomlist1@example.com', 'roomlist2@example.com'} ) def test_get_rooms(self): # The test server is not guaranteed to have any rooms or room lists which makes this test less useful roomlist = RoomList(email_address='my.roomlist@example.com') ws = GetRooms(self.account.protocol) with self.assertRaises(ErrorNameResolutionNoResults): list(ws.call(roomlist=roomlist)) # Test shortcut with self.assertRaises(ErrorNameResolutionNoResults): list(self.account.protocol.get_rooms('my.roomlist@example.com')) def test_get_rooms_parsing(self): # Test static XML since server has no rooms ws = GetRooms(self.account.protocol) xml = b'''\ NoError room1 room1@example.com SMTP Mailbox room2 room2@example.com SMTP Mailbox ''' header, body = ws._get_soap_parts(response=MockResponse(xml)) res = ws._get_elements_in_response(response=ws._get_soap_messages(body=body)) self.assertSetEqual( {Room.from_xml(elem=elem, account=None).email_address for elem in res}, {'room1@example.com', 'room2@example.com'} ) def test_resolvenames(self): with self.assertRaises(ValueError): self.account.protocol.resolve_names(names=[], search_scope='XXX') with self.assertRaises(ValueError): self.account.protocol.resolve_names(names=[], shape='XXX') self.assertGreaterEqual( self.account.protocol.resolve_names(names=['xxx@example.com']), [] ) self.assertEqual( self.account.protocol.resolve_names(names=[self.account.primary_smtp_address]), [Mailbox(email_address=self.account.primary_smtp_address)] ) # Test something that's not an email self.assertEqual( self.account.protocol.resolve_names(names=['foo\\bar']), [] ) # Test return_full_contact_data mailbox, contact = self.account.protocol.resolve_names( names=[self.account.primary_smtp_address], return_full_contact_data=True )[0] self.assertEqual( mailbox, Mailbox(email_address=self.account.primary_smtp_address) ) self.assertListEqual( [e.email.replace('SMTP:', '') for e in contact.email_addresses if e.label == 'EmailAddress1'], [self.account.primary_smtp_address] ) def test_resolvenames_parsing(self): # Test static XML since server has no roomlists ws = ResolveNames(self.account.protocol) xml = b'''\ Multiple results were found. ErrorNameResolutionMultipleResults 0 John Doe anne@example.com SMTP Mailbox John Deer john@example.com SMTP Mailbox ''' header, body = ws._get_soap_parts(response=MockResponse(xml)) res = ws._get_elements_in_response(response=ws._get_soap_messages(body=body)) self.assertSetEqual( {Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None).email_address for elem in res}, {'anne@example.com', 'john@example.com'} ) def test_get_searchable_mailboxes(self): # Insufficient privileges for the test account, so let's just test the exception with self.assertRaises(ErrorAccessDenied): self.account.protocol.get_searchable_mailboxes('non_existent_distro@example.com') def test_expanddl(self): with self.assertRaises(ErrorNameResolutionNoResults): self.account.protocol.expand_dl('non_existent_distro@example.com') with self.assertRaises(ErrorNameResolutionNoResults): self.account.protocol.expand_dl( DLMailbox(email_address='non_existent_distro@example.com', mailbox_type='PublicDL') ) def test_oof_settings(self): # First, ensure a common starting point self.account.oof_settings = OofSettings(state=OofSettings.DISABLED) oof = OofSettings( state=OofSettings.ENABLED, external_audience='None', internal_reply="I'm on holidays. See ya guys!", external_reply='Dear Sir, your email has now been deleted.', ) self.account.oof_settings = oof self.assertEqual(self.account.oof_settings, oof) oof = OofSettings( state=OofSettings.ENABLED, external_audience='Known', internal_reply='XXX', external_reply='YYY', ) self.account.oof_settings = oof self.assertEqual(self.account.oof_settings, oof) # Scheduled duration must not be in the past start, end = get_random_datetime_range(start_date=EWSDate.today()) oof = OofSettings( state=OofSettings.SCHEDULED, external_audience='Known', internal_reply="I'm in the pub. See ya guys!", external_reply="I'm having a business dinner in town", start=start, end=end, ) self.account.oof_settings = oof self.assertEqual(self.account.oof_settings, oof) oof = OofSettings( state=OofSettings.DISABLED, ) self.account.oof_settings = oof self.assertEqual(self.account.oof_settings, oof) def test_oof_settings_validation(self): with self.assertRaises(ValueError): # Needs a start and end OofSettings( state=OofSettings.SCHEDULED, ).clean(version=None) with self.assertRaises(ValueError): # Start must be before end OofSettings( state=OofSettings.SCHEDULED, start=UTC.localize(EWSDateTime(2100, 12, 1)), end=UTC.localize(EWSDateTime(2100, 11, 1)), ).clean(version=None) with self.assertRaises(ValueError): # End must be in the future OofSettings( state=OofSettings.SCHEDULED, start=UTC.localize(EWSDateTime(2000, 11, 1)), end=UTC.localize(EWSDateTime(2000, 12, 1)), ).clean(version=None) with self.assertRaises(ValueError): # Must have an internal and external reply OofSettings( state=OofSettings.SCHEDULED, start=UTC.localize(EWSDateTime(2100, 11, 1)), end=UTC.localize(EWSDateTime(2100, 12, 1)), ).clean(version=None) def test_convert_id(self): i = 'AAMkADQyYzZmYmUxLTJiYjItNDg2Ny1iMzNjLTIzYWE1NDgxNmZhNABGAAAAAADUebQDarW2Q7G2Ji8hKofPBwAl9iKCsfCfSa9cmjh' \ '+JCrCAAPJcuhjAAB0l+JSKvzBRYP+FXGewReXAABj6DrMAAA=' for fmt in ID_FORMATS: res = list(self.account.protocol.convert_ids( [AlternateId(id=i, format=EWS_ID, mailbox=self.account.primary_smtp_address)], destination_format=fmt)) self.assertEqual(len(res), 1) self.assertEqual(res[0].format, fmt) def test_sessionpool(self): # First, empty the calendar start = self.account.default_timezone.localize(EWSDateTime(2011, 10, 12, 8)) end = self.account.default_timezone.localize(EWSDateTime(2011, 10, 12, 10)) self.account.calendar.filter(start__lt=end, end__gt=start, categories__contains=self.categories).delete() items = [] for i in range(75): subject = 'Test Subject %s' % i item = CalendarItem( start=start, end=end, subject=subject, categories=self.categories, ) items.append(item) return_ids = self.account.calendar.bulk_create(items=items) self.assertEqual(len(return_ids), len(items)) ids = self.account.calendar.filter(start__lt=end, end__gt=start, categories__contains=self.categories) \ .values_list('id', 'changekey') self.assertEqual(len(ids), len(items)) def test_disable_ssl_verification(self): # Test that we can make requests when SSL verification is turned off. I don't know how to mock TLS responses if not self.verify_ssl: # We can only run this test if we haven't already disabled TLS raise self.skipTest('TLS verification already disabled') default_adapter_cls = BaseProtocol.HTTP_ADAPTER_CLS # Just test that we can query self.account.root.all().exists() # Smash TLS verification using an untrusted certificate with tempfile.NamedTemporaryFile() as f: f.write(b'''\ -----BEGIN CERTIFICATE----- MIIENzCCAx+gAwIBAgIJAOYfYfw7NCOcMA0GCSqGSIb3DQEBBQUAMIGxMQswCQYD VQQGEwJVUzERMA8GA1UECAwITWFyeWxhbmQxFDASBgNVBAcMC0ZvcmVzdCBIaWxs MScwJQYDVQQKDB5UaGUgQXBhY2hlIFNvZnR3YXJlIEZvdW5kYXRpb24xFjAUBgNV BAsMDUFwYWNoZSBUaHJpZnQxEjAQBgNVBAMMCWxvY2FsaG9zdDEkMCIGCSqGSIb3 DQEJARYVZGV2QHRocmlmdC5hcGFjaGUub3JnMB4XDTE0MDQwNzE4NTgwMFoXDTIy MDYyNDE4NTgwMFowgbExCzAJBgNVBAYTAlVTMREwDwYDVQQIDAhNYXJ5bGFuZDEU MBIGA1UEBwwLRm9yZXN0IEhpbGwxJzAlBgNVBAoMHlRoZSBBcGFjaGUgU29mdHdh cmUgRm91bmRhdGlvbjEWMBQGA1UECwwNQXBhY2hlIFRocmlmdDESMBAGA1UEAwwJ bG9jYWxob3N0MSQwIgYJKoZIhvcNAQkBFhVkZXZAdGhyaWZ0LmFwYWNoZS5vcmcw ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCqE9TE9wEXp5LRtLQVDSGQ GV78+7ZtP/I/ZaJ6Q6ZGlfxDFvZjFF73seNhAvlKlYm/jflIHYLnNOCySN8I2Xw6 L9MbC+jvwkEKfQo4eDoxZnOZjNF5J1/lZtBeOowMkhhzBMH1Rds351/HjKNg6ZKg 2Cldd0j7HbDtEixOLgLbPRpBcaYrLrNMasf3Hal+x8/b8ue28x93HSQBGmZmMIUw AinEu/fNP4lLGl/0kZb76TnyRpYSPYojtS6CnkH+QLYnsRREXJYwD1Xku62LipkX wCkRTnZ5nUsDMX6FPKgjQFQCWDXG/N096+PRUQAChhrXsJ+gF3NqWtDmtrhVQF4n AgMBAAGjUDBOMB0GA1UdDgQWBBQo8v0wzQPx3EEexJPGlxPK1PpgKjAfBgNVHSME GDAWgBQo8v0wzQPx3EEexJPGlxPK1PpgKjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3 DQEBBQUAA4IBAQBGFRiJslcX0aJkwZpzTwSUdgcfKbpvNEbCNtVohfQVTI4a/oN5 U+yqDZJg3vOaOuiAZqyHcIlZ8qyesCgRN314Tl4/JQ++CW8mKj1meTgo5YFxcZYm T9vsI3C+Nzn84DINgI9mx6yktIt3QOKZRDpzyPkUzxsyJ8J427DaimDrjTR+fTwD 1Dh09xeeMnSa5zeV1HEDyJTqCXutLetwQ/IyfmMBhIx+nvB5f67pz/m+Dv6V0r3I p4HCcdnDUDGJbfqtoqsAATQQWO+WWuswB6mOhDbvPTxhRpZq6AkgWqv4S+u3M2GO r5p9FrBgavAw5bKO54C0oQKpN/5fta5l6Ws0 -----END CERTIFICATE-----''') try: os.environ['REQUESTS_CA_BUNDLE'] = f.name # Setting the credentials is just an easy way of resetting the session pool. This will let requests # pick up the new environment variable. Now the request should fail self.account.protocol.credentials = self.account.protocol.credentials with self.assertRaises(TransportError): self.account.root.all().exists() # Disable insecure TLS warnings with warnings.catch_warnings(): warnings.simplefilter("ignore") # Make sure we can handle TLS validation errors when using the custom adapter BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter self.account.protocol.credentials = self.account.protocol.credentials self.account.root.all().exists() # Test that the custom adapter also works when validation is OK again del os.environ['REQUESTS_CA_BUNDLE'] self.account.protocol.credentials = self.account.protocol.credentials self.account.root.all().exists() finally: # Reset environment os.environ.pop('REQUESTS_CA_BUNDLE', None) # May already have been deleted BaseProtocol.HTTP_ADAPTER_CLS = default_adapter_cls exchangelib-3.1.1/tests/test_queryset.py000066400000000000000000000071401361226005600204240ustar00rootroot00000000000000# coding=utf-8 from collections import namedtuple from exchangelib import FolderCollection, Q from exchangelib.folders import Inbox from exchangelib.queryset import QuerySet from .common import TimedTestCase class QuerySetTest(TimedTestCase): def test_magic(self): self.assertEqual( str( QuerySet(folder_collection=FolderCollection(account=None, folders=[Inbox(root='XXX', name='FooBox')])) ), 'QuerySet(q=Q(), folders=[Inbox (FooBox)])' ) def test_from_folder(self): MockRoot = namedtuple('Root', ['account']) folder = Inbox(root=MockRoot(account='XXX')) self.assertIsInstance(folder.all(), QuerySet) self.assertIsInstance(folder.none(), QuerySet) self.assertIsInstance(folder.filter(subject='foo'), QuerySet) self.assertIsInstance(folder.exclude(subject='foo'), QuerySet) def test_queryset_copy(self): qs = QuerySet(folder_collection=FolderCollection(account=None, folders=[Inbox(root='XXX')])) qs.q = Q() qs.only_fields = ('a', 'b') qs.order_fields = ('c', 'd') qs.return_format = QuerySet.NONE # Initially, immutable items have the same id() new_qs = qs._copy_self() self.assertNotEqual(id(qs), id(new_qs)) self.assertEqual(id(qs.folder_collection), id(new_qs.folder_collection)) self.assertEqual(id(qs._cache), id(new_qs._cache)) self.assertEqual(qs._cache, new_qs._cache) self.assertNotEqual(id(qs.q), id(new_qs.q)) self.assertEqual(qs.q, new_qs.q) self.assertEqual(id(qs.only_fields), id(new_qs.only_fields)) self.assertEqual(qs.only_fields, new_qs.only_fields) self.assertEqual(id(qs.order_fields), id(new_qs.order_fields)) self.assertEqual(qs.order_fields, new_qs.order_fields) self.assertEqual(id(qs.return_format), id(new_qs.return_format)) self.assertEqual(qs.return_format, new_qs.return_format) # Set the same values, forcing a new id() new_qs.q = Q() new_qs.only_fields = ('a', 'b') new_qs.order_fields = ('c', 'd') new_qs.return_format = QuerySet.NONE self.assertNotEqual(id(qs), id(new_qs)) self.assertEqual(id(qs.folder_collection), id(new_qs.folder_collection)) self.assertEqual(id(qs._cache), id(new_qs._cache)) self.assertEqual(qs._cache, new_qs._cache) self.assertNotEqual(id(qs.q), id(new_qs.q)) self.assertEqual(qs.q, new_qs.q) self.assertEqual(qs.only_fields, new_qs.only_fields) self.assertEqual(qs.order_fields, new_qs.order_fields) self.assertEqual(qs.return_format, new_qs.return_format) # Set the new values, forcing a new id() new_qs.q = Q(foo=5) new_qs.only_fields = ('c', 'd') new_qs.order_fields = ('e', 'f') new_qs.return_format = QuerySet.VALUES self.assertNotEqual(id(qs), id(new_qs)) self.assertEqual(id(qs.folder_collection), id(new_qs.folder_collection)) self.assertEqual(id(qs._cache), id(new_qs._cache)) self.assertEqual(qs._cache, new_qs._cache) self.assertNotEqual(id(qs.q), id(new_qs.q)) self.assertNotEqual(qs.q, new_qs.q) self.assertNotEqual(id(qs.only_fields), id(new_qs.only_fields)) self.assertNotEqual(qs.only_fields, new_qs.only_fields) self.assertNotEqual(id(qs.order_fields), id(new_qs.order_fields)) self.assertNotEqual(qs.order_fields, new_qs.order_fields) self.assertNotEqual(id(qs.return_format), id(new_qs.return_format)) self.assertNotEqual(qs.return_format, new_qs.return_format) exchangelib-3.1.1/tests/test_recurrence.py000066400000000000000000000045771361226005600207130ustar00rootroot00000000000000from exchangelib import EWSDate from exchangelib.fields import MONDAY, FEBRUARY, AUGUST, SECOND, LAST, WEEKEND_DAY from exchangelib.recurrence import Recurrence, AbsoluteYearlyPattern, RelativeYearlyPattern, AbsoluteMonthlyPattern, \ RelativeMonthlyPattern, WeeklyPattern, DailyPattern, NoEndPattern, EndDatePattern, NumberedPattern from .common import TimedTestCase class RecurrenceTest(TimedTestCase): def test_magic(self): pattern = AbsoluteYearlyPattern(month=FEBRUARY, day_of_month=28) self.assertEqual(str(pattern), 'Occurs on day 28 of February') pattern = RelativeYearlyPattern(month=AUGUST, week_number=SECOND, weekday=MONDAY) self.assertEqual(str(pattern), 'Occurs on weekday Monday in the Second week of August') pattern = AbsoluteMonthlyPattern(interval=3, day_of_month=31) self.assertEqual(str(pattern), 'Occurs on day 31 of every 3 month(s)') pattern = RelativeMonthlyPattern(interval=2, week_number=LAST, weekday=5) self.assertEqual(str(pattern), 'Occurs on weekday Friday in the Last week of every 2 month(s)') pattern = WeeklyPattern(interval=4, weekdays=WEEKEND_DAY, first_day_of_week=7) self.assertEqual(str(pattern), 'Occurs on weekdays WeekendDay of every 4 week(s) where the first day of the week is Sunday') pattern = DailyPattern(interval=6) self.assertEqual(str(pattern), 'Occurs every 6 day(s)') def test_validation(self): p = DailyPattern(interval=3) d_start = EWSDate(2017, 9, 1) d_end = EWSDate(2017, 9, 7) with self.assertRaises(ValueError): Recurrence(pattern=p, boundary='foo', start='bar') # Specify *either* boundary *or* start, end and number with self.assertRaises(ValueError): Recurrence(pattern=p, start='foo', end='bar', number='baz') # number is invalid when end is present with self.assertRaises(ValueError): Recurrence(pattern=p, end='bar', number='baz') # Must have start r = Recurrence(pattern=p, start=d_start) self.assertEqual(r.boundary, NoEndPattern(start=d_start)) r = Recurrence(pattern=p, start=d_start, end=d_end) self.assertEqual(r.boundary, EndDatePattern(start=d_start, end=d_end)) r = Recurrence(pattern=p, start=d_start, number=1) self.assertEqual(r.boundary, NumberedPattern(start=d_start, number=1)) exchangelib-3.1.1/tests/test_restriction.py000066400000000000000000000147141361226005600211150ustar00rootroot00000000000000from exchangelib import EWSDateTime, EWSTimeZone, Q, Build from exchangelib.folders import Calendar, Root from exchangelib.restriction import Restriction from exchangelib.util import xml_to_str from exchangelib.version import Version, EXCHANGE_2007 from .common import TimedTestCase, mock_account, mock_protocol class RestrictionTest(TimedTestCase): def test_magic(self): self.assertEqual(str(Q()), 'Q()') def test_q(self): version = Version(build=EXCHANGE_2007) account = mock_account(version=version, protocol=mock_protocol(version=version, service_endpoint='example.com')) root = Root(account=account) tz = EWSTimeZone.timezone('Europe/Copenhagen') start = tz.localize(EWSDateTime(1950, 9, 26, 8, 0, 0)) end = tz.localize(EWSDateTime(2050, 9, 26, 11, 0, 0)) result = '''\ ''' q = Q(Q(categories__contains='FOO') | Q(categories__contains='BAR'), start__lt=end, end__gt=start) r = Restriction(q, folders=[Calendar(root=root)], applies_to=Restriction.ITEMS) self.assertEqual(str(r), ''.join(s.lstrip() for s in result.split('\n'))) # Test empty Q q = Q() self.assertEqual(q.to_xml(folders=[Calendar()], version=version, applies_to=Restriction.ITEMS), None) with self.assertRaises(ValueError): Restriction(q, folders=[Calendar(root=root)], applies_to=Restriction.ITEMS) # Test validation with self.assertRaises(ValueError): Q(datetime_created__range=(1,)) # Must have exactly 2 args with self.assertRaises(ValueError): Q(datetime_created__range=(1, 2, 3)) # Must have exactly 2 args with self.assertRaises(TypeError): Q(datetime_created=Build(15, 1)).clean(version=Version(build=EXCHANGE_2007)) # Must be serializable with self.assertRaises(ValueError): Q(datetime_created=EWSDateTime(2017, 1, 1)).clean(version=Version(build=EXCHANGE_2007)) # Must be tz-aware with self.assertRaises(ValueError): Q(categories__contains=[[1, 2], [3, 4]]).clean(version=Version(build=EXCHANGE_2007)) # Must be single value def test_q_expr(self): self.assertEqual(Q().expr(), None) self.assertEqual((~Q()).expr(), None) self.assertEqual(Q(x=5).expr(), 'x == 5') self.assertEqual((~Q(x=5)).expr(), 'x != 5') q = (Q(b__contains='a', x__contains=5) | Q(~Q(a__contains='c'), f__gt=3, c=6)) & ~Q(y=9, z__contains='b') self.assertEqual( str(q), # str() calls expr() "((b contains 'a' AND x contains 5) OR (NOT a contains 'c' AND c == 6 AND f > 3)) " "AND NOT (y == 9 AND z contains 'b')" ) self.assertEqual( repr(q), "Q('AND', Q('OR', Q('AND', Q(b contains 'a'), Q(x contains 5)), Q('AND', Q('NOT', Q(a contains 'c')), " "Q(c == 6), Q(f > 3))), Q('NOT', Q('AND', Q(y == 9), Q(z contains 'b'))))" ) # Test simulated IN expression in_q = Q(foo__in=[1, 2, 3]) self.assertEqual(in_q.conn_type, Q.OR) self.assertEqual(len(in_q.children), 3) def test_q_inversion(self): version = Version(build=EXCHANGE_2007) account = mock_account(version=version, protocol=mock_protocol(version=version, service_endpoint='example.com')) root = Root(account=account) self.assertEqual((~Q(foo=5)).op, Q.NE) self.assertEqual((~Q(foo__not=5)).op, Q.EQ) self.assertEqual((~Q(foo__lt=5)).op, Q.GTE) self.assertEqual((~Q(foo__lte=5)).op, Q.GT) self.assertEqual((~Q(foo__gt=5)).op, Q.LTE) self.assertEqual((~Q(foo__gte=5)).op, Q.LT) # Test not not Q on a non-leaf self.assertEqual(Q(foo__contains=('bar', 'baz')).conn_type, Q.AND) self.assertEqual((~Q(foo__contains=('bar', 'baz'))).conn_type, Q.NOT) self.assertEqual((~~Q(foo__contains=('bar', 'baz'))).conn_type, Q.AND) self.assertEqual(Q(foo__contains=('bar', 'baz')), ~~Q(foo__contains=('bar', 'baz'))) # Test generated XML of 'Not' statement when there is only one child. Skip 't:And' between 't:Not' and 't:Or'. result = '''\ ''' q = ~(Q(subject='bar') | Q(subject='baz')) self.assertEqual( xml_to_str(q.to_xml(folders=[Calendar(root=root)], version=version, applies_to=Restriction.ITEMS)), ''.join(s.lstrip() for s in result.split('\n')) ) def test_q_boolean_ops(self): self.assertEqual((Q(foo=5) & Q(foo=6)).conn_type, Q.AND) self.assertEqual((Q(foo=5) | Q(foo=6)).conn_type, Q.OR) def test_q_failures(self): with self.assertRaises(ValueError): # Invalid value Q(foo=None).clean(version=Version(build=EXCHANGE_2007)) exchangelib-3.1.1/tests/test_services.py000066400000000000000000000204201361226005600203620ustar00rootroot00000000000000import requests_mock from exchangelib.errors import ErrorServerBusy, ErrorNonExistentMailbox, TransportError, MalformedResponseError, \ ErrorInvalidServerVersion, SOAPError from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames from exchangelib.util import create_element from exchangelib.version import EXCHANGE_2007, EXCHANGE_2010 from .common import EWSTest, mock_protocol, mock_version, mock_account, MockResponse, get_random_string class ServicesTest(EWSTest): def test_invalid_server_version(self): # Test that we get a client-side error if we call a service that was only implemented in a later version version = mock_version(build=EXCHANGE_2007) account = mock_account(version=version, protocol=mock_protocol(version=version, service_endpoint='example.com')) with self.assertRaises(NotImplementedError): list(GetServerTimeZones(protocol=account.protocol).call()) with self.assertRaises(NotImplementedError): list(GetRoomLists(protocol=account.protocol).call()) with self.assertRaises(NotImplementedError): list(GetRooms(protocol=account.protocol).call('XXX')) def test_error_server_busy(self): # Test that we can parse an ErrorServerBusy response version = mock_version(build=EXCHANGE_2010) ws = GetRoomLists(mock_protocol(version=version, service_endpoint='example.com')) xml = b'''\ a:ErrorServerBusy The server cannot service this request right now. Try again later. ErrorServerBusy The server cannot service this request right now. Try again later. 297749 ''' header, body = ws._get_soap_parts(response=MockResponse(xml)) with self.assertRaises(ErrorServerBusy) as cm: ws._get_elements_in_response(response=ws._get_soap_messages(body=body)) self.assertEqual(cm.exception.back_off, 297.749) def test_soap_error(self): soap_xml = """\ {faultcode} {faultstring} https://CAS01.example.com/EWS/Exchange.asmx {responsecode} {message} """ header, body = ResolveNames._get_soap_parts(response=MockResponse(soap_xml.format( faultcode='YYY', faultstring='AAA', responsecode='XXX', message='ZZZ' ).encode('utf-8'))) with self.assertRaises(SOAPError) as e: ResolveNames._get_soap_messages(body=body) self.assertIn('AAA', e.exception.args[0]) self.assertIn('YYY', e.exception.args[0]) self.assertIn('ZZZ', e.exception.args[0]) header, body = ResolveNames._get_soap_parts(response=MockResponse(soap_xml.format( faultcode='ErrorNonExistentMailbox', faultstring='AAA', responsecode='XXX', message='ZZZ' ).encode('utf-8'))) with self.assertRaises(ErrorNonExistentMailbox) as e: ResolveNames._get_soap_messages(body=body) self.assertIn('AAA', e.exception.args[0]) header, body = ResolveNames._get_soap_parts(response=MockResponse(soap_xml.format( faultcode='XXX', faultstring='AAA', responsecode='ErrorNonExistentMailbox', message='YYY' ).encode('utf-8'))) with self.assertRaises(ErrorNonExistentMailbox) as e: ResolveNames._get_soap_messages(body=body) self.assertIn('YYY', e.exception.args[0]) # Test bad XML (no body) soap_xml = b"""\ """ with self.assertRaises(MalformedResponseError): ResolveNames._get_soap_parts(response=MockResponse(soap_xml)) # Test bad XML (no fault) soap_xml = b"""\ """ header, body = ResolveNames._get_soap_parts(response=MockResponse(soap_xml)) with self.assertRaises(TransportError): ResolveNames._get_soap_messages(body=body) def test_element_container(self): svc = ResolveNames(self.account.protocol) soap_xml = b"""\ NoError """ header, body = svc._get_soap_parts(response=MockResponse(soap_xml)) resp = svc._get_soap_messages(body=body) with self.assertRaises(TransportError) as e: # Missing ResolutionSet elements list(svc._get_elements_in_response(response=resp)) self.assertIn('ResolutionSet elements in ResponseMessage', e.exception.args[0]) def test_get_elements(self): # Test that we can handle SOAP-level error messages # TODO: The request actually raises ErrorInvalidRequest, but we interpret that to mean a wrong API version and # end up throwing ErrorInvalidServerVersion. We should make a more direct test. svc = ResolveNames(self.account.protocol) with self.assertRaises(ErrorInvalidServerVersion): svc._get_elements(create_element('XXX')) @requests_mock.mock() def test_invalid_soap_response(self, m): m.post(self.account.protocol.service_endpoint, text='XXX') with self.assertRaises(SOAPError): self.account.inbox.all().count() def test_version_renegotiate(self): # Test that we can recover from a wrong API version. This is needed in version guessing and when the # autodiscover response returns a wrong server version for the account old_version = self.account.version.api_version self.account.version.api_version = 'Exchange2016' # Newer EWS versions require a valid value try: list(self.account.inbox.filter(subject=get_random_string(16))) self.assertEqual(old_version, self.account.version.api_version) finally: self.account.version.api_version = old_version exchangelib-3.1.1/tests/test_source.py000066400000000000000000000100031361226005600200330ustar00rootroot00000000000000import flake8.defaults import flake8.main.application from exchangelib.errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorItemNotFound, ErrorInvalidOperation, \ ErrorNoPublicFolderReplicaAvailable from exchangelib.properties import EWSElement from .common import EWSTest, TimedTestCase class StyleTest(TimedTestCase): def test_flake8(self): import exchangelib flake8.defaults.MAX_LINE_LENGTH = 120 app = flake8.main.application.Application() app.run(exchangelib.__path__) # If this fails, look at stdout for actual error messages self.assertEqual(app.result_count, 0) class CommonTest(EWSTest): def test_magic(self): self.assertIn(self.account.protocol.version.api_version, str(self.account.protocol)) self.assertIn(self.account.protocol.credentials.username, str(self.account.protocol.credentials)) self.assertIn(self.account.primary_smtp_address, str(self.account)) self.assertIn(str(self.account.version.build.major_version), repr(self.account.version)) for item in ( self.account.protocol, self.account.version, ): with self.subTest(item=item): # Just test that these at least don't throw errors repr(item) str(item) for attr in ( 'admin_audit_logs', 'archive_deleted_items', 'archive_inbox', 'archive_msg_folder_root', 'archive_recoverable_items_deletions', 'archive_recoverable_items_purges', 'archive_recoverable_items_root', 'archive_recoverable_items_versions', 'archive_root', 'calendar', 'conflicts', 'contacts', 'conversation_history', 'directory', 'drafts', 'favorites', 'im_contact_list', 'inbox', 'journal', 'junk', 'local_failures', 'msg_folder_root', 'my_contacts', 'notes', 'outbox', 'people_connect', 'public_folders_root', 'quick_contacts', 'recipient_cache', 'recoverable_items_deletions', 'recoverable_items_purges', 'recoverable_items_root', 'recoverable_items_versions', 'search_folders', 'sent', 'server_failures', 'sync_issues', 'tasks', 'todo_search', 'trash', 'voice_mail', ): with self.subTest(attr=attr): # Test distinguished folder shortcuts. Some may raise ErrorAccessDenied try: item = getattr(self.account, attr) except (ErrorAccessDenied, ErrorFolderNotFound, ErrorItemNotFound, ErrorInvalidOperation, ErrorNoPublicFolderReplicaAvailable): continue else: repr(item) str(item) self.assertTrue(item.is_distinguished) def test_from_xml(self): # Test for all EWSElement classes that they handle None as input to from_xml() import exchangelib for mod in (exchangelib.attachments, exchangelib.extended_properties, exchangelib.indexed_properties, exchangelib.folders, exchangelib.items, exchangelib.properties): for k, v in vars(mod).items(): with self.subTest(k=k, v=v): if type(v) != type: continue if not issubclass(v, EWSElement): continue # from_xml() does not support None input with self.assertRaises(Exception): v.from_xml(elem=None, account=None) exchangelib-3.1.1/tests/test_transport.py000066400000000000000000000127371361226005600206070ustar00rootroot00000000000000from collections import namedtuple import requests import requests_mock from exchangelib import DELEGATE, IMPERSONATION from exchangelib.errors import UnauthorizedError from exchangelib.transport import wrap, get_auth_method_from_response, BASIC, NOAUTH, NTLM, DIGEST from exchangelib.util import PrettyXmlHandler, create_element from .common import TimedTestCase class TransportTest(TimedTestCase): @requests_mock.mock() def test_get_auth_method_from_response(self, m): url = 'http://example.com/noauth' m.get(url, status_code=200) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), NOAUTH) # No authentication needed url = 'http://example.com/redirect' m.get(url, status_code=302, headers={'location': 'http://contoso.com'}) r = requests.get(url, allow_redirects=False) with self.assertRaises(UnauthorizedError): get_auth_method_from_response(r) # Redirect to another host url = 'http://example.com/relativeredirect' m.get(url, status_code=302, headers={'location': 'http://example.com/'}) r = requests.get(url, allow_redirects=False) with self.assertRaises(UnauthorizedError): get_auth_method_from_response(r) # Redirect to same host url = 'http://example.com/internalerror' m.get(url, status_code=501) r = requests.get(url) with self.assertRaises(UnauthorizedError): get_auth_method_from_response(r) # Non-401 status code url = 'http://example.com/no_auth_headers' m.get(url, status_code=401) r = requests.get(url) with self.assertRaises(UnauthorizedError): get_auth_method_from_response(r) # 401 status code but no auth headers url = 'http://example.com/no_supported_auth' m.get(url, status_code=401, headers={'WWW-Authenticate': 'FANCYAUTH'}) r = requests.get(url) with self.assertRaises(UnauthorizedError): get_auth_method_from_response(r) # 401 status code but no auth headers url = 'http://example.com/basic_auth' m.get(url, status_code=401, headers={'WWW-Authenticate': 'Basic'}) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), BASIC) url = 'http://example.com/basic_auth_empty_realm' m.get(url, status_code=401, headers={'WWW-Authenticate': 'Basic realm=""'}) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), BASIC) url = 'http://example.com/basic_auth_realm' m.get(url, status_code=401, headers={'WWW-Authenticate': 'Basic realm="some realm"'}) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), BASIC) url = 'http://example.com/digest' m.get(url, status_code=401, headers={ 'WWW-Authenticate': 'Digest realm="foo@bar.com", qop="auth,auth-int", nonce="mumble", opaque="bumble"' }) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), DIGEST) url = 'http://example.com/ntlm' m.get(url, status_code=401, headers={'WWW-Authenticate': 'NTLM'}) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), NTLM) # Make sure we prefer the most secure auth method if multiple methods are supported url = 'http://example.com/mixed' m.get(url, status_code=401, headers={'WWW-Authenticate': 'Basic realm="X1", Digest realm="X2", NTLM'}) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), DIGEST) def test_wrap(self): # Test payload wrapper with both delegation, impersonation and timezones MockTZ = namedtuple('EWSTimeZone', ['ms_id']) MockAccount = namedtuple('Account', ['access_type', 'primary_smtp_address', 'default_timezone']) content = create_element('AAA') api_version = 'BBB' account = MockAccount(DELEGATE, 'foo@example.com', MockTZ('XXX')) wrapped = wrap(content=content, api_version=api_version, account=account) self.assertEqual( PrettyXmlHandler.prettify_xml(wrapped), b''' ''') account = MockAccount(IMPERSONATION, 'foo@example.com', MockTZ('XXX')) wrapped = wrap(content=content, api_version=api_version, account=account) self.assertEqual( PrettyXmlHandler.prettify_xml(wrapped), b''' foo@example.com ''') exchangelib-3.1.1/tests/test_util.py000066400000000000000000000324341361226005600175240ustar00rootroot00000000000000import io from itertools import chain import logging import requests import requests_mock from exchangelib import FailFast, FaultTolerance from exchangelib.errors import RelativeRedirect, TransportError, RateLimitError, RedirectError, UnauthorizedError,\ CASError import exchangelib.util from exchangelib.util import chunkify, peek, get_redirect_url, get_domain, PrettyXmlHandler, to_xml, BOM_UTF8, \ ParseError, post_ratelimited, safe_b64decode, CONNECTION_ERRORS from .common import EWSTest, mock_post, mock_session_exception class UtilTest(EWSTest): def test_chunkify(self): # Test tuple, list, set, range, map, chain and generator seq = [1, 2, 3, 4, 5] self.assertEqual(list(chunkify(seq, chunksize=2)), [[1, 2], [3, 4], [5]]) seq = (1, 2, 3, 4, 6, 7, 9) self.assertEqual(list(chunkify(seq, chunksize=3)), [(1, 2, 3), (4, 6, 7), (9,)]) seq = {1, 2, 3, 4, 5} self.assertEqual(list(chunkify(seq, chunksize=2)), [[1, 2], [3, 4], [5, ]]) seq = range(5) self.assertEqual(list(chunkify(seq, chunksize=2)), [range(0, 2), range(2, 4), range(4, 5)]) seq = map(int, range(5)) self.assertEqual(list(chunkify(seq, chunksize=2)), [[0, 1], [2, 3], [4]]) seq = chain(*[[i] for i in range(5)]) self.assertEqual(list(chunkify(seq, chunksize=2)), [[0, 1], [2, 3], [4]]) seq = (i for i in range(5)) self.assertEqual(list(chunkify(seq, chunksize=2)), [[0, 1], [2, 3], [4]]) def test_peek(self): # Test peeking into various sequence types # tuple is_empty, seq = peek(tuple()) self.assertEqual((is_empty, list(seq)), (True, [])) is_empty, seq = peek((1, 2, 3)) self.assertEqual((is_empty, list(seq)), (False, [1, 2, 3])) # list is_empty, seq = peek([]) self.assertEqual((is_empty, list(seq)), (True, [])) is_empty, seq = peek([1, 2, 3]) self.assertEqual((is_empty, list(seq)), (False, [1, 2, 3])) # set is_empty, seq = peek(set()) self.assertEqual((is_empty, list(seq)), (True, [])) is_empty, seq = peek({1, 2, 3}) self.assertEqual((is_empty, list(seq)), (False, [1, 2, 3])) # range is_empty, seq = peek(range(0)) self.assertEqual((is_empty, list(seq)), (True, [])) is_empty, seq = peek(range(1, 4)) self.assertEqual((is_empty, list(seq)), (False, [1, 2, 3])) # map is_empty, seq = peek(map(int, [])) self.assertEqual((is_empty, list(seq)), (True, [])) is_empty, seq = peek(map(int, [1, 2, 3])) self.assertEqual((is_empty, list(seq)), (False, [1, 2, 3])) # generator is_empty, seq = peek((i for i in [])) self.assertEqual((is_empty, list(seq)), (True, [])) is_empty, seq = peek((i for i in [1, 2, 3])) self.assertEqual((is_empty, list(seq)), (False, [1, 2, 3])) @requests_mock.mock() def test_get_redirect_url(self, m): m.get('https://httpbin.org/redirect-to', status_code=302, headers={'location': 'https://example.com/'}) r = requests.get('https://httpbin.org/redirect-to?url=https://example.com/', allow_redirects=False) self.assertEqual(get_redirect_url(r), 'https://example.com/') m.get('https://httpbin.org/redirect-to', status_code=302, headers={'location': 'http://example.com/'}) r = requests.get('https://httpbin.org/redirect-to?url=http://example.com/', allow_redirects=False) self.assertEqual(get_redirect_url(r), 'http://example.com/') m.get('https://httpbin.org/redirect-to', status_code=302, headers={'location': '/example'}) r = requests.get('https://httpbin.org/redirect-to?url=/example', allow_redirects=False) self.assertEqual(get_redirect_url(r), 'https://httpbin.org/example') m.get('https://httpbin.org/redirect-to', status_code=302, headers={'location': 'https://example.com'}) with self.assertRaises(RelativeRedirect): r = requests.get('https://httpbin.org/redirect-to?url=https://example.com', allow_redirects=False) get_redirect_url(r, require_relative=True) m.get('https://httpbin.org/redirect-to', status_code=302, headers={'location': '/example'}) with self.assertRaises(RelativeRedirect): r = requests.get('https://httpbin.org/redirect-to?url=/example', allow_redirects=False) get_redirect_url(r, allow_relative=False) def test_to_xml(self): to_xml(b'') to_xml(BOM_UTF8+b'') to_xml(BOM_UTF8+b'&broken') with self.assertRaises(ParseError): to_xml(b'foo') try: to_xml(b'Baz') except ParseError as e: # Not all lxml versions throw an error here, so we can't use assertRaises self.assertIn('Offending text: [...]Bazbar'},), exc_info=None) h.emit(r) h.stream.seek(0) self.assertEqual( h.stream.read(), "hello \x1b[36m\x1b[39;49;00m\n\x1b[94m" "\x1b[39;49;00mbar\x1b[94m\x1b[39;49;00m\n\n" ) def test_post_ratelimited(self): url = 'https://example.com' protocol = self.account.protocol retry_policy = protocol.config.retry_policy RETRY_WAIT = exchangelib.util.RETRY_WAIT MAX_REDIRECTS = exchangelib.util.MAX_REDIRECTS session = protocol.get_session() try: # Make sure we fail fast in error cases protocol.config.retry_policy = FailFast() # Test the straight, HTTP 200 path session.post = mock_post(url, 200, {}, 'foo') r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') self.assertEqual(r.content, b'foo') # Test exceptions raises by the POST request for err_cls in CONNECTION_ERRORS: session.post = mock_session_exception(err_cls) with self.assertRaises(err_cls): r, session = post_ratelimited( protocol=protocol, session=session, url='http://', headers=None, data='') # Test bad exit codes and headers session.post = mock_post(url, 401, {}) with self.assertRaises(UnauthorizedError): r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') session.post = mock_post(url, 999, {'connection': 'close'}) with self.assertRaises(TransportError): r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') session.post = mock_post(url, 302, {'location': '/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx'}) with self.assertRaises(TransportError): r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') session.post = mock_post(url, 503, {}) with self.assertRaises(TransportError): r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') # No redirect header session.post = mock_post(url, 302, {}) with self.assertRaises(TransportError): r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') # Redirect header to same location session.post = mock_post(url, 302, {'location': url}) with self.assertRaises(TransportError): r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') # Redirect header to relative location session.post = mock_post(url, 302, {'location': url + '/foo'}) with self.assertRaises(RedirectError): r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') # Redirect header to other location and allow_redirects=False session.post = mock_post(url, 302, {'location': 'https://contoso.com'}) with self.assertRaises(TransportError): r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') # Redirect header to other location and allow_redirects=True exchangelib.util.MAX_REDIRECTS = 0 session.post = mock_post(url, 302, {'location': 'https://contoso.com'}) with self.assertRaises(TransportError): r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='', allow_redirects=True) # CAS error session.post = mock_post(url, 999, {'X-CasErrorCode': 'AAARGH!'}) with self.assertRaises(CASError): r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') # Allow XML data in a non-HTTP 200 response session.post = mock_post(url, 500, {}, '') r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') self.assertEqual(r.content, b'') # Bad status_code and bad text session.post = mock_post(url, 999, {}) with self.assertRaises(TransportError): r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') # Test rate limit exceeded exchangelib.util.RETRY_WAIT = 1 protocol.config.retry_policy = FaultTolerance(max_wait=0.5) # Fail after first RETRY_WAIT session.post = mock_post(url, 503, {'connection': 'close'}) # Mock renew_session to return the same session so the session object's 'post' method is still mocked protocol.renew_session = lambda s: s with self.assertRaises(RateLimitError) as rle: r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') self.assertEqual(rle.exception.status_code, 503) self.assertEqual(rle.exception.url, url) self.assertTrue(1 <= rle.exception.total_wait < 2) # One RETRY_WAIT plus some overhead # Test something larger than the default wait, so we retry at least once protocol.retry_policy.max_wait = 3 # Fail after second RETRY_WAIT session.post = mock_post(url, 503, {'connection': 'close'}) with self.assertRaises(RateLimitError) as rle: r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') self.assertEqual(rle.exception.status_code, 503) self.assertEqual(rle.exception.url, url) # We double the wait for each retry, so this is RETRY_WAIT + 2*RETRY_WAIT plus some overhead self.assertTrue(3 <= rle.exception.total_wait < 4, rle.exception.total_wait) finally: protocol.retire_session(session) # We have patched the session, so discard it # Restore patched attributes and functions protocol.config.retry_policy = retry_policy exchangelib.util.RETRY_WAIT = RETRY_WAIT exchangelib.util.MAX_REDIRECTS = MAX_REDIRECTS try: delattr(protocol, 'renew_session') except AttributeError: pass def test_safe_b64decode(self): # Test correctly padded string self.assertEqual(safe_b64decode('SGVsbG8gd29ybGQ='), b'Hello world') # Test incorrectly padded string self.assertEqual(safe_b64decode('SGVsbG8gd29ybGQ'), b'Hello world') # Test binary data self.assertEqual(safe_b64decode(b'SGVsbG8gd29ybGQ='), b'Hello world') # Test incorrectly padded binary data self.assertEqual(safe_b64decode(b'SGVsbG8gd29ybGQ'), b'Hello world') exchangelib-3.1.1/tests/test_version.py000066400000000000000000000066161361226005600202370ustar00rootroot00000000000000import requests_mock from exchangelib import Version from exchangelib.errors import TransportError from exchangelib.version import EXCHANGE_2007, Build from exchangelib.util import to_xml from .common import TimedTestCase class VersionTest(TimedTestCase): def test_default_api_version(self): # Test that a version gets a reasonable api_version value if we don't set one explicitly version = Version(build=Build(15, 1, 2, 3)) self.assertEqual(version.api_version, 'Exchange2016') @requests_mock.mock() # Just to make sure we don't make any requests def test_from_response(self, m): # Test fallback to suggested api_version value when there is a version mismatch and response version is fishy version = Version.from_soap_header( 'Exchange2007', to_xml(b'''\ ''') ) self.assertEqual(version.api_version, EXCHANGE_2007.api_version()) self.assertEqual(version.api_version, 'Exchange2007') self.assertEqual(version.build, Build(15, 1, 845, 22)) # Test that override the suggested version if the response version is not fishy version = Version.from_soap_header( 'Exchange2013', to_xml(b'''\ ''') ) self.assertEqual(version.api_version, 'HELLO_FROM_EXCHANGELIB') # Test that we override the suggested version with the version deduced from the build number if a version is not # present in the response version = Version.from_soap_header( 'Exchange2013', to_xml(b'''\ ''') ) self.assertEqual(version.api_version, 'Exchange2016') # Test that we use the version deduced from the build number when a version is not present in the response and # there was no suggested version. version = Version.from_soap_header( None, to_xml(b'''\ ''') ) self.assertEqual(version.api_version, 'Exchange2016') # Test various parse failures with self.assertRaises(TransportError): Version.from_soap_header( 'Exchange2013', to_xml(b'''\ ''') ) with self.assertRaises(TransportError): Version.from_soap_header( 'Exchange2013', to_xml(b'''\ ''') )