File: extended_properties.py

package info (click to toggle)
python-exchangelib 3.2.0-1
  • links: PTS, VCS
  • area: main
  • in suites: bullseye
  • size: 1,496 kB
  • sloc: python: 18,149; makefile: 3
file content (325 lines) | stat: -rw-r--r-- 13,946 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
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.
        # TODO: Rewrite to take advantage of exchangelib.properties.ExtendedFieldURI
        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()


class Flag(ExtendedProperty):
    """This property returns 0 for Not Flagged messages, 1 for Flagged messages and 2 for Completed messages.

    For a description of each status, see:
    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/flagstatus
    """
    property_tag = 0x1090
    property_type = 'Integer'