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.
@@ -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())