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.
- zimbra_client/__init__.py +92 -0
- zimbra_client/account.py +225 -0
- zimbra_client/calendar.py +339 -0
- zimbra_client/client.py +824 -0
- zimbra_client/config.py +119 -0
- zimbra_client/contacts.py +120 -0
- zimbra_client/errors.py +64 -0
- zimbra_client/filters.py +191 -0
- zimbra_client/mail.py +499 -0
- zimbra_client/models.py +223 -0
- zimbra_client-0.3.2.dist-info/METADATA +167 -0
- zimbra_client-0.3.2.dist-info/RECORD +15 -0
- zimbra_client-0.3.2.dist-info/WHEEL +5 -0
- zimbra_client-0.3.2.dist-info/licenses/LICENSE +21 -0
- zimbra_client-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
zimbra_client/account.py
ADDED
|
@@ -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
|