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/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""pyactivesync: a Python client for Exchange ActiveSync (EAS / MS-ASCMD / MS-ASWBXML)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .client import Client
|
|
5
|
+
from .exceptions import EASError, ProtocolError, ProvisionError, StatusError
|
|
6
|
+
from .models import (
|
|
7
|
+
AttachmentInfo,
|
|
8
|
+
BodyType,
|
|
9
|
+
Folder,
|
|
10
|
+
FolderType,
|
|
11
|
+
GalEntry,
|
|
12
|
+
PingResult,
|
|
13
|
+
Recipient,
|
|
14
|
+
SyncItem,
|
|
15
|
+
SyncResult,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Client",
|
|
22
|
+
"EASError",
|
|
23
|
+
"ProtocolError",
|
|
24
|
+
"ProvisionError",
|
|
25
|
+
"StatusError",
|
|
26
|
+
"AttachmentInfo",
|
|
27
|
+
"BodyType",
|
|
28
|
+
"Folder",
|
|
29
|
+
"FolderType",
|
|
30
|
+
"GalEntry",
|
|
31
|
+
"PingResult",
|
|
32
|
+
"Recipient",
|
|
33
|
+
"SyncItem",
|
|
34
|
+
"SyncResult",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""MS-ASWBXML codepage token tables.
|
|
2
|
+
|
|
3
|
+
Token values are the real MS-ASWBXML codepage numbers, verified against
|
|
4
|
+
both the official spec
|
|
5
|
+
(https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-aswbxml/)
|
|
6
|
+
and a live Exchange server -- not an arbitrary local enum order.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
CODEPAGES: dict[str, dict[str, int]] = {
|
|
11
|
+
"AirSync": {
|
|
12
|
+
"Sync": 0x05, "Responses": 0x06, "Add": 0x07, "Change": 0x08,
|
|
13
|
+
"Delete": 0x09, "Fetch": 0x0A, "SyncKey": 0x0B, "ClientId": 0x0C,
|
|
14
|
+
"ServerId": 0x0D, "Status": 0x0E, "Collection": 0x0F, "Class": 0x10,
|
|
15
|
+
"Version": 0x11, "CollectionId": 0x12, "GetChanges": 0x13,
|
|
16
|
+
"MoreAvailable": 0x14, "WindowSize": 0x15, "Commands": 0x16,
|
|
17
|
+
"Options": 0x17, "FilterType": 0x18, "Truncation": 0x19,
|
|
18
|
+
"Conflict": 0x1B, "Collections": 0x1C, "ApplicationData": 0x1D,
|
|
19
|
+
"DeletesAsMoves": 0x1E, "Supported": 0x20, "SoftDelete": 0x21,
|
|
20
|
+
"MIMESupport": 0x22, "MIMETruncation": 0x23, "Wait": 0x24,
|
|
21
|
+
"Limit": 0x25, "Partial": 0x26, "MaxItems": 0x28,
|
|
22
|
+
"HeartbeatInterval": 0x29,
|
|
23
|
+
},
|
|
24
|
+
"Email": {
|
|
25
|
+
"DateReceived": 0x0F, "DisplayTo": 0x11, "Importance": 0x12,
|
|
26
|
+
"MessageClass": 0x13, "Subject": 0x14, "Read": 0x15, "To": 0x16,
|
|
27
|
+
"Cc": 0x17, "From": 0x18, "ReplyTo": 0x19, "Categories": 0x1B,
|
|
28
|
+
"Category": 0x1C, "InternetCPID": 0x39, "Flag": 0x3A,
|
|
29
|
+
"ContentClass": 0x3C,
|
|
30
|
+
},
|
|
31
|
+
"Move": {
|
|
32
|
+
"MoveItems": 0x05, "Move": 0x06, "SrcMsgId": 0x07, "SrcFldId": 0x08,
|
|
33
|
+
"DstFldId": 0x09, "Response": 0x0A, "Status": 0x0B, "DstMsgId": 0x0C,
|
|
34
|
+
},
|
|
35
|
+
"ItemEstimate": {
|
|
36
|
+
"GetItemEstimate": 0x05, "Collections": 0x07, "Collection": 0x08,
|
|
37
|
+
"CollectionId": 0x0A, "Estimate": 0x0C, "Response": 0x0D, "Status": 0x0E,
|
|
38
|
+
},
|
|
39
|
+
"FolderHierarchy": {
|
|
40
|
+
"DisplayName": 0x07, "ServerId": 0x08, "ParentId": 0x09,
|
|
41
|
+
"Type": 0x0A, "Response": 0x0B, "Status": 0x0C, "Changes": 0x0E,
|
|
42
|
+
"Add": 0x0F, "Delete": 0x10, "Update": 0x11, "SyncKey": 0x12,
|
|
43
|
+
"FolderCreate": 0x13, "FolderDelete": 0x14, "FolderUpdate": 0x15,
|
|
44
|
+
"FolderSync": 0x16, "Count": 0x17,
|
|
45
|
+
},
|
|
46
|
+
"Provision": {
|
|
47
|
+
"Provision": 0x05, "Policies": 0x06, "Policy": 0x07,
|
|
48
|
+
"PolicyType": 0x08, "PolicyKey": 0x09, "Data": 0x0A,
|
|
49
|
+
"Status": 0x0B, "RemoteWipe": 0x0C, "EASProvisionDoc": 0x0D,
|
|
50
|
+
},
|
|
51
|
+
"Ping": {
|
|
52
|
+
"Ping": 0x05, "AutdState": 0x06, "Status": 0x07, "HeartbeatInterval": 0x08,
|
|
53
|
+
"Folders": 0x09, "Folder": 0x0A, "Id": 0x0B, "Class": 0x0C, "MaxFolders": 0x0D,
|
|
54
|
+
},
|
|
55
|
+
"AirSyncBase": {
|
|
56
|
+
"BodyPreference": 0x05, "Type": 0x06, "TruncationSize": 0x07, "AllOrNone": 0x08,
|
|
57
|
+
"Body": 0x0A, "Data": 0x0B, "EstimatedDataSize": 0x0C, "Truncated": 0x0D,
|
|
58
|
+
"Attachments": 0x0E, "Attachment": 0x0F, "DisplayName": 0x10, "FileReference": 0x11,
|
|
59
|
+
"Method": 0x12, "ContentId": 0x13, "ContentLocation": 0x14, "IsInline": 0x15,
|
|
60
|
+
"NativeBodyType": 0x16, "ContentType": 0x17, "Preview": 0x18,
|
|
61
|
+
},
|
|
62
|
+
"Settings": {
|
|
63
|
+
"Settings": 0x05, "Status": 0x06, "Get": 0x07, "Set": 0x08,
|
|
64
|
+
"Oof": 0x09, "OofState": 0x0A, "BodyType": 0x13,
|
|
65
|
+
"DeviceInformation": 0x16, "Model": 0x17, "IMEI": 0x18,
|
|
66
|
+
"FriendlyName": 0x19, "OS": 0x1A, "OSLanguage": 0x1B,
|
|
67
|
+
"PhoneNumber": 0x1C, "UserAgent": 0x20,
|
|
68
|
+
},
|
|
69
|
+
"ItemOperations": {
|
|
70
|
+
"ItemOperations": 0x05, "Fetch": 0x06, "Store": 0x07, "Options": 0x08,
|
|
71
|
+
"Range": 0x09, "Total": 0x0A, "Properties": 0x0B, "Data": 0x0C,
|
|
72
|
+
"Status": 0x0D, "Response": 0x0E,
|
|
73
|
+
},
|
|
74
|
+
"ComposeMail": {
|
|
75
|
+
"SendMail": 0x05, "SmartForward": 0x06, "SmartReply": 0x07,
|
|
76
|
+
"SaveInSentItems": 0x08, "ReplaceMime": 0x09, "Source": 0x0B,
|
|
77
|
+
"FolderId": 0x0C, "ItemId": 0x0D, "LongId": 0x0E,
|
|
78
|
+
"InstanceId": 0x0F, "MIME": 0x10, "ClientId": 0x11,
|
|
79
|
+
"Status": 0x12, "AccountId": 0x13,
|
|
80
|
+
},
|
|
81
|
+
# ResolveRecipients/Search/GAL token values are taken verbatim from the
|
|
82
|
+
# official MS-ASWBXML spec, not reverse-engineered.
|
|
83
|
+
"ResolveRecipients": {
|
|
84
|
+
"ResolveRecipients": 0x05, "Response": 0x06, "Status": 0x07, "Type": 0x08,
|
|
85
|
+
"Recipient": 0x09, "DisplayName": 0x0A, "EmailAddress": 0x0B,
|
|
86
|
+
"Certificates": 0x0C, "Certificate": 0x0D, "MiniCertificate": 0x0E,
|
|
87
|
+
"Options": 0x0F, "To": 0x10, "CertificateRetrieval": 0x11,
|
|
88
|
+
"RecipientCount": 0x12, "MaxCertificates": 0x13, "MaxAmbiguousRecipients": 0x14,
|
|
89
|
+
"CertificateCount": 0x15,
|
|
90
|
+
},
|
|
91
|
+
"Search": {
|
|
92
|
+
"Search": 0x05, "Store": 0x07, "Name": 0x08, "Query": 0x09,
|
|
93
|
+
"Options": 0x0A, "Range": 0x0B, "Status": 0x0C, "Response": 0x0D,
|
|
94
|
+
"Result": 0x0E, "Properties": 0x0F, "Total": 0x10,
|
|
95
|
+
"EqualTo": 0x11, "Value": 0x12, "And": 0x13, "Or": 0x14, "FreeText": 0x15,
|
|
96
|
+
"DeepTraversal": 0x17, "LongId": 0x18, "RebuildResults": 0x19,
|
|
97
|
+
# GreaterThan verified byte-for-byte against a captured, working
|
|
98
|
+
# request/response pair (Store.Status=1, Total=96). Note
|
|
99
|
+
# Class/CollectionId scoping inside a Search query uses the
|
|
100
|
+
# *AirSync* codepage's tokens (xmlns="AirSync" in the spec XML),
|
|
101
|
+
# not tokens on this page -- do not add Search.Class/CollectionId.
|
|
102
|
+
"GreaterThan": 0x1B,
|
|
103
|
+
},
|
|
104
|
+
"GAL": {
|
|
105
|
+
"DisplayName": 0x05, "Phone": 0x06, "Office": 0x07, "Title": 0x08,
|
|
106
|
+
"Company": 0x09, "Alias": 0x0A, "FirstName": 0x0B, "LastName": 0x0C,
|
|
107
|
+
"HomePhone": 0x0D, "MobilePhone": 0x0E, "EmailAddress": 0x0F,
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Real MS-ASWBXML codepage numbers (must match the protocol, not an
|
|
112
|
+
# arbitrary local order -- these are the values sent in SWITCH_PAGE).
|
|
113
|
+
PAGE_INDEX: dict[str, int] = {
|
|
114
|
+
"AirSync": 0,
|
|
115
|
+
"Email": 2,
|
|
116
|
+
"Move": 5,
|
|
117
|
+
"ItemEstimate": 6,
|
|
118
|
+
"FolderHierarchy": 7,
|
|
119
|
+
"Ping": 13,
|
|
120
|
+
"Provision": 14,
|
|
121
|
+
"ResolveRecipients": 10,
|
|
122
|
+
"Search": 15,
|
|
123
|
+
"GAL": 16,
|
|
124
|
+
"AirSyncBase": 17,
|
|
125
|
+
"Settings": 18,
|
|
126
|
+
"ItemOperations": 20,
|
|
127
|
+
"ComposeMail": 21,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
PAGE_ORDER: dict[int, str] = {num: name for name, num in PAGE_INDEX.items()}
|
|
131
|
+
|
|
132
|
+
REVERSE: dict[int, dict[int, str]] = {
|
|
133
|
+
PAGE_INDEX[page]: {tok & 0x3F: tag for tag, tok in tags.items()}
|
|
134
|
+
for page, tags in CODEPAGES.items()
|
|
135
|
+
}
|
pyactivesync/_http.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Thin HTTP transport for EAS: one ``requests.Session``, auth + headers + retries."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
from requests.adapters import HTTPAdapter
|
|
6
|
+
from urllib3.util.retry import Retry
|
|
7
|
+
|
|
8
|
+
_RETRYABLE_STATUS = (500, 502, 503, 504)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Transport:
|
|
12
|
+
"""POSTs WBXML bodies to ``/Microsoft-Server-ActiveSync`` and returns raw response bytes."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
server: str,
|
|
17
|
+
username: str,
|
|
18
|
+
password: str,
|
|
19
|
+
*,
|
|
20
|
+
device_id: str,
|
|
21
|
+
device_type: str = "pyactivesync",
|
|
22
|
+
protocol_version: str = "14.1",
|
|
23
|
+
verify_ssl: bool = True,
|
|
24
|
+
timeout: float = 30.0,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.base_url = f"https://{server}/Microsoft-Server-ActiveSync"
|
|
27
|
+
self.device_id = device_id
|
|
28
|
+
self.device_type = device_type
|
|
29
|
+
self.protocol_version = protocol_version
|
|
30
|
+
self.timeout = timeout
|
|
31
|
+
self.policy_key: str | None = None
|
|
32
|
+
|
|
33
|
+
self.session = requests.Session()
|
|
34
|
+
self.session.auth = (username, password)
|
|
35
|
+
self.session.verify = verify_ssl
|
|
36
|
+
retry = Retry(
|
|
37
|
+
total=3,
|
|
38
|
+
backoff_factor=0.5,
|
|
39
|
+
status_forcelist=_RETRYABLE_STATUS,
|
|
40
|
+
allowed_methods=frozenset({"POST"}),
|
|
41
|
+
)
|
|
42
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
43
|
+
self.session.mount("https://", adapter)
|
|
44
|
+
self.session.mount("http://", adapter)
|
|
45
|
+
|
|
46
|
+
def post(
|
|
47
|
+
self,
|
|
48
|
+
cmd: str,
|
|
49
|
+
user: str,
|
|
50
|
+
wbxml: bytes,
|
|
51
|
+
*,
|
|
52
|
+
extra_params: dict[str, str] | None = None,
|
|
53
|
+
timeout: float | None = None,
|
|
54
|
+
) -> bytes:
|
|
55
|
+
params = {
|
|
56
|
+
"Cmd": cmd,
|
|
57
|
+
"User": user,
|
|
58
|
+
"DeviceId": self.device_id,
|
|
59
|
+
"DeviceType": self.device_type,
|
|
60
|
+
}
|
|
61
|
+
if extra_params:
|
|
62
|
+
params.update(extra_params)
|
|
63
|
+
headers = {
|
|
64
|
+
"Content-Type": "application/vnd.ms-sync.wbxml",
|
|
65
|
+
"MS-ASProtocolVersion": self.protocol_version,
|
|
66
|
+
}
|
|
67
|
+
if self.policy_key:
|
|
68
|
+
headers["X-MS-PolicyKey"] = self.policy_key
|
|
69
|
+
resp = self.session.post(
|
|
70
|
+
self.base_url,
|
|
71
|
+
params=params,
|
|
72
|
+
data=wbxml,
|
|
73
|
+
headers=headers,
|
|
74
|
+
timeout=timeout if timeout is not None else self.timeout,
|
|
75
|
+
)
|
|
76
|
+
resp.raise_for_status()
|
|
77
|
+
return resp.content
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
self.session.close()
|
pyactivesync/_mime.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""MIME helpers: turn a stdlib ``email.message`` object into the CRLF bytes
|
|
2
|
+
EAS's ``ComposeMail.MIME`` opaque field requires, and parse MIME bytes back.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from email import message_from_bytes
|
|
7
|
+
from email.message import Message
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_crlf_bytes(message: Message) -> bytes:
|
|
11
|
+
"""Render an ``email.message`` object to CRLF-terminated bytes.
|
|
12
|
+
|
|
13
|
+
EAS requires CRLF line endings in the MIME payload; LF-only bytes (the
|
|
14
|
+
default from ``Message.as_bytes()`` on POSIX) are rejected by the
|
|
15
|
+
server with ``Status=101`` (``InvalidContent``).
|
|
16
|
+
"""
|
|
17
|
+
raw = message.as_bytes()
|
|
18
|
+
return raw.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_mime(data: bytes) -> Message:
|
|
22
|
+
return message_from_bytes(data)
|
pyactivesync/_wbxml.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""MS-ASWBXML binary XML codec: writer, reader, and small tree helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable, Iterable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._codepages import CODEPAGES, PAGE_INDEX, PAGE_ORDER, REVERSE
|
|
8
|
+
|
|
9
|
+
SWITCH_PAGE = 0x00
|
|
10
|
+
END = 0x01
|
|
11
|
+
ENTITY = 0x02
|
|
12
|
+
STR_I = 0x03
|
|
13
|
+
LITERAL = 0x04
|
|
14
|
+
OPAQUE = 0xC3
|
|
15
|
+
|
|
16
|
+
# A parsed WBXML node: (tag_path, text-or-None, children). Leaf text/opaque
|
|
17
|
+
# nodes use the synthetic tag paths "#text" / "#opaque".
|
|
18
|
+
Node = tuple[str, "str | bytes | None", list[Any]]
|
|
19
|
+
NodeBuilder = Callable[["WBXMLWriter"], None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _mb_uint_bytes(n: int) -> bytes:
|
|
23
|
+
out = [n & 0x7F]
|
|
24
|
+
n >>= 7
|
|
25
|
+
while n:
|
|
26
|
+
out.append((n & 0x7F) | 0x80)
|
|
27
|
+
n >>= 7
|
|
28
|
+
return bytes(reversed(out))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WBXMLWriter:
|
|
32
|
+
"""Builds a WBXML document from a nested tree of ``tag()``/``wtag()`` calls."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self.body = bytearray()
|
|
36
|
+
self.current_page = 0
|
|
37
|
+
|
|
38
|
+
def _switch(self, page_name: str) -> None:
|
|
39
|
+
page = PAGE_INDEX[page_name]
|
|
40
|
+
if page != self.current_page:
|
|
41
|
+
self.body.append(SWITCH_PAGE)
|
|
42
|
+
self.body.append(page)
|
|
43
|
+
self.current_page = page
|
|
44
|
+
|
|
45
|
+
def tag(
|
|
46
|
+
self,
|
|
47
|
+
page_name: str,
|
|
48
|
+
name: str,
|
|
49
|
+
children: Iterable[NodeBuilder] | None = None,
|
|
50
|
+
text: str | None = None,
|
|
51
|
+
opaque: bytes | None = None,
|
|
52
|
+
) -> WBXMLWriter:
|
|
53
|
+
self._switch(page_name)
|
|
54
|
+
token = CODEPAGES[page_name][name]
|
|
55
|
+
has_content = bool(children) or text is not None or opaque is not None
|
|
56
|
+
self.body.append(token | (0x40 if has_content else 0x00))
|
|
57
|
+
if text is not None:
|
|
58
|
+
self.body.append(STR_I)
|
|
59
|
+
self.body.extend(text.encode("utf-8"))
|
|
60
|
+
self.body.append(0x00)
|
|
61
|
+
if opaque is not None:
|
|
62
|
+
self.body.append(OPAQUE)
|
|
63
|
+
self.body.extend(_mb_uint_bytes(len(opaque)))
|
|
64
|
+
self.body.extend(opaque)
|
|
65
|
+
if children:
|
|
66
|
+
for child in children:
|
|
67
|
+
child(self)
|
|
68
|
+
if has_content:
|
|
69
|
+
self.body.append(END)
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def render(self) -> bytes:
|
|
73
|
+
header = bytes([0x03, 0x01, 0x6A, 0x00]) # version 1.3, unknown PID, UTF-8, no str table
|
|
74
|
+
return header + bytes(self.body)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def wtag(
|
|
78
|
+
page: str,
|
|
79
|
+
name: str,
|
|
80
|
+
children: Iterable[NodeBuilder] | None = None,
|
|
81
|
+
text: str | None = None,
|
|
82
|
+
opaque: bytes | None = None,
|
|
83
|
+
) -> NodeBuilder:
|
|
84
|
+
"""Return a closure that appends one tag (+ children) into a WBXMLWriter."""
|
|
85
|
+
|
|
86
|
+
def build(writer: WBXMLWriter) -> None:
|
|
87
|
+
writer.tag(page, name, children, text, opaque)
|
|
88
|
+
|
|
89
|
+
return build
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class WBXMLReader:
|
|
93
|
+
"""Parses a WBXML document into a tree of ``Node`` tuples."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, data: bytes) -> None:
|
|
96
|
+
self.data = data
|
|
97
|
+
self.pos = 3 # skip version, publicid, charset
|
|
98
|
+
strtbl_len, self.pos = self._mb_uint()
|
|
99
|
+
self.pos += strtbl_len # skip string table
|
|
100
|
+
self.page = 0
|
|
101
|
+
|
|
102
|
+
def _mb_uint(self, pos: int | None = None) -> tuple[int, int]:
|
|
103
|
+
result = 0
|
|
104
|
+
pos = self.pos if pos is None else pos
|
|
105
|
+
while True:
|
|
106
|
+
b = self.data[pos]
|
|
107
|
+
result = (result << 7) | (b & 0x7F)
|
|
108
|
+
pos += 1
|
|
109
|
+
if not (b & 0x80):
|
|
110
|
+
break
|
|
111
|
+
return result, pos
|
|
112
|
+
|
|
113
|
+
def parse(self) -> list[Node]:
|
|
114
|
+
nodes, self.pos = self._parse_body(self.pos)
|
|
115
|
+
return nodes
|
|
116
|
+
|
|
117
|
+
def _parse_body(self, pos: int) -> tuple[list[Node], int]:
|
|
118
|
+
nodes: list[Node] = []
|
|
119
|
+
while pos < len(self.data):
|
|
120
|
+
b = self.data[pos]
|
|
121
|
+
if b == END:
|
|
122
|
+
return nodes, pos + 1
|
|
123
|
+
if b == SWITCH_PAGE:
|
|
124
|
+
self.page = self.data[pos + 1]
|
|
125
|
+
pos += 2
|
|
126
|
+
continue
|
|
127
|
+
if b == STR_I:
|
|
128
|
+
end = self.data.index(0x00, pos + 1)
|
|
129
|
+
nodes.append(("#text", self.data[pos + 1 : end].decode("utf-8"), []))
|
|
130
|
+
pos = end + 1
|
|
131
|
+
continue
|
|
132
|
+
if b == OPAQUE:
|
|
133
|
+
length, pos = self._mb_uint(pos + 1)
|
|
134
|
+
nodes.append(("#opaque", self.data[pos : pos + length], []))
|
|
135
|
+
pos += length
|
|
136
|
+
continue
|
|
137
|
+
has_content = bool(b & 0x40)
|
|
138
|
+
token = b & 0x3F
|
|
139
|
+
page_name = PAGE_ORDER.get(self.page, f"page{self.page}")
|
|
140
|
+
tag_name = REVERSE.get(self.page, {}).get(token, f"0x{token:02X}")
|
|
141
|
+
pos += 1
|
|
142
|
+
children: list[Node] = []
|
|
143
|
+
if has_content:
|
|
144
|
+
children, pos = self._parse_body(pos)
|
|
145
|
+
nodes.append((f"{page_name}.{tag_name}", None, children))
|
|
146
|
+
return nodes, pos
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def find(nodes: list[Node], path: str) -> Node | None:
|
|
150
|
+
"""Find the first descendant node by dotted tag path, e.g. ``"FolderHierarchy.FolderSync"``."""
|
|
151
|
+
for name, text, children in nodes:
|
|
152
|
+
if name == path:
|
|
153
|
+
return name, text, children
|
|
154
|
+
found = find(children, path)
|
|
155
|
+
if found:
|
|
156
|
+
return found
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def find_all(nodes: list[Node], path: str) -> list[Node]:
|
|
161
|
+
out: list[Node] = []
|
|
162
|
+
for name, text, children in nodes:
|
|
163
|
+
if name == path:
|
|
164
|
+
out.append((name, text, children))
|
|
165
|
+
out.extend(find_all(children, path))
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def text_of(node: Node | None) -> str | None:
|
|
170
|
+
if not node:
|
|
171
|
+
return None
|
|
172
|
+
_, _, children = node
|
|
173
|
+
for name, text, _ in children:
|
|
174
|
+
if name == "#text":
|
|
175
|
+
assert text is None or isinstance(text, str)
|
|
176
|
+
return text
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def opaque_of(node: Node | None) -> bytes | None:
|
|
181
|
+
if not node:
|
|
182
|
+
return None
|
|
183
|
+
_, _, children = node
|
|
184
|
+
for name, data, _ in children:
|
|
185
|
+
if name == "#opaque":
|
|
186
|
+
assert isinstance(data, (bytes, bytearray))
|
|
187
|
+
return bytes(data)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def leaves(nodes: list[Node]) -> dict[str, str]:
|
|
192
|
+
"""Flatten a subtree into ``{tag_name: text}`` for every leaf with text content."""
|
|
193
|
+
out: dict[str, str] = {}
|
|
194
|
+
for name, _, children in nodes:
|
|
195
|
+
text = next((t for n, t, _ in children if n == "#text"), None)
|
|
196
|
+
if text is not None:
|
|
197
|
+
out[name] = text
|
|
198
|
+
else:
|
|
199
|
+
out.update(leaves(children))
|
|
200
|
+
return out
|
pyactivesync/cli.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""``pyactivesync`` console script: list-folders, sync, send, fetch, ping."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from email.message import EmailMessage
|
|
10
|
+
|
|
11
|
+
from .client import Client
|
|
12
|
+
from .exceptions import EASError
|
|
13
|
+
from .models import BodyType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _client_from_args(args: argparse.Namespace) -> Client:
|
|
17
|
+
password = args.password or os.environ.get("PYACTIVESYNC_PASSWORD")
|
|
18
|
+
if not password:
|
|
19
|
+
raise SystemExit("no password: pass --password or set PYACTIVESYNC_PASSWORD")
|
|
20
|
+
return Client(
|
|
21
|
+
args.server,
|
|
22
|
+
args.username,
|
|
23
|
+
password,
|
|
24
|
+
device_id=args.device_id,
|
|
25
|
+
user=args.user,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _to_jsonable(obj: object) -> object:
|
|
30
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
31
|
+
return dataclasses.asdict(obj)
|
|
32
|
+
if isinstance(obj, list):
|
|
33
|
+
return [_to_jsonable(o) for o in obj]
|
|
34
|
+
return obj
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _print(obj: object, as_json: bool) -> None:
|
|
38
|
+
if as_json:
|
|
39
|
+
print(json.dumps(_to_jsonable(obj), default=str, indent=2))
|
|
40
|
+
else:
|
|
41
|
+
print(obj)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _add_common_args(p: argparse.ArgumentParser) -> None:
|
|
45
|
+
p.add_argument("--server", required=True, help="EAS server hostname")
|
|
46
|
+
p.add_argument("--username", required=True, help="auth username (domain\\user or email)")
|
|
47
|
+
p.add_argument("--user", help="mailbox SMTP address for the User param (defaults to --username)")
|
|
48
|
+
p.add_argument("--password", help="password (falls back to PYACTIVESYNC_PASSWORD env var)")
|
|
49
|
+
p.add_argument("--device-id", default="pyactivesync-cli", help="device id (default: pyactivesync-cli)")
|
|
50
|
+
p.add_argument("--json", action="store_true", help="output JSON instead of a table")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_list_folders(args: argparse.Namespace) -> None:
|
|
54
|
+
with _client_from_args(args) as client:
|
|
55
|
+
folders = client.list_folders()
|
|
56
|
+
if args.json:
|
|
57
|
+
_print(folders, True)
|
|
58
|
+
return
|
|
59
|
+
for f in folders:
|
|
60
|
+
print(f"{f.id}\t{f.type.name}\t{f.parent_id}\t{f.name}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_sync(args: argparse.Namespace) -> None:
|
|
64
|
+
with _client_from_args(args) as client:
|
|
65
|
+
result = client.sync_folder(args.folder_id, sync_key=args.sync_key, window_size=args.window_size)
|
|
66
|
+
if args.json:
|
|
67
|
+
_print(result, True)
|
|
68
|
+
return
|
|
69
|
+
print(f"SyncKey={result.sync_key} more_available={result.more_available}")
|
|
70
|
+
for item in result.added:
|
|
71
|
+
print(f" + {item.server_id}\t{item.fields}")
|
|
72
|
+
for item in result.changed:
|
|
73
|
+
print(f" ~ {item.server_id}\t{item.fields}")
|
|
74
|
+
for server_id in result.deleted:
|
|
75
|
+
print(f" - {server_id}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_fetch(args: argparse.Namespace) -> None:
|
|
79
|
+
with _client_from_args(args) as client:
|
|
80
|
+
body_type = BodyType[args.body_type.upper()]
|
|
81
|
+
props = client.fetch_item(args.folder_id, args.item_id, body_type=body_type)
|
|
82
|
+
_print(props, args.json)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_send(args: argparse.Namespace) -> None:
|
|
86
|
+
msg = EmailMessage()
|
|
87
|
+
msg["To"] = args.to
|
|
88
|
+
msg["From"] = args.user or args.username
|
|
89
|
+
msg["Subject"] = args.subject
|
|
90
|
+
msg.set_content(args.body)
|
|
91
|
+
with _client_from_args(args) as client:
|
|
92
|
+
client.send_mail(msg)
|
|
93
|
+
if not args.json:
|
|
94
|
+
print("sent")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_ping(args: argparse.Namespace) -> None:
|
|
98
|
+
with _client_from_args(args) as client:
|
|
99
|
+
result = client.ping(args.folder_id, heartbeat=args.heartbeat, timeout=args.timeout)
|
|
100
|
+
_print(result, args.json)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
104
|
+
parser = argparse.ArgumentParser(prog="pyactivesync", description="Exchange ActiveSync client CLI")
|
|
105
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
106
|
+
|
|
107
|
+
p = sub.add_parser("list-folders", help="list the folder hierarchy")
|
|
108
|
+
_add_common_args(p)
|
|
109
|
+
p.set_defaults(func=cmd_list_folders)
|
|
110
|
+
|
|
111
|
+
p = sub.add_parser("sync", help="sync one folder")
|
|
112
|
+
_add_common_args(p)
|
|
113
|
+
p.add_argument("folder_id")
|
|
114
|
+
p.add_argument("--sync-key", default="0")
|
|
115
|
+
p.add_argument("--window-size", type=int, default=25)
|
|
116
|
+
p.set_defaults(func=cmd_sync)
|
|
117
|
+
|
|
118
|
+
p = sub.add_parser("fetch", help="fetch one item's properties")
|
|
119
|
+
_add_common_args(p)
|
|
120
|
+
p.add_argument("folder_id")
|
|
121
|
+
p.add_argument("item_id")
|
|
122
|
+
p.add_argument("--body-type", default="html", choices=["plain_text", "html", "rtf", "mime"])
|
|
123
|
+
p.set_defaults(func=cmd_fetch)
|
|
124
|
+
|
|
125
|
+
p = sub.add_parser("send", help="send a plain-text message")
|
|
126
|
+
_add_common_args(p)
|
|
127
|
+
p.add_argument("--to", required=True)
|
|
128
|
+
p.add_argument("--subject", required=True)
|
|
129
|
+
p.add_argument("--body", required=True)
|
|
130
|
+
p.set_defaults(func=cmd_send)
|
|
131
|
+
|
|
132
|
+
p = sub.add_parser("ping", help="long-poll one folder for changes")
|
|
133
|
+
_add_common_args(p)
|
|
134
|
+
p.add_argument("folder_id")
|
|
135
|
+
p.add_argument("--heartbeat", type=int, default=60)
|
|
136
|
+
p.add_argument("--timeout", type=float, default=30.0)
|
|
137
|
+
p.set_defaults(func=cmd_ping)
|
|
138
|
+
|
|
139
|
+
return parser
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main(argv: list[str] | None = None) -> int:
|
|
143
|
+
parser = build_parser()
|
|
144
|
+
args = parser.parse_args(argv)
|
|
145
|
+
try:
|
|
146
|
+
args.func(args)
|
|
147
|
+
except EASError as exc:
|
|
148
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
sys.exit(main())
|