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/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, ...)."""