pyactivesync 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyactivesync/__init__.py +36 -0
- pyactivesync/_codepages.py +135 -0
- pyactivesync/_http.py +80 -0
- pyactivesync/_mime.py +22 -0
- pyactivesync/_wbxml.py +200 -0
- pyactivesync/cli.py +154 -0
- pyactivesync/client.py +642 -0
- pyactivesync/exceptions.py +29 -0
- pyactivesync/models.py +112 -0
- pyactivesync-0.2.0.dist-info/METADATA +142 -0
- pyactivesync-0.2.0.dist-info/RECORD +14 -0
- pyactivesync-0.2.0.dist-info/WHEEL +4 -0
- pyactivesync-0.2.0.dist-info/entry_points.txt +2 -0
- pyactivesync-0.2.0.dist-info/licenses/LICENSE +21 -0
pyactivesync/client.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""EAS client: one method per MS-ASCMD command, WBXML in and out."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import uuid
|
|
6
|
+
from email.message import Message
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
|
|
9
|
+
from ._http import Transport
|
|
10
|
+
from ._mime import to_crlf_bytes
|
|
11
|
+
from ._wbxml import Node, WBXMLReader, WBXMLWriter, find, find_all, leaves, text_of, wtag
|
|
12
|
+
from .exceptions import ProtocolError, ProvisionError, StatusError
|
|
13
|
+
from .models import (
|
|
14
|
+
BodyType,
|
|
15
|
+
Folder,
|
|
16
|
+
FolderType,
|
|
17
|
+
GalEntry,
|
|
18
|
+
PingResult,
|
|
19
|
+
Recipient,
|
|
20
|
+
SyncItem,
|
|
21
|
+
SyncResult,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_PROVISION_STATUS_MEANINGS = {"165": "DeviceInformationRequired"}
|
|
25
|
+
_SENDMAIL_STATUS_MEANINGS = {"101": "InvalidContent"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_status(command: str, status: str | None, meanings: dict[str, str] | None = None) -> None:
|
|
29
|
+
if status is not None and status != "1":
|
|
30
|
+
raise StatusError(command, status, (meanings or {}).get(status))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Client:
|
|
34
|
+
"""A stateless-HTTP EAS client wrapping one connection to a single mailbox.
|
|
35
|
+
|
|
36
|
+
``username`` is passed to HTTP Basic auth as-is (NTLM-style
|
|
37
|
+
``domain\\user`` or a plain email, both work against Basic-auth EAS
|
|
38
|
+
endpoints). ``user`` is the value sent as the ``User`` query parameter
|
|
39
|
+
on every command (the mailbox's SMTP address) -- it defaults to
|
|
40
|
+
``username`` and only needs to be set separately when the login
|
|
41
|
+
identity and the mailbox's primary SMTP address differ.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
server: str,
|
|
47
|
+
username: str,
|
|
48
|
+
password: str,
|
|
49
|
+
*,
|
|
50
|
+
device_id: str,
|
|
51
|
+
user: str | None = None,
|
|
52
|
+
device_type: str = "pyactivesync",
|
|
53
|
+
protocol_version: str = "14.1",
|
|
54
|
+
verify_ssl: bool = True,
|
|
55
|
+
timeout: float = 30.0,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.user = user or username
|
|
58
|
+
self._transport = Transport(
|
|
59
|
+
server,
|
|
60
|
+
username,
|
|
61
|
+
password,
|
|
62
|
+
device_id=device_id,
|
|
63
|
+
device_type=device_type,
|
|
64
|
+
protocol_version=protocol_version,
|
|
65
|
+
verify_ssl=verify_ssl,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
)
|
|
68
|
+
self._provisioned = False
|
|
69
|
+
|
|
70
|
+
def __enter__(self) -> Client:
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def __exit__(
|
|
74
|
+
self,
|
|
75
|
+
exc_type: type[BaseException] | None,
|
|
76
|
+
exc: BaseException | None,
|
|
77
|
+
tb: TracebackType | None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.close()
|
|
80
|
+
|
|
81
|
+
def close(self) -> None:
|
|
82
|
+
self._transport.close()
|
|
83
|
+
|
|
84
|
+
def _post(
|
|
85
|
+
self, cmd: str, wbxml: bytes, *, extra_params: dict[str, str] | None = None, timeout: float | None = None
|
|
86
|
+
) -> bytes:
|
|
87
|
+
return self._transport.post(cmd, self.user, wbxml, extra_params=extra_params, timeout=timeout)
|
|
88
|
+
|
|
89
|
+
def _ensure_provisioned(self) -> None:
|
|
90
|
+
if not self._provisioned:
|
|
91
|
+
self.provision()
|
|
92
|
+
|
|
93
|
+
# -- Device provisioning -------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def provision(self) -> str:
|
|
96
|
+
"""Acknowledge device policy (two round-trips). Returns the granted PolicyKey.
|
|
97
|
+
|
|
98
|
+
A bare ``Provision`` request is rejected with ``Status=165``
|
|
99
|
+
(``DeviceInformationRequired``) unless a ``Settings.DeviceInformation``
|
|
100
|
+
block is included on the first request.
|
|
101
|
+
"""
|
|
102
|
+
w = WBXMLWriter()
|
|
103
|
+
w.tag(
|
|
104
|
+
"Provision",
|
|
105
|
+
"Provision",
|
|
106
|
+
children=[
|
|
107
|
+
wtag(
|
|
108
|
+
"Settings",
|
|
109
|
+
"DeviceInformation",
|
|
110
|
+
children=[
|
|
111
|
+
wtag(
|
|
112
|
+
"Settings",
|
|
113
|
+
"Set",
|
|
114
|
+
children=[
|
|
115
|
+
wtag("Settings", "Model", text="pyactivesync"),
|
|
116
|
+
wtag("Settings", "FriendlyName", text="pyactivesync client"),
|
|
117
|
+
wtag("Settings", "OS", text="Linux"),
|
|
118
|
+
wtag("Settings", "UserAgent", text="pyactivesync/0.1"),
|
|
119
|
+
],
|
|
120
|
+
),
|
|
121
|
+
],
|
|
122
|
+
),
|
|
123
|
+
wtag(
|
|
124
|
+
"Provision",
|
|
125
|
+
"Policies",
|
|
126
|
+
children=[
|
|
127
|
+
wtag(
|
|
128
|
+
"Provision",
|
|
129
|
+
"Policy",
|
|
130
|
+
children=[wtag("Provision", "PolicyType", text="MS-EAS-Provisioning-WBXML")],
|
|
131
|
+
),
|
|
132
|
+
],
|
|
133
|
+
),
|
|
134
|
+
],
|
|
135
|
+
)
|
|
136
|
+
resp = self._post("Provision", w.render())
|
|
137
|
+
nodes = WBXMLReader(resp).parse()
|
|
138
|
+
status = text_of(find(nodes, "Provision.Status"))
|
|
139
|
+
_check_status("Provision", status, _PROVISION_STATUS_MEANINGS)
|
|
140
|
+
temp_key = text_of(find(nodes, "Provision.PolicyKey"))
|
|
141
|
+
if not temp_key:
|
|
142
|
+
raise ProvisionError("Provision: no PolicyKey in response")
|
|
143
|
+
|
|
144
|
+
w2 = WBXMLWriter()
|
|
145
|
+
w2.tag(
|
|
146
|
+
"Provision",
|
|
147
|
+
"Provision",
|
|
148
|
+
children=[
|
|
149
|
+
wtag(
|
|
150
|
+
"Provision",
|
|
151
|
+
"Policies",
|
|
152
|
+
children=[
|
|
153
|
+
wtag(
|
|
154
|
+
"Provision",
|
|
155
|
+
"Policy",
|
|
156
|
+
children=[
|
|
157
|
+
wtag("Provision", "PolicyType", text="MS-EAS-Provisioning-WBXML"),
|
|
158
|
+
wtag("Provision", "PolicyKey", text=temp_key),
|
|
159
|
+
wtag("Provision", "Status", text="1"),
|
|
160
|
+
],
|
|
161
|
+
),
|
|
162
|
+
],
|
|
163
|
+
),
|
|
164
|
+
],
|
|
165
|
+
)
|
|
166
|
+
resp2 = self._post("Provision", w2.render(), extra_params={"PolicyKey": temp_key})
|
|
167
|
+
nodes2 = WBXMLReader(resp2).parse()
|
|
168
|
+
final_key = text_of(find(nodes2, "Provision.PolicyKey"))
|
|
169
|
+
key = final_key or temp_key
|
|
170
|
+
self._transport.policy_key = key
|
|
171
|
+
self._provisioned = True
|
|
172
|
+
return key
|
|
173
|
+
|
|
174
|
+
# -- Folders ---------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def list_folders(self) -> list[Folder]:
|
|
177
|
+
"""Full ``FolderSync`` hierarchy listing."""
|
|
178
|
+
self._ensure_provisioned()
|
|
179
|
+
w = WBXMLWriter()
|
|
180
|
+
w.tag("FolderHierarchy", "FolderSync", children=[wtag("FolderHierarchy", "SyncKey", text="0")])
|
|
181
|
+
resp = self._post("FolderSync", w.render())
|
|
182
|
+
nodes = WBXMLReader(resp).parse()
|
|
183
|
+
status = text_of(find(nodes, "FolderHierarchy.Status"))
|
|
184
|
+
_check_status("FolderSync", status)
|
|
185
|
+
folders = []
|
|
186
|
+
for _, _, children in find_all(nodes, "FolderHierarchy.Add"):
|
|
187
|
+
server_id = text_of(find(children, "FolderHierarchy.ServerId"))
|
|
188
|
+
parent_id = text_of(find(children, "FolderHierarchy.ParentId"))
|
|
189
|
+
type_ = text_of(find(children, "FolderHierarchy.Type"))
|
|
190
|
+
name = text_of(find(children, "FolderHierarchy.DisplayName"))
|
|
191
|
+
if server_id is None or parent_id is None or type_ is None or name is None:
|
|
192
|
+
raise ProtocolError("FolderSync: incomplete Add entry in response")
|
|
193
|
+
folders.append(Folder(id=server_id, parent_id=parent_id, type=FolderType(int(type_)), name=name))
|
|
194
|
+
return folders
|
|
195
|
+
|
|
196
|
+
def _folder_sync_key(self) -> str:
|
|
197
|
+
"""Bootstrap ``SyncKey`` for a mutating FolderHierarchy command."""
|
|
198
|
+
w = WBXMLWriter()
|
|
199
|
+
w.tag("FolderHierarchy", "FolderSync", children=[wtag("FolderHierarchy", "SyncKey", text="0")])
|
|
200
|
+
resp = self._post("FolderSync", w.render())
|
|
201
|
+
key = text_of(find(WBXMLReader(resp).parse(), "FolderHierarchy.SyncKey"))
|
|
202
|
+
if not key:
|
|
203
|
+
raise ProtocolError("FolderSync: no SyncKey in response")
|
|
204
|
+
return key
|
|
205
|
+
|
|
206
|
+
def create_folder(
|
|
207
|
+
self, display_name: str, parent_id: str = "0", type: FolderType = FolderType.USER_GENERIC
|
|
208
|
+
) -> Folder:
|
|
209
|
+
self._ensure_provisioned()
|
|
210
|
+
sync_key = self._folder_sync_key()
|
|
211
|
+
w = WBXMLWriter()
|
|
212
|
+
w.tag(
|
|
213
|
+
"FolderHierarchy",
|
|
214
|
+
"FolderCreate",
|
|
215
|
+
children=[
|
|
216
|
+
wtag("FolderHierarchy", "SyncKey", text=sync_key),
|
|
217
|
+
wtag("FolderHierarchy", "ParentId", text=parent_id),
|
|
218
|
+
wtag("FolderHierarchy", "DisplayName", text=display_name),
|
|
219
|
+
wtag("FolderHierarchy", "Type", text=str(int(type))),
|
|
220
|
+
],
|
|
221
|
+
)
|
|
222
|
+
resp = self._post("FolderCreate", w.render())
|
|
223
|
+
nodes = WBXMLReader(resp).parse()
|
|
224
|
+
status = text_of(find(nodes, "FolderHierarchy.Status"))
|
|
225
|
+
_check_status("FolderCreate", status)
|
|
226
|
+
server_id = text_of(find(nodes, "FolderHierarchy.ServerId"))
|
|
227
|
+
if not server_id:
|
|
228
|
+
raise ProtocolError("FolderCreate: no ServerId in response")
|
|
229
|
+
return Folder(id=server_id, parent_id=parent_id, type=type, name=display_name)
|
|
230
|
+
|
|
231
|
+
def update_folder(self, folder_id: str, display_name: str, parent_id: str = "0") -> None:
|
|
232
|
+
self._ensure_provisioned()
|
|
233
|
+
sync_key = self._folder_sync_key()
|
|
234
|
+
w = WBXMLWriter()
|
|
235
|
+
w.tag(
|
|
236
|
+
"FolderHierarchy",
|
|
237
|
+
"FolderUpdate",
|
|
238
|
+
children=[
|
|
239
|
+
wtag("FolderHierarchy", "SyncKey", text=sync_key),
|
|
240
|
+
wtag("FolderHierarchy", "ServerId", text=folder_id),
|
|
241
|
+
wtag("FolderHierarchy", "ParentId", text=parent_id),
|
|
242
|
+
wtag("FolderHierarchy", "DisplayName", text=display_name),
|
|
243
|
+
],
|
|
244
|
+
)
|
|
245
|
+
resp = self._post("FolderUpdate", w.render())
|
|
246
|
+
status = text_of(find(WBXMLReader(resp).parse(), "FolderHierarchy.Status"))
|
|
247
|
+
_check_status("FolderUpdate", status)
|
|
248
|
+
|
|
249
|
+
def delete_folder(self, folder_id: str) -> None:
|
|
250
|
+
self._ensure_provisioned()
|
|
251
|
+
sync_key = self._folder_sync_key()
|
|
252
|
+
w = WBXMLWriter()
|
|
253
|
+
w.tag(
|
|
254
|
+
"FolderHierarchy",
|
|
255
|
+
"FolderDelete",
|
|
256
|
+
children=[
|
|
257
|
+
wtag("FolderHierarchy", "SyncKey", text=sync_key),
|
|
258
|
+
wtag("FolderHierarchy", "ServerId", text=folder_id),
|
|
259
|
+
],
|
|
260
|
+
)
|
|
261
|
+
resp = self._post("FolderDelete", w.render())
|
|
262
|
+
status = text_of(find(WBXMLReader(resp).parse(), "FolderHierarchy.Status"))
|
|
263
|
+
_check_status("FolderDelete", status)
|
|
264
|
+
|
|
265
|
+
# -- Sync ------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def sync_folder(
|
|
268
|
+
self,
|
|
269
|
+
folder_id: str,
|
|
270
|
+
sync_key: str = "0",
|
|
271
|
+
*,
|
|
272
|
+
window_size: int = 25,
|
|
273
|
+
filter_type: str | None = None,
|
|
274
|
+
) -> SyncResult:
|
|
275
|
+
"""``Sync`` one collection. Pass ``sync_key="0"`` (the default) to bootstrap.
|
|
276
|
+
|
|
277
|
+
A bootstrap request only returns a fresh ``SyncKey``, no items --
|
|
278
|
+
call again with that key to fetch the actual ``Add``/``Change``/``Delete``
|
|
279
|
+
entries, exactly as the protocol requires.
|
|
280
|
+
"""
|
|
281
|
+
self._ensure_provisioned()
|
|
282
|
+
options = [wtag("AirSync", "FilterType", text=filter_type)] if filter_type else []
|
|
283
|
+
collection_children = [
|
|
284
|
+
wtag("AirSync", "SyncKey", text=sync_key),
|
|
285
|
+
wtag("AirSync", "CollectionId", text=folder_id),
|
|
286
|
+
wtag("AirSync", "WindowSize", text=str(window_size)),
|
|
287
|
+
]
|
|
288
|
+
if options:
|
|
289
|
+
collection_children.append(wtag("AirSync", "Options", children=options))
|
|
290
|
+
w = WBXMLWriter()
|
|
291
|
+
w.tag(
|
|
292
|
+
"AirSync",
|
|
293
|
+
"Sync",
|
|
294
|
+
children=[
|
|
295
|
+
wtag(
|
|
296
|
+
"AirSync",
|
|
297
|
+
"Collections",
|
|
298
|
+
children=[wtag("AirSync", "Collection", children=collection_children)],
|
|
299
|
+
),
|
|
300
|
+
],
|
|
301
|
+
)
|
|
302
|
+
resp = self._post("Sync", w.render())
|
|
303
|
+
nodes = WBXMLReader(resp).parse()
|
|
304
|
+
status = text_of(find(nodes, "AirSync.Status"))
|
|
305
|
+
_check_status("Sync", status)
|
|
306
|
+
new_sync_key = text_of(find(nodes, "AirSync.SyncKey")) or sync_key
|
|
307
|
+
|
|
308
|
+
added = [self._sync_item(c) for _, _, c in find_all(nodes, "AirSync.Add")]
|
|
309
|
+
changed = [self._sync_item(c) for _, _, c in find_all(nodes, "AirSync.Change")]
|
|
310
|
+
deleted = [
|
|
311
|
+
sid
|
|
312
|
+
for _, _, c in find_all(nodes, "AirSync.Delete")
|
|
313
|
+
if (sid := text_of(find(c, "AirSync.ServerId"))) is not None
|
|
314
|
+
]
|
|
315
|
+
more_available = find(nodes, "AirSync.MoreAvailable") is not None
|
|
316
|
+
return SyncResult(
|
|
317
|
+
sync_key=new_sync_key, added=added, changed=changed, deleted=deleted, more_available=more_available
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
@staticmethod
|
|
321
|
+
def _sync_item(children: list[Node]) -> SyncItem:
|
|
322
|
+
server_id = text_of(find(children, "AirSync.ServerId")) or ""
|
|
323
|
+
appdata = find(children, "AirSync.ApplicationData")
|
|
324
|
+
return SyncItem(server_id=server_id, fields=leaves(appdata[2]) if appdata else {})
|
|
325
|
+
|
|
326
|
+
def get_item_estimate(self, folder_id: str, sync_key: str) -> int:
|
|
327
|
+
"""``GetItemEstimate`` needs a real (non-zero) ``SyncKey`` -- bootstrap one via ``sync_folder`` first."""
|
|
328
|
+
self._ensure_provisioned()
|
|
329
|
+
w = WBXMLWriter()
|
|
330
|
+
w.tag(
|
|
331
|
+
"ItemEstimate",
|
|
332
|
+
"GetItemEstimate",
|
|
333
|
+
children=[
|
|
334
|
+
wtag(
|
|
335
|
+
"ItemEstimate",
|
|
336
|
+
"Collections",
|
|
337
|
+
children=[
|
|
338
|
+
wtag(
|
|
339
|
+
"ItemEstimate",
|
|
340
|
+
"Collection",
|
|
341
|
+
children=[
|
|
342
|
+
wtag("AirSync", "SyncKey", text=sync_key),
|
|
343
|
+
wtag("ItemEstimate", "CollectionId", text=folder_id),
|
|
344
|
+
],
|
|
345
|
+
),
|
|
346
|
+
],
|
|
347
|
+
),
|
|
348
|
+
],
|
|
349
|
+
)
|
|
350
|
+
resp = self._post("GetItemEstimate", w.render())
|
|
351
|
+
nodes = WBXMLReader(resp).parse()
|
|
352
|
+
status = text_of(find(nodes, "ItemEstimate.Status"))
|
|
353
|
+
_check_status("GetItemEstimate", status)
|
|
354
|
+
estimate = text_of(find(nodes, "ItemEstimate.Estimate"))
|
|
355
|
+
if estimate is None:
|
|
356
|
+
raise ProtocolError("GetItemEstimate: no Estimate in response")
|
|
357
|
+
return int(estimate)
|
|
358
|
+
|
|
359
|
+
# -- Items -------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
def fetch_item(self, folder_id: str, item_id: str, *, body_type: BodyType = BodyType.HTML) -> dict[str, str]:
|
|
362
|
+
"""``ItemOperations`` Fetch: full item properties. Does not mark the item read."""
|
|
363
|
+
self._ensure_provisioned()
|
|
364
|
+
w = WBXMLWriter()
|
|
365
|
+
w.tag(
|
|
366
|
+
"ItemOperations",
|
|
367
|
+
"ItemOperations",
|
|
368
|
+
children=[
|
|
369
|
+
wtag(
|
|
370
|
+
"ItemOperations",
|
|
371
|
+
"Fetch",
|
|
372
|
+
children=[
|
|
373
|
+
wtag("ItemOperations", "Store", text="Mailbox"),
|
|
374
|
+
wtag("AirSync", "CollectionId", text=folder_id),
|
|
375
|
+
wtag("AirSync", "ServerId", text=item_id),
|
|
376
|
+
wtag(
|
|
377
|
+
"ItemOperations",
|
|
378
|
+
"Options",
|
|
379
|
+
children=[
|
|
380
|
+
wtag(
|
|
381
|
+
"AirSyncBase",
|
|
382
|
+
"BodyPreference",
|
|
383
|
+
children=[wtag("AirSyncBase", "Type", text=str(int(body_type)))],
|
|
384
|
+
),
|
|
385
|
+
],
|
|
386
|
+
),
|
|
387
|
+
],
|
|
388
|
+
),
|
|
389
|
+
],
|
|
390
|
+
)
|
|
391
|
+
resp = self._post("ItemOperations", w.render())
|
|
392
|
+
nodes = WBXMLReader(resp).parse()
|
|
393
|
+
status = text_of(find(nodes, "ItemOperations.Status"))
|
|
394
|
+
_check_status("ItemOperations", status)
|
|
395
|
+
props = find(nodes, "ItemOperations.Properties")
|
|
396
|
+
return leaves(props[2]) if props else {}
|
|
397
|
+
|
|
398
|
+
def fetch_attachment(self, file_reference: str) -> bytes:
|
|
399
|
+
"""``ItemOperations`` Fetch by ``FileReference``. Returns decoded attachment bytes."""
|
|
400
|
+
self._ensure_provisioned()
|
|
401
|
+
w = WBXMLWriter()
|
|
402
|
+
w.tag(
|
|
403
|
+
"ItemOperations",
|
|
404
|
+
"ItemOperations",
|
|
405
|
+
children=[
|
|
406
|
+
wtag(
|
|
407
|
+
"ItemOperations",
|
|
408
|
+
"Fetch",
|
|
409
|
+
children=[
|
|
410
|
+
wtag("ItemOperations", "Store", text="Mailbox"),
|
|
411
|
+
wtag("AirSyncBase", "FileReference", text=file_reference),
|
|
412
|
+
],
|
|
413
|
+
),
|
|
414
|
+
],
|
|
415
|
+
)
|
|
416
|
+
resp = self._post("ItemOperations", w.render())
|
|
417
|
+
nodes = WBXMLReader(resp).parse()
|
|
418
|
+
status = text_of(find(nodes, "ItemOperations.Status"))
|
|
419
|
+
_check_status("ItemOperations", status)
|
|
420
|
+
b64 = text_of(find(nodes, "ItemOperations.Data"))
|
|
421
|
+
return base64.b64decode(b64) if b64 else b""
|
|
422
|
+
|
|
423
|
+
def move_item(self, item_id: str, src_folder_id: str, dst_folder_id: str) -> str:
|
|
424
|
+
"""``MoveItems``. Returns the new (destination) ``ServerId``."""
|
|
425
|
+
self._ensure_provisioned()
|
|
426
|
+
w = WBXMLWriter()
|
|
427
|
+
w.tag(
|
|
428
|
+
"Move",
|
|
429
|
+
"MoveItems",
|
|
430
|
+
children=[
|
|
431
|
+
wtag(
|
|
432
|
+
"Move",
|
|
433
|
+
"Move",
|
|
434
|
+
children=[
|
|
435
|
+
wtag("Move", "SrcMsgId", text=item_id),
|
|
436
|
+
wtag("Move", "SrcFldId", text=src_folder_id),
|
|
437
|
+
wtag("Move", "DstFldId", text=dst_folder_id),
|
|
438
|
+
],
|
|
439
|
+
),
|
|
440
|
+
],
|
|
441
|
+
)
|
|
442
|
+
resp = self._post("MoveItems", w.render())
|
|
443
|
+
nodes = WBXMLReader(resp).parse()
|
|
444
|
+
status = text_of(find(nodes, "Move.Status"))
|
|
445
|
+
_check_status("MoveItems", status)
|
|
446
|
+
new_id = text_of(find(nodes, "Move.DstMsgId"))
|
|
447
|
+
if not new_id:
|
|
448
|
+
raise ProtocolError("MoveItems: no DstMsgId in response")
|
|
449
|
+
return new_id
|
|
450
|
+
|
|
451
|
+
# -- Mail --------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
def send_mail(self, message: Message, *, save_in_sent_items: bool = True) -> None:
|
|
454
|
+
"""``SendMail``: WBXML ``ComposeMail`` wrapper, MIME embedded as opaque data.
|
|
455
|
+
|
|
456
|
+
Protocol v14.1 (what this client negotiates) requires the
|
|
457
|
+
``ComposeMail`` WBXML wrapper -- unlike v12.0/12.1, a raw
|
|
458
|
+
``message/rfc822`` POST body is rejected.
|
|
459
|
+
"""
|
|
460
|
+
self._ensure_provisioned()
|
|
461
|
+
mime_bytes = to_crlf_bytes(message)
|
|
462
|
+
children = [wtag("ComposeMail", "ClientId", text=uuid.uuid4().hex)]
|
|
463
|
+
if save_in_sent_items:
|
|
464
|
+
children.append(wtag("ComposeMail", "SaveInSentItems"))
|
|
465
|
+
children.append(wtag("ComposeMail", "MIME", opaque=mime_bytes))
|
|
466
|
+
w = WBXMLWriter()
|
|
467
|
+
w.tag("ComposeMail", "SendMail", children=children)
|
|
468
|
+
resp = self._post("SendMail", w.render())
|
|
469
|
+
if resp:
|
|
470
|
+
status = text_of(find(WBXMLReader(resp).parse(), "ComposeMail.Status"))
|
|
471
|
+
_check_status("SendMail", status, _SENDMAIL_STATUS_MEANINGS)
|
|
472
|
+
|
|
473
|
+
# -- Device / connectivity ----------------------------------------------------
|
|
474
|
+
|
|
475
|
+
def ping(
|
|
476
|
+
self, folder_id: str, *, folder_class: str = "Email", heartbeat: int = 60, timeout: float | None = None
|
|
477
|
+
) -> PingResult:
|
|
478
|
+
"""``Ping``: long-poll for changes on one folder.
|
|
479
|
+
|
|
480
|
+
``timeout`` bounds the client-side wait; it is independent of
|
|
481
|
+
``heartbeat`` (the server-side long-poll duration requested).
|
|
482
|
+
"""
|
|
483
|
+
self._ensure_provisioned()
|
|
484
|
+
w = WBXMLWriter()
|
|
485
|
+
w.tag(
|
|
486
|
+
"Ping",
|
|
487
|
+
"Ping",
|
|
488
|
+
children=[
|
|
489
|
+
wtag("Ping", "HeartbeatInterval", text=str(heartbeat)),
|
|
490
|
+
wtag(
|
|
491
|
+
"Ping",
|
|
492
|
+
"Folders",
|
|
493
|
+
children=[
|
|
494
|
+
wtag(
|
|
495
|
+
"Ping",
|
|
496
|
+
"Folder",
|
|
497
|
+
children=[
|
|
498
|
+
wtag("Ping", "Id", text=folder_id),
|
|
499
|
+
wtag("Ping", "Class", text=folder_class),
|
|
500
|
+
],
|
|
501
|
+
),
|
|
502
|
+
],
|
|
503
|
+
),
|
|
504
|
+
],
|
|
505
|
+
)
|
|
506
|
+
resp = self._post("Ping", w.render(), timeout=timeout)
|
|
507
|
+
nodes = WBXMLReader(resp).parse()
|
|
508
|
+
status = text_of(find(nodes, "Ping.Status")) or ""
|
|
509
|
+
changed = [
|
|
510
|
+
sid for _, _, c in find_all(nodes, "Ping.Folder") if (sid := text_of(find(c, "Ping.Id"))) is not None
|
|
511
|
+
]
|
|
512
|
+
return PingResult(status=status, changed_folder_ids=changed)
|
|
513
|
+
|
|
514
|
+
# -- Directory / search --------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
def resolve_recipients(self, address: str) -> list[Recipient]:
|
|
517
|
+
"""Resolve one address against contacts/GAL."""
|
|
518
|
+
self._ensure_provisioned()
|
|
519
|
+
w = WBXMLWriter()
|
|
520
|
+
w.tag("ResolveRecipients", "ResolveRecipients", children=[wtag("ResolveRecipients", "To", text=address)])
|
|
521
|
+
resp = self._post("ResolveRecipients", w.render())
|
|
522
|
+
nodes = WBXMLReader(resp).parse()
|
|
523
|
+
status = text_of(find(nodes, "ResolveRecipients.Status"))
|
|
524
|
+
_check_status("ResolveRecipients", status)
|
|
525
|
+
recipients = []
|
|
526
|
+
for _, _, children in find_all(nodes, "ResolveRecipients.Recipient"):
|
|
527
|
+
data = leaves(children)
|
|
528
|
+
recipients.append(
|
|
529
|
+
Recipient(
|
|
530
|
+
type=data.get("ResolveRecipients.Type"),
|
|
531
|
+
display_name=data.get("ResolveRecipients.DisplayName"),
|
|
532
|
+
email_address=data.get("ResolveRecipients.EmailAddress"),
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
return recipients
|
|
536
|
+
|
|
537
|
+
def search_gal(self, query: str, *, max_results: int = 10) -> list[GalEntry]:
|
|
538
|
+
"""GAL (directory) search."""
|
|
539
|
+
self._ensure_provisioned()
|
|
540
|
+
w = WBXMLWriter()
|
|
541
|
+
w.tag(
|
|
542
|
+
"Search",
|
|
543
|
+
"Search",
|
|
544
|
+
children=[
|
|
545
|
+
wtag(
|
|
546
|
+
"Search",
|
|
547
|
+
"Store",
|
|
548
|
+
children=[
|
|
549
|
+
wtag("Search", "Name", text="GAL"),
|
|
550
|
+
wtag("Search", "Query", text=query),
|
|
551
|
+
wtag("Search", "Options", children=[wtag("Search", "Range", text=f"0-{max_results - 1}")]),
|
|
552
|
+
],
|
|
553
|
+
),
|
|
554
|
+
],
|
|
555
|
+
)
|
|
556
|
+
resp = self._post("Search", w.render())
|
|
557
|
+
nodes = WBXMLReader(resp).parse()
|
|
558
|
+
status = text_of(find(nodes, "Search.Status"))
|
|
559
|
+
_check_status("Search", status)
|
|
560
|
+
entries = []
|
|
561
|
+
for _, _, children in find_all(nodes, "Search.Result"):
|
|
562
|
+
data = leaves(children)
|
|
563
|
+
entries.append(
|
|
564
|
+
GalEntry(
|
|
565
|
+
display_name=data.get("GAL.DisplayName"),
|
|
566
|
+
email_address=data.get("GAL.EmailAddress"),
|
|
567
|
+
phone=data.get("GAL.Phone"),
|
|
568
|
+
office=data.get("GAL.Office"),
|
|
569
|
+
title=data.get("GAL.Title"),
|
|
570
|
+
company=data.get("GAL.Company"),
|
|
571
|
+
alias=data.get("GAL.Alias"),
|
|
572
|
+
first_name=data.get("GAL.FirstName"),
|
|
573
|
+
last_name=data.get("GAL.LastName"),
|
|
574
|
+
home_phone=data.get("GAL.HomePhone"),
|
|
575
|
+
mobile_phone=data.get("GAL.MobilePhone"),
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
return entries
|
|
579
|
+
|
|
580
|
+
def search_mailbox(
|
|
581
|
+
self,
|
|
582
|
+
folder_id: str,
|
|
583
|
+
*,
|
|
584
|
+
date_received_after: str,
|
|
585
|
+
item_class: str = "Email",
|
|
586
|
+
max_results: int = 10,
|
|
587
|
+
) -> list[dict[str, str]]:
|
|
588
|
+
"""Structured mailbox search: items of ``item_class`` in ``folder_id``
|
|
589
|
+
received after ``date_received_after`` (an ``AirSyncBase``-style UTC
|
|
590
|
+
timestamp, e.g. ``"2020-01-01T00:00:00.000Z"``).
|
|
591
|
+
|
|
592
|
+
There is deliberately no ``free_text=`` parameter: full-text search
|
|
593
|
+
conditions in ``Search`` are known to fail against real servers
|
|
594
|
+
with ``Store.Status=110`` (a server-side bug, not a WBXML encoding
|
|
595
|
+
issue -- confirmed by cross-checking the same mailbox's content
|
|
596
|
+
index through an unrelated protocol). Shipping that parameter would
|
|
597
|
+
just reproduce the failure for every caller; only the structured
|
|
598
|
+
condition below is exposed.
|
|
599
|
+
"""
|
|
600
|
+
self._ensure_provisioned()
|
|
601
|
+
w = WBXMLWriter()
|
|
602
|
+
w.tag(
|
|
603
|
+
"Search",
|
|
604
|
+
"Search",
|
|
605
|
+
children=[
|
|
606
|
+
wtag(
|
|
607
|
+
"Search",
|
|
608
|
+
"Store",
|
|
609
|
+
children=[
|
|
610
|
+
wtag("Search", "Name", text="Mailbox"),
|
|
611
|
+
wtag(
|
|
612
|
+
"Search",
|
|
613
|
+
"Query",
|
|
614
|
+
children=[
|
|
615
|
+
wtag(
|
|
616
|
+
"Search",
|
|
617
|
+
"And",
|
|
618
|
+
children=[
|
|
619
|
+
wtag("AirSync", "Class", text=item_class),
|
|
620
|
+
wtag("AirSync", "CollectionId", text=folder_id),
|
|
621
|
+
wtag(
|
|
622
|
+
"Search",
|
|
623
|
+
"GreaterThan",
|
|
624
|
+
children=[
|
|
625
|
+
wtag("Email", "DateReceived"),
|
|
626
|
+
wtag("Search", "Value", text=date_received_after),
|
|
627
|
+
],
|
|
628
|
+
),
|
|
629
|
+
],
|
|
630
|
+
),
|
|
631
|
+
],
|
|
632
|
+
),
|
|
633
|
+
wtag("Search", "Options", children=[wtag("Search", "Range", text=f"0-{max_results - 1}")]),
|
|
634
|
+
],
|
|
635
|
+
),
|
|
636
|
+
],
|
|
637
|
+
)
|
|
638
|
+
resp = self._post("Search", w.render())
|
|
639
|
+
nodes = WBXMLReader(resp).parse()
|
|
640
|
+
status = text_of(find(nodes, "Search.Status"))
|
|
641
|
+
_check_status("Search", status)
|
|
642
|
+
return [leaves(children) for _, _, children in find_all(nodes, "Search.Result")]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Exception hierarchy for pyactivesync."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class EASError(Exception):
|
|
6
|
+
"""Base class for all pyactivesync errors."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProtocolError(EASError):
|
|
10
|
+
"""The server response did not match the expected MS-ASCMD/MS-ASWBXML shape."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StatusError(ProtocolError):
|
|
14
|
+
"""An EAS command returned a non-success ``Status`` code.
|
|
15
|
+
|
|
16
|
+
``code`` is the raw status string from the response (e.g. ``"110"``);
|
|
17
|
+
``meaning`` is a short human-readable label where known, else ``None``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, command: str, code: str, meaning: str | None = None) -> None:
|
|
21
|
+
self.command = command
|
|
22
|
+
self.code = code
|
|
23
|
+
self.meaning = meaning
|
|
24
|
+
suffix = f" ({meaning})" if meaning else ""
|
|
25
|
+
super().__init__(f"{command}: Status={code}{suffix}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProvisionError(EASError):
|
|
29
|
+
"""Device provisioning failed (policy not acknowledged, no PolicyKey granted, ...)."""
|