zimbra-client 0.3.2__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,92 @@
1
+ """End-user Zimbra SOAP client."""
2
+
3
+ from .client import (
4
+ ACCOUNT_NAMESPACE,
5
+ MAIL_NAMESPACE,
6
+ SOAP_NAMESPACE,
7
+ ZIMBRA_NAMESPACE,
8
+ ZimbraClient,
9
+ build_soap_envelope,
10
+ local_name,
11
+ )
12
+ from .config import ZimbraConfig, normalize_base_url
13
+ from .errors import (
14
+ ZimbraAuthenticationError,
15
+ ZimbraConfigError,
16
+ ZimbraConnectionError,
17
+ ZimbraError,
18
+ ZimbraHTTPError,
19
+ ZimbraLimitError,
20
+ ZimbraNotFoundError,
21
+ ZimbraProtocolError,
22
+ ZimbraSOAPFault,
23
+ )
24
+ from .filters import (
25
+ filter_discard,
26
+ filter_file_into,
27
+ filter_from_address,
28
+ filter_keep,
29
+ filter_stop,
30
+ filter_subject_contains,
31
+ filter_tag,
32
+ )
33
+ from .models import (
34
+ AppointmentSummary,
35
+ Attachment,
36
+ Contact,
37
+ FilterAction,
38
+ FilterRule,
39
+ FilterTest,
40
+ Folder,
41
+ Identity,
42
+ Message,
43
+ Preference,
44
+ Recipient,
45
+ SearchResult,
46
+ SendResult,
47
+ Signature,
48
+ Task,
49
+ )
50
+
51
+ __all__ = [
52
+ "ACCOUNT_NAMESPACE",
53
+ "AppointmentSummary",
54
+ "Attachment",
55
+ "Contact",
56
+ "FilterAction",
57
+ "FilterRule",
58
+ "FilterTest",
59
+ "Folder",
60
+ "Identity",
61
+ "MAIL_NAMESPACE",
62
+ "Message",
63
+ "Preference",
64
+ "Recipient",
65
+ "SOAP_NAMESPACE",
66
+ "SearchResult",
67
+ "SendResult",
68
+ "Signature",
69
+ "Task",
70
+ "ZIMBRA_NAMESPACE",
71
+ "ZimbraAuthenticationError",
72
+ "ZimbraClient",
73
+ "ZimbraConfig",
74
+ "ZimbraConfigError",
75
+ "ZimbraConnectionError",
76
+ "ZimbraError",
77
+ "ZimbraHTTPError",
78
+ "ZimbraLimitError",
79
+ "ZimbraNotFoundError",
80
+ "ZimbraProtocolError",
81
+ "ZimbraSOAPFault",
82
+ "build_soap_envelope",
83
+ "filter_discard",
84
+ "filter_file_into",
85
+ "filter_from_address",
86
+ "filter_keep",
87
+ "filter_stop",
88
+ "filter_subject_contains",
89
+ "filter_tag",
90
+ "local_name",
91
+ "normalize_base_url",
92
+ ]
@@ -0,0 +1,225 @@
1
+ """SOAP request builders and response parsers for Zimbra account operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import xml.etree.ElementTree as ET
6
+ from typing import Mapping, Optional, Sequence, Tuple
7
+
8
+ from .errors import ZimbraNotFoundError
9
+ from .models import Identity, Preference, Signature
10
+
11
+ ACCOUNT_NAMESPACE = "urn:zimbraAccount"
12
+
13
+
14
+ def local_name(tag: str) -> str:
15
+ return tag.rsplit("}", 1)[-1] if "}" in tag else tag
16
+
17
+
18
+ def _qname(namespace: str, name: str) -> str:
19
+ return f"{{{namespace}}}{name}"
20
+
21
+
22
+ def _text(value: object) -> str:
23
+ return str(value or "").strip()
24
+
25
+
26
+ def _attr(element: ET.Element, name: str, default: str = "") -> str:
27
+ return _text(element.get(name, default))
28
+
29
+
30
+ def _parse_signature(element: ET.Element) -> Signature:
31
+ text_plain = ""
32
+ text_html = ""
33
+ for child in element:
34
+ if local_name(child.tag) != "content":
35
+ continue
36
+ content_type = _attr(child, "type")
37
+ if content_type == "text/html":
38
+ text_html = _text(child.text)
39
+ else:
40
+ text_plain = _text(child.text)
41
+ return Signature(
42
+ id=_attr(element, "id"),
43
+ name=_attr(element, "name"),
44
+ text_plain=text_plain,
45
+ text_html=text_html,
46
+ contact_id=_attr(element, "cid"),
47
+ )
48
+
49
+
50
+ def _parse_identity(element: ET.Element) -> Identity:
51
+ attrs = {}
52
+ for child in element:
53
+ if local_name(child.tag) == "a":
54
+ name = _attr(child, "name")
55
+ if name:
56
+ attrs[name] = _text(child.text)
57
+ return Identity(
58
+ id=_attr(element, "id"),
59
+ name=_attr(element, "name"),
60
+ attrs=attrs,
61
+ )
62
+
63
+
64
+ def _parse_pref(element: ET.Element) -> Preference:
65
+ modified = _attr(element, "modified")
66
+ return Preference(
67
+ name=_attr(element, "name"),
68
+ value=_text(element.text),
69
+ modified=int(modified) if modified.isdigit() else None,
70
+ )
71
+
72
+
73
+ def parse_signatures_response(root: ET.Element) -> Tuple[Signature, ...]:
74
+ return tuple(
75
+ _parse_signature(elem)
76
+ for elem in root.iter()
77
+ if local_name(elem.tag) == "signature" and _attr(elem, "id")
78
+ )
79
+
80
+
81
+ def parse_signature_response(root: ET.Element, *, signature_id: str = "") -> Signature:
82
+ signatures = parse_signatures_response(root)
83
+ if signatures:
84
+ return signatures[0]
85
+ raise ZimbraNotFoundError(
86
+ "Signature not found in response",
87
+ resource_type="signature",
88
+ resource_id=signature_id,
89
+ )
90
+
91
+
92
+ def parse_identities_response(root: ET.Element) -> Tuple[Identity, ...]:
93
+ return tuple(
94
+ _parse_identity(elem)
95
+ for elem in root.iter()
96
+ if local_name(elem.tag) == "identity" and _attr(elem, "id")
97
+ )
98
+
99
+
100
+ def parse_identity_response(root: ET.Element, *, identity_id: str = "") -> Identity:
101
+ identities = parse_identities_response(root)
102
+ if identities:
103
+ return identities[0]
104
+ raise ZimbraNotFoundError(
105
+ "Identity not found in response",
106
+ resource_type="identity",
107
+ resource_id=identity_id,
108
+ )
109
+
110
+
111
+ def parse_prefs_response(root: ET.Element) -> Tuple[Preference, ...]:
112
+ return tuple(
113
+ _parse_pref(elem)
114
+ for elem in root.iter()
115
+ if local_name(elem.tag) == "pref" and _attr(elem, "name")
116
+ )
117
+
118
+
119
+ def build_get_signatures_request() -> ET.Element:
120
+ return ET.Element(_qname(ACCOUNT_NAMESPACE, "GetSignaturesRequest"))
121
+
122
+
123
+ def build_create_signature_request(
124
+ name: str,
125
+ *,
126
+ text: str = "",
127
+ html: str = "",
128
+ contact_id: str = "",
129
+ ) -> ET.Element:
130
+ request = ET.Element(_qname(ACCOUNT_NAMESPACE, "CreateSignatureRequest"))
131
+ signature = ET.SubElement(request, "signature")
132
+ signature.set("name", name)
133
+ if contact_id:
134
+ cid = ET.SubElement(signature, "cid")
135
+ cid.text = contact_id
136
+ if text:
137
+ content = ET.SubElement(signature, "content")
138
+ content.set("type", "text/plain")
139
+ content.text = text
140
+ if html:
141
+ content = ET.SubElement(signature, "content")
142
+ content.set("type", "text/html")
143
+ content.text = html
144
+ return request
145
+
146
+
147
+ def build_modify_signature_request(
148
+ signature_id: str,
149
+ *,
150
+ name: Optional[str] = None,
151
+ text: Optional[str] = None,
152
+ html: Optional[str] = None,
153
+ ) -> ET.Element:
154
+ request = ET.Element(_qname(ACCOUNT_NAMESPACE, "ModifySignatureRequest"))
155
+ signature = ET.SubElement(request, "signature")
156
+ signature.set("id", signature_id)
157
+ if name is not None:
158
+ signature.set("name", name)
159
+ if text is not None:
160
+ content = ET.SubElement(signature, "content")
161
+ content.set("type", "text/plain")
162
+ content.text = text
163
+ if html is not None:
164
+ content = ET.SubElement(signature, "content")
165
+ content.set("type", "text/html")
166
+ content.text = html
167
+ return request
168
+
169
+
170
+ def build_delete_signature_request(signature_id: str) -> ET.Element:
171
+ request = ET.Element(_qname(ACCOUNT_NAMESPACE, "DeleteSignatureRequest"))
172
+ signature = ET.SubElement(request, "signature")
173
+ signature.set("id", signature_id)
174
+ return request
175
+
176
+
177
+ def build_get_identities_request() -> ET.Element:
178
+ return ET.Element(_qname(ACCOUNT_NAMESPACE, "GetIdentitiesRequest"))
179
+
180
+
181
+ def _append_identity_attrs(parent: ET.Element, attrs: Mapping[str, str]) -> None:
182
+ for name, value in attrs.items():
183
+ element = ET.SubElement(parent, "a")
184
+ element.set("name", name)
185
+ element.text = str(value)
186
+
187
+
188
+ def build_create_identity_request(name: str, attrs: Mapping[str, str]) -> ET.Element:
189
+ request = ET.Element(_qname(ACCOUNT_NAMESPACE, "CreateIdentityRequest"))
190
+ identity = ET.SubElement(request, "identity")
191
+ identity.set("name", name)
192
+ _append_identity_attrs(identity, attrs)
193
+ return request
194
+
195
+
196
+ def build_modify_identity_request(identity_id: str, attrs: Mapping[str, str]) -> ET.Element:
197
+ request = ET.Element(_qname(ACCOUNT_NAMESPACE, "ModifyIdentityRequest"))
198
+ identity = ET.SubElement(request, "identity")
199
+ identity.set("id", identity_id)
200
+ _append_identity_attrs(identity, attrs)
201
+ return request
202
+
203
+
204
+ def build_delete_identity_request(identity_id: str) -> ET.Element:
205
+ request = ET.Element(_qname(ACCOUNT_NAMESPACE, "DeleteIdentityRequest"))
206
+ identity = ET.SubElement(request, "identity")
207
+ identity.set("id", identity_id)
208
+ return request
209
+
210
+
211
+ def build_get_prefs_request(names: Sequence[str] = ()) -> ET.Element:
212
+ request = ET.Element(_qname(ACCOUNT_NAMESPACE, "GetPrefsRequest"))
213
+ for name in names:
214
+ pref = ET.SubElement(request, "pref")
215
+ pref.set("name", name)
216
+ return request
217
+
218
+
219
+ def build_modify_prefs_request(values: Mapping[str, str]) -> ET.Element:
220
+ request = ET.Element(_qname(ACCOUNT_NAMESPACE, "ModifyPrefsRequest"))
221
+ for name, value in values.items():
222
+ pref = ET.SubElement(request, "pref")
223
+ pref.set("name", name)
224
+ pref.text = str(value)
225
+ return request
@@ -0,0 +1,339 @@
1
+ """SOAP request builders and response parsers for Zimbra calendar and tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import xml.etree.ElementTree as ET
6
+ from datetime import datetime
7
+ from typing import Mapping, Optional, Sequence, Tuple, Union
8
+
9
+ from .errors import ZimbraNotFoundError
10
+ from .mail import _append_body_parts
11
+ from .models import AppointmentSummary, Task, datetime_to_epoch_ms, epoch_ms_to_datetime
12
+
13
+ MAIL_NAMESPACE = "urn:zimbraMail"
14
+
15
+
16
+ def local_name(tag: str) -> str:
17
+ return tag.rsplit("}", 1)[-1] if "}" in tag else tag
18
+
19
+
20
+ def _qname(namespace: str, name: str) -> str:
21
+ return f"{{{namespace}}}{name}"
22
+
23
+
24
+ def _text(value: object) -> str:
25
+ return str(value or "").strip()
26
+
27
+
28
+ def _attr(element: ET.Element, name: str, default: str = "") -> str:
29
+ return _text(element.get(name, default))
30
+
31
+
32
+ def _instance_times(element: ET.Element) -> Tuple[Optional[datetime], Optional[datetime]]:
33
+ start_ms = _attr(element, "s")
34
+ duration_ms = _attr(element, "d") or _attr(element, "dur")
35
+ start = epoch_ms_to_datetime(start_ms) if start_ms else None
36
+ end = None
37
+ if start and duration_ms.isdigit():
38
+ end = epoch_ms_to_datetime(int(start_ms) + int(duration_ms))
39
+ end_attr = _attr(element, "e")
40
+ if end_attr:
41
+ end = epoch_ms_to_datetime(end_attr)
42
+ return start, end
43
+
44
+
45
+ def _parse_appointment(element: ET.Element) -> AppointmentSummary:
46
+ start, end = _instance_times(element)
47
+ inst = next((item for item in element if local_name(item.tag) == "inst"), None)
48
+ if inst is not None:
49
+ inst_start, inst_end = _instance_times(inst)
50
+ start = inst_start or start
51
+ end = inst_end or end
52
+ return AppointmentSummary(
53
+ id=_attr(element, "id") or _attr(element, "invId"),
54
+ uid=_attr(element, "uid"),
55
+ name=_attr(element, "name"),
56
+ location=_attr(element, "loc"),
57
+ start=start,
58
+ end=end,
59
+ folder_id=_attr(element, "l"),
60
+ all_day=_attr(element, "allDay") in {"1", "true"},
61
+ status=_attr(element, "status"),
62
+ )
63
+
64
+
65
+ def _parse_task_from_inv(comp: ET.Element, task_id: str = "") -> Task:
66
+ body_text = ""
67
+ for child in comp.iter():
68
+ if local_name(child.tag) == "mp" and _attr(child, "body") == "1":
69
+ content = next((item for item in child if local_name(item.tag) == "content"), None)
70
+ if content is not None and _text(content.text):
71
+ body_text = _text(content.text)
72
+ break
73
+ due = epoch_ms_to_datetime(_attr(comp, "due"))
74
+ status = _attr(comp, "status") or _attr(comp, "ptst")
75
+ return Task(
76
+ id=task_id or _attr(comp, "calItemId"),
77
+ subject=_attr(comp, "name"),
78
+ status=status,
79
+ due=due,
80
+ location=_attr(comp, "loc"),
81
+ body_text=body_text,
82
+ completed=status in {"COMP", "CO"},
83
+ )
84
+
85
+
86
+ def parse_appointments_response(root: ET.Element) -> Tuple[AppointmentSummary, ...]:
87
+ return tuple(
88
+ _parse_appointment(elem)
89
+ for elem in root.iter()
90
+ if local_name(elem.tag) == "appt" and (_attr(elem, "id") or _attr(elem, "invId"))
91
+ )
92
+
93
+
94
+ def parse_appointment_response(root: ET.Element, appointment_id: str = "") -> AppointmentSummary:
95
+ appointments = parse_appointments_response(root)
96
+ if appointment_id:
97
+ for appointment in appointments:
98
+ if appointment.id == appointment_id:
99
+ return appointment
100
+ if not appointments:
101
+ message = next((elem for elem in root.iter() if local_name(elem.tag) == "m"), None)
102
+ if message is not None:
103
+ appt = _parse_appointment(message)
104
+ if appt.id:
105
+ return appt
106
+ if appointments:
107
+ return appointments[0]
108
+ raise ZimbraNotFoundError(
109
+ "Appointment not found in response",
110
+ resource_type="appointment",
111
+ resource_id=appointment_id,
112
+ )
113
+
114
+
115
+ def parse_task_response(root: ET.Element, task_id: str = "") -> Task:
116
+ for message in root.iter():
117
+ if local_name(message.tag) != "m":
118
+ continue
119
+ message_id = _attr(message, "id") or task_id
120
+ for inv in message.iter():
121
+ if local_name(inv.tag) != "inv":
122
+ continue
123
+ comp = next((item for item in inv if local_name(item.tag) == "comp"), None)
124
+ if comp is not None:
125
+ return _parse_task_from_inv(comp, message_id)
126
+ for elem in root.iter():
127
+ if local_name(elem.tag) in {"c", "appt", "task"}:
128
+ task = Task(
129
+ id=_attr(elem, "id"),
130
+ subject=_attr(elem, "name") or _attr(elem, "su"),
131
+ status=_attr(elem, "status") or _attr(elem, "ptst"),
132
+ due=epoch_ms_to_datetime(_attr(elem, "due") or _attr(elem, "d")),
133
+ location=_attr(elem, "loc"),
134
+ completed=_attr(elem, "status") in {"COMP", "CO"},
135
+ )
136
+ if task.id:
137
+ return task
138
+ raise ZimbraNotFoundError(
139
+ "Task not found in response",
140
+ resource_type="task",
141
+ resource_id=task_id,
142
+ )
143
+
144
+
145
+ def parse_tasks_search_response(root: ET.Element) -> Tuple[Task, ...]:
146
+ tasks = []
147
+ for elem in root.iter():
148
+ if local_name(elem.tag) not in {"c", "appt", "task"}:
149
+ continue
150
+ task_id = _attr(elem, "id")
151
+ if not task_id:
152
+ continue
153
+ tasks.append(
154
+ Task(
155
+ id=task_id,
156
+ subject=_attr(elem, "name") or _attr(elem, "su"),
157
+ status=_attr(elem, "status") or _attr(elem, "ptst"),
158
+ due=epoch_ms_to_datetime(_attr(elem, "due") or _attr(elem, "d")),
159
+ location=_attr(elem, "loc"),
160
+ completed=_attr(elem, "status") in {"COMP", "CO"},
161
+ )
162
+ )
163
+ return tuple(tasks)
164
+
165
+
166
+ def _build_invite_component(
167
+ *,
168
+ subject: str,
169
+ start: datetime,
170
+ end: datetime,
171
+ location: str = "",
172
+ attendees: Sequence[str] = (),
173
+ due: Optional[datetime] = None,
174
+ status: str = "CONF",
175
+ percent_complete: Optional[str] = None,
176
+ ) -> ET.Element:
177
+ comp = ET.Element("comp")
178
+ comp.set("name", subject)
179
+ comp.set("status", status)
180
+ if location:
181
+ comp.set("loc", location)
182
+ if due is not None:
183
+ comp.set("due", datetime_to_epoch_ms(due))
184
+ else:
185
+ comp.set("s", datetime_to_epoch_ms(start))
186
+ comp.set("e", datetime_to_epoch_ms(end))
187
+ if percent_complete is not None:
188
+ comp.set("percentComplete", percent_complete)
189
+ for attendee in attendees:
190
+ element = ET.SubElement(comp, "at")
191
+ element.set("a", attendee)
192
+ return comp
193
+
194
+
195
+ def build_create_appointment_request(
196
+ subject: str,
197
+ start: datetime,
198
+ end: datetime,
199
+ *,
200
+ location: str = "",
201
+ attendees: Optional[Sequence[str]] = None,
202
+ text: str = "",
203
+ html: str = "",
204
+ folder_id: Optional[str] = None,
205
+ ) -> ET.Element:
206
+ request = ET.Element(_qname(MAIL_NAMESPACE, "CreateAppointmentRequest"))
207
+ message = ET.SubElement(request, "m")
208
+ if folder_id:
209
+ message.set("l", folder_id)
210
+ subject_element = ET.SubElement(message, "su")
211
+ subject_element.text = subject
212
+ invite = ET.SubElement(message, "inv")
213
+ comp = _build_invite_component(
214
+ subject=subject,
215
+ start=start,
216
+ end=end,
217
+ location=location,
218
+ attendees=(),
219
+ status="CONF",
220
+ )
221
+ invite.append(comp)
222
+ for attendee in attendees or ():
223
+ email = attendee.email if hasattr(attendee, "email") else str(attendee)
224
+ element = ET.SubElement(comp, "at")
225
+ element.set("a", email)
226
+ _append_body_parts(message, text=text, html=html)
227
+ return request
228
+
229
+
230
+ def build_get_appt_summaries_request(
231
+ start: datetime,
232
+ end: datetime,
233
+ *,
234
+ folder_id: Optional[str] = None,
235
+ ) -> ET.Element:
236
+ request = ET.Element(_qname(MAIL_NAMESPACE, "GetApptSummariesRequest"))
237
+ request.set("s", datetime_to_epoch_ms(start))
238
+ request.set("e", datetime_to_epoch_ms(end))
239
+ if folder_id:
240
+ request.set("l", folder_id)
241
+ return request
242
+
243
+
244
+ def build_get_appointment_request(appointment_id: str) -> ET.Element:
245
+ request = ET.Element(_qname(MAIL_NAMESPACE, "GetAppointmentRequest"))
246
+ appointment = ET.SubElement(request, "m")
247
+ appointment.set("id", appointment_id)
248
+ return request
249
+
250
+
251
+ def build_cancel_appointment_request(appointment_id: str, *, comp: int = 0) -> ET.Element:
252
+ request = ET.Element(_qname(MAIL_NAMESPACE, "CancelAppointmentRequest"))
253
+ component = ET.SubElement(request, "comp")
254
+ component.set("id", appointment_id)
255
+ component.set("comp", str(comp))
256
+ return request
257
+
258
+
259
+ def build_create_task_request(
260
+ subject: str,
261
+ *,
262
+ due: Optional[datetime] = None,
263
+ location: str = "",
264
+ text: str = "",
265
+ ) -> ET.Element:
266
+ request = ET.Element(_qname(MAIL_NAMESPACE, "CreateTaskRequest"))
267
+ message = ET.SubElement(request, "m")
268
+ subject_element = ET.SubElement(message, "su")
269
+ subject_element.text = subject
270
+ invite = ET.SubElement(message, "inv")
271
+ start = due or datetime.now().astimezone()
272
+ invite.append(
273
+ _build_invite_component(
274
+ subject=subject,
275
+ start=start,
276
+ end=start,
277
+ location=location,
278
+ due=due,
279
+ status="NEED",
280
+ )
281
+ )
282
+ if text:
283
+ _append_body_parts(message, text=text, html="")
284
+ return request
285
+
286
+
287
+ def build_get_task_request(task_id: str) -> ET.Element:
288
+ request = ET.Element(_qname(MAIL_NAMESPACE, "GetTaskRequest"))
289
+ task = ET.SubElement(request, "task")
290
+ task.set("id", task_id)
291
+ return request
292
+
293
+
294
+ def build_modify_task_request(
295
+ task_id: str,
296
+ *,
297
+ subject: Optional[str] = None,
298
+ due: Optional[datetime] = None,
299
+ status: Optional[str] = None,
300
+ text: Optional[str] = None,
301
+ percent_complete: Optional[str] = None,
302
+ ) -> ET.Element:
303
+ request = ET.Element(_qname(MAIL_NAMESPACE, "ModifyTaskRequest"))
304
+ message = ET.SubElement(request, "m")
305
+ message.set("id", task_id)
306
+ if subject is not None:
307
+ subject_element = ET.SubElement(message, "su")
308
+ subject_element.text = subject
309
+ invite = ET.SubElement(message, "inv")
310
+ comp = ET.SubElement(invite, "comp")
311
+ if subject is not None:
312
+ comp.set("name", subject)
313
+ if due is not None:
314
+ comp.set("due", datetime_to_epoch_ms(due))
315
+ if status is not None:
316
+ comp.set("status", status)
317
+ if percent_complete is not None:
318
+ comp.set("percentComplete", percent_complete)
319
+ if text is not None:
320
+ _append_body_parts(message, text=text, html="")
321
+ return request
322
+
323
+
324
+ def build_search_tasks_request(
325
+ *,
326
+ folder_id: Optional[str] = None,
327
+ limit: int = 50,
328
+ offset: int = 0,
329
+ ) -> ET.Element:
330
+ request = ET.Element(_qname(MAIL_NAMESPACE, "SearchRequest"))
331
+ request.set("types", "task")
332
+ request.set("limit", str(limit))
333
+ request.set("offset", str(offset))
334
+ query = "in:tasks"
335
+ if folder_id:
336
+ query = f"inid:{folder_id}"
337
+ query_element = ET.SubElement(request, "query")
338
+ query_element.text = query
339
+ return request