pax_global_header 0000666 0000000 0000000 00000000064 13612260056 0014513 g ustar 00root root 0000000 0000000 52 comment=a695bda1edd9a574532bd099fe0c19968a5c5be4
exchangelib-3.1.1/ 0000775 0000000 0000000 00000000000 13612260056 0013766 5 ustar 00root root 0000000 0000000 exchangelib-3.1.1/.codacy.yaml 0000664 0000000 0000000 00000000057 13612260056 0016174 0 ustar 00root root 0000000 0000000 ---
exclude_paths:
- tests/**
- scripts/**
exchangelib-3.1.1/.flake8 0000664 0000000 0000000 00000000101 13612260056 0015131 0 ustar 00root root 0000000 0000000 [flake8]
max-line-length = 120
exclude = .git,__pycache__,vendor
exchangelib-3.1.1/.github/ 0000775 0000000 0000000 00000000000 13612260056 0015326 5 ustar 00root root 0000000 0000000 exchangelib-3.1.1/.github/FUNDING.yml 0000664 0000000 0000000 00000000027 13612260056 0017142 0 ustar 00root root 0000000 0000000 github: [ecederstrand]
exchangelib-3.1.1/.gitignore 0000664 0000000 0000000 00000000147 13612260056 0015760 0 ustar 00root root 0000000 0000000 .eggs
.idea
.coverage
*.html
*.pyc
*.swp
*.egg-info
build
dist
__pycache__
settings.yml
scratch*.py
exchangelib-3.1.1/.travis.yml 0000664 0000000 0000000 00000002466 13612260056 0016107 0 ustar 00root root 0000000 0000000 language: 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.md 0000664 0000000 0000000 00000065310 13612260056 0015604 0 ustar 00root root 0000000 0000000 Change 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 ')
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.md 0000664 0000000 0000000 00000006427 13612260056 0016576 0 ustar 00root root 0000000 0000000 # 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/LICENSE 0000664 0000000 0000000 00000002446 13612260056 0015001 0 ustar 00root root 0000000 0000000 Copyright (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.in 0000664 0000000 0000000 00000000113 13612260056 0015517 0 ustar 00root root 0000000 0000000 include MANIFEST.in
include LICENSE
include CHANGELOG.md
include README.md
exchangelib-3.1.1/README.md 0000664 0000000 0000000 00000151640 13612260056 0015254 0 ustar 00root root 0000000 0000000 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, contact and distribution list items.
[](https://pypi.org/project/exchangelib/)
[](https://pypi.org/project/exchangelib/)
[](https://www.codacy.com/app/ecederstrand/exchangelib?utm_source=github.com&utm_medium=referral&utm_content=ecederstrand/exchangelib&utm_campaign=Badge_Grade)
[](http://travis-ci.com/ecederstrand/exchangelib)
[](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 ')
```
## 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/ 0000775 0000000 0000000 00000000000 13612260056 0014716 5 ustar 00root root 0000000 0000000 exchangelib-3.1.1/docs/_config.yml 0000664 0000000 0000000 00000000340 13612260056 0017042 0 ustar 00root root 0000000 0000000 ---
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.md 0000664 0000000 0000000 00000000164 13612260056 0016024 0 ustar 00root root 0000000 0000000 ---
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.md 0000664 0000000 0000000 00000000712 13612260056 0016347 0 ustar 00root root 0000000 0000000 ---
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/ 0000775 0000000 0000000 00000000000 13612260056 0016237 5 ustar 00root root 0000000 0000000 exchangelib-3.1.1/exchangelib/__init__.py 0000664 0000000 0000000 00000003746 13612260056 0020362 0 ustar 00root root 0000000 0000000 from .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.py 0000664 0000000 0000000 00000075200 13612260056 0020251 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000025725 13612260056 0021137 0 ustar 00root root 0000000 0000000 from 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/ 0000775 0000000 0000000 00000000000 13612260056 0020746 5 ustar 00root root 0000000 0000000 exchangelib-3.1.1/exchangelib/autodiscover/__init__.py 0000664 0000000 0000000 00000000736 13612260056 0023065 0 ustar 00root root 0000000 0000000 from .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.py 0000664 0000000 0000000 00000013362 13612260056 0022370 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000062765 13612260056 0023347 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000036244 13612260056 0023525 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000000531 13612260056 0023160 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000006211 13612260056 0021460 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000017647 13612260056 0021125 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000067740 13612260056 0020143 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000027173 13612260056 0021136 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000032313 13612260056 0022667 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000137654 13612260056 0020077 0 ustar 00root root 0000000 0000000 import 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/ 0000775 0000000 0000000 00000000000 13612260056 0017675 5 ustar 00root root 0000000 0000000 exchangelib-3.1.1/exchangelib/folders/__init__.py 0000664 0000000 0000000 00000006207 13612260056 0022013 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000075107 13612260056 0021173 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000034577 13612260056 0022605 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000042204 13612260056 0023123 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000013665 13612260056 0022143 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000034133 13612260056 0021421 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000006135 13612260056 0022512 0 ustar 00root root 0000000 0000000 import 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/ 0000775 0000000 0000000 00000000000 13612260056 0017360 5 ustar 00root root 0000000 0000000 exchangelib-3.1.1/exchangelib/items/__init__.py 0000664 0000000 0000000 00000005731 13612260056 0021477 0 ustar 00root root 0000000 0000000 from .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.py 0000664 0000000 0000000 00000015377 13612260056 0020661 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000037517 13612260056 0022536 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000015542 13612260056 0021374 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000051531 13612260056 0020675 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000021060 13612260056 0021355 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000002671 13612260056 0020725 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000012274 13612260056 0020702 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000144164 13612260056 0021017 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000076005 13612260056 0020462 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000072004 13612260056 0020475 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000027346 13612260056 0020762 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000056301 13612260056 0021163 0 ustar 00root root 0000000 0000000 import 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/ 0000775 0000000 0000000 00000000000 13612260056 0020062 5 ustar 00root root 0000000 0000000 exchangelib-3.1.1/exchangelib/services/__init__.py 0000664 0000000 0000000 00000004676 13612260056 0022210 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000003554 13612260056 0023102 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000077331 13612260056 0021737 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000004531 13612260056 0022573 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000000332 13612260056 0022422 0 ustar 00root root 0000000 0000000 from . 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.py 0000664 0000000 0000000 00000002162 13612260056 0024110 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000002713 13612260056 0023235 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000005072 13612260056 0022721 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000002470 13612260056 0024111 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000001573 13612260056 0023237 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000004601 13612260056 0022715 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000002271 13612260056 0023127 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000002107 13612260056 0022372 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000002120 13612260056 0023151 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000005742 13612260056 0022717 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000006247 13612260056 0022403 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000013325 13612260056 0022724 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000007434 13612260056 0023433 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000004440 13612260056 0023047 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000004322 13612260056 0022547 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000002725 13612260056 0022237 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000003165 13612260056 0023261 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000002103 13612260056 0022736 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000001436 13612260056 0023471 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000001637 13612260056 0022441 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000005255 13612260056 0025436 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000013303 13612260056 0025035 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000004201 13612260056 0025000 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000003326 13612260056 0025040 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000001753 13612260056 0022426 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000005774 13612260056 0023313 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000002226 13612260056 0022405 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000002417 13612260056 0025054 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000010301 13612260056 0023244 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000022516 13612260056 0022742 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000003533 13612260056 0023125 0 ustar 00root root 0000000 0000000 from ..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.py 0000664 0000000 0000000 00000007754 13612260056 0020466 0 ustar 00root root 0000000 0000000 from .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.py 0000664 0000000 0000000 00000017567 13612260056 0020665 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000102534 13612260056 0017573 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000030377 13612260056 0020310 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000070017 13612260056 0020307 0 ustar 00root root 0000000 0000000 """ 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/ 0000775 0000000 0000000 00000000000 13612260056 0015455 5 ustar 00root root 0000000 0000000 exchangelib-3.1.1/scripts/notifier.py 0000664 0000000 0000000 00000006070 13612260056 0017651 0 ustar 00root root 0000000 0000000 """
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.py 0000775 0000000 0000000 00000005675 13612260056 0017707 0 ustar 00root root 0000000 0000000 #!/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.py 0000664 0000000 0000000 00000000125 13612260056 0021544 0 ustar 00root root 0000000 0000000 from tests.common import EWSTest
t = EWSTest()
t.setUpClass()
t.wipe_test_account()
exchangelib-3.1.1/settings.yml.enc 0000664 0000000 0000000 00000000420 13612260056 0017111 0 ustar 00root root 0000000 0000000 >e`WXG5+2ijt=5gT*4)JQCD#!m90zI
/4_eyCgYdzlqB|v>i()kjZ)FQ5|6kv ơs yҕCwq0 *b(o-5Ҩ۱2{a3fkɧ0iD}PBw[ kO1aVWm 1 ½Z[|tQŖNW8#OŕhBU!AM; exchangelib-3.1.1/settings.yml.sample 0000664 0000000 0000000 00000000374 13612260056 0017635 0 ustar 00root root 0000000 0000000 server: '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.cfg 0000664 0000000 0000000 00000000077 13612260056 0015613 0 ustar 00root root 0000000 0000000 [bdist_wheel]
universal = 1
[metadata]
license_file = LICENSE
exchangelib-3.1.1/setup.py 0000775 0000000 0000000 00000004133 13612260056 0015504 0 ustar 00root root 0000000 0000000 #!/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/ 0000775 0000000 0000000 00000000000 13612260056 0015130 5 ustar 00root root 0000000 0000000 exchangelib-3.1.1/tests/__init__.py 0000664 0000000 0000000 00000000544 13612260056 0017244 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000034754 13612260056 0017007 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000025142 13612260056 0020201 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000024224 13612260056 0021060 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000064275 13612260056 0021266 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000003412 13612260056 0017640 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000007042 13612260056 0021413 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000001671 13612260056 0021043 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000022313 13612260056 0021055 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000026470 13612260056 0022626 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000026663 13612260056 0017641 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000051227 13612260056 0020023 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000344163 13612260056 0017675 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000021555 13612260056 0020745 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000056537 13612260056 0020422 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000007140 13612260056 0020424 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000004577 13612260056 0020713 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000014714 13612260056 0021115 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000020420 13612260056 0020362 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000010003 13612260056 0020033 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000012737 13612260056 0020607 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000032434 13612260056 0017524 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000006616 13612260056 0020237 0 ustar 00root root 0000000 0000000 import 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'''\
''')
)