zimbra-client 0.3.2__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ben Chan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: zimbra-client
3
+ Version: 0.3.2
4
+ Summary: End-user Zimbra SOAP client for mail, contacts, calendar, and account settings
5
+ Author-email: Ben Chan <kpchanaf@connect.ust.hk>
6
+ License-Expression: MIT
7
+ Keywords: zimbra,email,soap,mailbox,calendar,contacts
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Communications :: Email
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # zimbra-client
25
+
26
+ Python client for the Zimbra end-user SOAP API. Connect with an email account and work with mail, folders, drafts, contacts, calendar, tasks, signatures, preferences, and filter rules through typed methods.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ python -m pip install zimbra-client
32
+ ```
33
+
34
+ Requires Python 3.9+. The package uses the standard library only.
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ from zimbra_client import ZimbraClient, ZimbraConfig
40
+
41
+ config = ZimbraConfig(
42
+ host="https://mail.example.com",
43
+ email="user@example.com",
44
+ password="your-password",
45
+ )
46
+
47
+ with ZimbraClient(config) as client:
48
+ inbox = client.search_messages(folder_id="2", limit=10)
49
+ for summary in inbox.messages:
50
+ message = client.get_message(summary.id)
51
+ print(message.subject, message.from_.email)
52
+ ```
53
+
54
+ You can also pass a mapping or keyword-style dict:
55
+
56
+ ```python
57
+ client = ZimbraClient(
58
+ {
59
+ "host": "mail.example.com",
60
+ "email": "user@example.com",
61
+ "password": "your-password",
62
+ "verify_ssl": True,
63
+ }
64
+ )
65
+ ```
66
+
67
+ ### Configuration options
68
+
69
+ | Option | Description |
70
+ |--------|-------------|
71
+ | `host` | Zimbra server hostname or full `https://` URL |
72
+ | `email` | Account email address |
73
+ | `password` | Account password |
74
+ | `username` | Optional login name when it differs from `email` |
75
+ | `verify_ssl` | Validate TLS certificates (default: `False`) |
76
+ | `timeout` | Request timeout in seconds (default: `60`) |
77
+
78
+ ## Mailbox
79
+
80
+ ```python
81
+ with ZimbraClient(config) as client:
82
+ results = client.search_messages(query="from:sender@example.com", limit=25)
83
+
84
+ message = client.get_message("12345")
85
+ print(message.body_text, message.body_html, message.headers)
86
+
87
+ sent = client.send_message(
88
+ to="recipient@example.com",
89
+ subject="Hello",
90
+ text="Plain text",
91
+ html="<p>HTML body</p>",
92
+ )
93
+ print(sent.message_id)
94
+
95
+ client.mark_read(message.id)
96
+ client.move_message(message.id, folder_id="256")
97
+ client.trash_message(message.id)
98
+ ```
99
+
100
+ Drafts, attachments, folders, tags, and other mailbox actions are available on `ZimbraClient`.
101
+
102
+ ## Account, contacts, calendar, and filters
103
+
104
+ ```python
105
+ from datetime import datetime, timedelta, timezone
106
+
107
+ from zimbra_client import FilterRule
108
+ from zimbra_client.filters import filter_file_into, filter_from_address, filter_stop
109
+
110
+ with ZimbraClient(config) as client:
111
+ signatures = client.list_signatures()
112
+ prefs = client.get_prefs("zimbraPrefLocale")
113
+
114
+ contact = client.create_contact(
115
+ {"firstName": "Jane", "lastName": "Doe", "email": "jane@example.com"}
116
+ )
117
+
118
+ start = datetime.now(tz=timezone.utc)
119
+ client.create_appointment(
120
+ "Team sync",
121
+ start,
122
+ start + timedelta(hours=1),
123
+ location="Room A",
124
+ )
125
+
126
+ task = client.create_task("Follow up", text="Send summary")
127
+ client.complete_task(task.id)
128
+
129
+ client.set_filter_rules(
130
+ (
131
+ FilterRule(
132
+ name="Archive reports",
133
+ tests=(filter_from_address("reports@example.com"),),
134
+ actions=(filter_file_into("/Archive"), filter_stop()),
135
+ ),
136
+ )
137
+ )
138
+ ```
139
+
140
+ ## Advanced SOAP access
141
+
142
+ For API calls that do not have a convenience wrapper yet, use the generic request methods:
143
+
144
+ ```python
145
+ import xml.etree.ElementTree as ET
146
+
147
+ from zimbra_client import ACCOUNT_NAMESPACE, ZimbraClient
148
+
149
+ request = ET.Element(f"{{{ACCOUNT_NAMESPACE}}}GetInfoRequest")
150
+
151
+ with ZimbraClient(config) as client:
152
+ response = client.request_account(request)
153
+ ```
154
+
155
+ - `request_account()` sends `urn:zimbraAccount` requests
156
+ - `request_mail()` sends `urn:zimbraMail` requests
157
+ - `request()` is an alias that works for either namespace
158
+
159
+ The client authenticates lazily, reuses the session token, retries once on session expiry, and clears credentials from error messages.
160
+
161
+ ## Errors
162
+
163
+ The client raises typed exceptions such as `ZimbraAuthenticationError`, `ZimbraConnectionError`, `ZimbraNotFoundError`, `ZimbraLimitError`, and `ZimbraSOAPFault`.
164
+
165
+ ## License
166
+
167
+ MIT
@@ -0,0 +1,144 @@
1
+ # zimbra-client
2
+
3
+ Python client for the Zimbra end-user SOAP API. Connect with an email account and work with mail, folders, drafts, contacts, calendar, tasks, signatures, preferences, and filter rules through typed methods.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ python -m pip install zimbra-client
9
+ ```
10
+
11
+ Requires Python 3.9+. The package uses the standard library only.
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ from zimbra_client import ZimbraClient, ZimbraConfig
17
+
18
+ config = ZimbraConfig(
19
+ host="https://mail.example.com",
20
+ email="user@example.com",
21
+ password="your-password",
22
+ )
23
+
24
+ with ZimbraClient(config) as client:
25
+ inbox = client.search_messages(folder_id="2", limit=10)
26
+ for summary in inbox.messages:
27
+ message = client.get_message(summary.id)
28
+ print(message.subject, message.from_.email)
29
+ ```
30
+
31
+ You can also pass a mapping or keyword-style dict:
32
+
33
+ ```python
34
+ client = ZimbraClient(
35
+ {
36
+ "host": "mail.example.com",
37
+ "email": "user@example.com",
38
+ "password": "your-password",
39
+ "verify_ssl": True,
40
+ }
41
+ )
42
+ ```
43
+
44
+ ### Configuration options
45
+
46
+ | Option | Description |
47
+ |--------|-------------|
48
+ | `host` | Zimbra server hostname or full `https://` URL |
49
+ | `email` | Account email address |
50
+ | `password` | Account password |
51
+ | `username` | Optional login name when it differs from `email` |
52
+ | `verify_ssl` | Validate TLS certificates (default: `False`) |
53
+ | `timeout` | Request timeout in seconds (default: `60`) |
54
+
55
+ ## Mailbox
56
+
57
+ ```python
58
+ with ZimbraClient(config) as client:
59
+ results = client.search_messages(query="from:sender@example.com", limit=25)
60
+
61
+ message = client.get_message("12345")
62
+ print(message.body_text, message.body_html, message.headers)
63
+
64
+ sent = client.send_message(
65
+ to="recipient@example.com",
66
+ subject="Hello",
67
+ text="Plain text",
68
+ html="<p>HTML body</p>",
69
+ )
70
+ print(sent.message_id)
71
+
72
+ client.mark_read(message.id)
73
+ client.move_message(message.id, folder_id="256")
74
+ client.trash_message(message.id)
75
+ ```
76
+
77
+ Drafts, attachments, folders, tags, and other mailbox actions are available on `ZimbraClient`.
78
+
79
+ ## Account, contacts, calendar, and filters
80
+
81
+ ```python
82
+ from datetime import datetime, timedelta, timezone
83
+
84
+ from zimbra_client import FilterRule
85
+ from zimbra_client.filters import filter_file_into, filter_from_address, filter_stop
86
+
87
+ with ZimbraClient(config) as client:
88
+ signatures = client.list_signatures()
89
+ prefs = client.get_prefs("zimbraPrefLocale")
90
+
91
+ contact = client.create_contact(
92
+ {"firstName": "Jane", "lastName": "Doe", "email": "jane@example.com"}
93
+ )
94
+
95
+ start = datetime.now(tz=timezone.utc)
96
+ client.create_appointment(
97
+ "Team sync",
98
+ start,
99
+ start + timedelta(hours=1),
100
+ location="Room A",
101
+ )
102
+
103
+ task = client.create_task("Follow up", text="Send summary")
104
+ client.complete_task(task.id)
105
+
106
+ client.set_filter_rules(
107
+ (
108
+ FilterRule(
109
+ name="Archive reports",
110
+ tests=(filter_from_address("reports@example.com"),),
111
+ actions=(filter_file_into("/Archive"), filter_stop()),
112
+ ),
113
+ )
114
+ )
115
+ ```
116
+
117
+ ## Advanced SOAP access
118
+
119
+ For API calls that do not have a convenience wrapper yet, use the generic request methods:
120
+
121
+ ```python
122
+ import xml.etree.ElementTree as ET
123
+
124
+ from zimbra_client import ACCOUNT_NAMESPACE, ZimbraClient
125
+
126
+ request = ET.Element(f"{{{ACCOUNT_NAMESPACE}}}GetInfoRequest")
127
+
128
+ with ZimbraClient(config) as client:
129
+ response = client.request_account(request)
130
+ ```
131
+
132
+ - `request_account()` sends `urn:zimbraAccount` requests
133
+ - `request_mail()` sends `urn:zimbraMail` requests
134
+ - `request()` is an alias that works for either namespace
135
+
136
+ The client authenticates lazily, reuses the session token, retries once on session expiry, and clears credentials from error messages.
137
+
138
+ ## Errors
139
+
140
+ The client raises typed exceptions such as `ZimbraAuthenticationError`, `ZimbraConnectionError`, `ZimbraNotFoundError`, `ZimbraLimitError`, and `ZimbraSOAPFault`.
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "zimbra-client"
7
+ version = "0.3.2"
8
+ description = "End-user Zimbra SOAP client for mail, contacts, calendar, and account settings"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Ben Chan", email = "kpchanaf@connect.ust.hk" },
15
+ ]
16
+ keywords = ["zimbra", "email", "soap", "mailbox", "calendar", "contacts"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Communications :: Email",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ ]
30
+ dependencies = []
31
+
32
+ [tool.setuptools.packages.find]
33
+ include = ["zimbra_client*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,99 @@
1
+ import io
2
+ import unittest
3
+ import urllib.error
4
+ import xml.etree.ElementTree as ET
5
+ from unittest.mock import patch
6
+
7
+ from zimbra_client import ZimbraClient, ZimbraConfig, local_name
8
+
9
+ from tests.support.fake_zimbra import FakeZimbraDispatcher, request_name, soap_body_from_request
10
+
11
+
12
+ class AccountTests(unittest.TestCase):
13
+ def setUp(self):
14
+ self.config = ZimbraConfig(
15
+ host="https://mail.example.com/",
16
+ email="user@example.com",
17
+ password="secret<&password",
18
+ )
19
+ self.client = ZimbraClient(self.config)
20
+ self.dispatcher = FakeZimbraDispatcher()
21
+
22
+ def test_list_and_create_signatures(self):
23
+ with patch("zimbra_client.client.urllib.request.urlopen", self.dispatcher):
24
+ signatures = self.client.list_signatures()
25
+ created = self.client.create_signature("Work", text="Hello & welcome")
26
+
27
+ self.assertEqual(signatures[0].name, "Default")
28
+ self.assertEqual(created.id, "11")
29
+ self.assertEqual(created.text_plain, "Hello & welcome")
30
+
31
+ def test_modify_and_delete_signature(self):
32
+ captured = []
33
+
34
+ def dispatch(request, timeout, context):
35
+ captured.append(soap_body_from_request(request))
36
+ return self.dispatcher(request, timeout, context)
37
+
38
+ with patch("zimbra_client.client.urllib.request.urlopen", dispatch):
39
+ updated = self.client.modify_signature("10", name="Updated")
40
+ self.client.delete_signature("10")
41
+
42
+ signature = next(elem for elem in captured[1] if local_name(elem.tag) == "signature")
43
+ self.assertEqual(updated.name, "Updated")
44
+ self.assertEqual(signature.get("id"), "10")
45
+
46
+ def test_identities_and_prefs(self):
47
+ with patch("zimbra_client.client.urllib.request.urlopen", self.dispatcher):
48
+ identities = self.client.list_identities()
49
+ created = self.client.create_identity(
50
+ "Work",
51
+ {"zimbraPrefFromDisplay": "Work User"},
52
+ )
53
+ modified = self.client.modify_identity("21", {"zimbraPrefFromDisplay": "Updated"})
54
+ self.client.delete_identity("21")
55
+ prefs = self.client.get_prefs("zimbraPrefLocale")
56
+ updated_prefs = self.client.set_prefs({"zimbraPrefLocale": "en_GB"})
57
+
58
+ self.assertEqual(identities[0].attrs["zimbraPrefFromAddress"], "user@example.com")
59
+ self.assertEqual(created.name, "Work")
60
+ self.assertEqual(modified.id, "21")
61
+ self.assertEqual(prefs[0].name, "zimbraPrefLocale")
62
+ self.assertEqual(updated_prefs[0].value, "en_GB")
63
+
64
+ def test_signature_request_xml_is_escaped(self):
65
+ captured = []
66
+
67
+ def dispatch(request, timeout, context):
68
+ captured.append(request.data)
69
+ return self.dispatcher(request, timeout, context)
70
+
71
+ with patch("zimbra_client.client.urllib.request.urlopen", dispatch):
72
+ self.client.create_signature("Sig", text="a & b")
73
+
74
+ self.assertIn(b"a &amp; b", captured[1])
75
+ self.assertNotIn("secret<&password", captured[1].decode("utf-8"))
76
+
77
+ def test_auth_fault_does_not_leak_password(self):
78
+ fault = (
79
+ '<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">'
80
+ "<soap:Body><soap:Fault>"
81
+ "<soap:Reason><soap:Text>failed for secret&lt;&amp;password</soap:Text></soap:Reason>"
82
+ '<detail><Error xmlns="urn:zimbra"><Code>account.AUTH_FAILED</Code></Error></detail>'
83
+ "</soap:Fault></soap:Body></soap:Envelope>"
84
+ ).encode("utf-8")
85
+ error = urllib.error.HTTPError(
86
+ "https://mail.example.com/service/soap",
87
+ 500,
88
+ "Server Error",
89
+ None,
90
+ io.BytesIO(fault),
91
+ )
92
+ with patch("zimbra_client.client.urllib.request.urlopen", side_effect=error):
93
+ with self.assertRaises(Exception) as caught:
94
+ self.client.list_signatures()
95
+ self.assertNotIn("secret<&password", str(caught.exception))
96
+
97
+
98
+ if __name__ == "__main__":
99
+ unittest.main()
@@ -0,0 +1,55 @@
1
+ import unittest
2
+ from datetime import datetime, timezone
3
+ from unittest.mock import patch
4
+
5
+ from zimbra_client import ZimbraClient, ZimbraConfig
6
+
7
+ from tests.support.fake_zimbra import FakeZimbraDispatcher
8
+
9
+
10
+ class CalendarTests(unittest.TestCase):
11
+ def setUp(self):
12
+ self.config = ZimbraConfig(
13
+ host="https://mail.example.com/",
14
+ email="user@example.com",
15
+ password="secret",
16
+ )
17
+ self.client = ZimbraClient(self.config)
18
+ self.dispatcher = FakeZimbraDispatcher()
19
+ self.start = datetime(2023, 11, 14, 22, 13, 20, tzinfo=timezone.utc)
20
+ self.end = datetime(2023, 11, 14, 23, 13, 20, tzinfo=timezone.utc)
21
+
22
+ def test_appointment_create_list_get_cancel(self):
23
+ with patch("zimbra_client.client.urllib.request.urlopen", self.dispatcher):
24
+ created = self.client.create_appointment(
25
+ "Meeting",
26
+ self.start,
27
+ self.end,
28
+ location="Room A",
29
+ attendees=["guest@example.com"],
30
+ )
31
+ listed = self.client.list_appointments(self.start, self.end)
32
+ fetched = self.client.get_appointment("400")
33
+ self.client.cancel_appointment("400")
34
+
35
+ self.assertEqual(created.id, "400")
36
+ self.assertEqual(created.name, "Meeting")
37
+ self.assertEqual(listed[0].location, "Room A")
38
+ self.assertEqual(fetched.uid, "uid-400")
39
+
40
+ def test_task_create_get_modify_complete_list(self):
41
+ due = datetime(2023, 11, 16, 10, 0, tzinfo=timezone.utc)
42
+ with patch("zimbra_client.client.urllib.request.urlopen", self.dispatcher):
43
+ created = self.client.create_task("Follow up", due=due, text="Details")
44
+ fetched = self.client.get_task("500")
45
+ completed = self.client.complete_task("500")
46
+ tasks = self.client.list_tasks(limit=10)
47
+
48
+ self.assertEqual(created.id, "500")
49
+ self.assertEqual(fetched.subject, "Follow up")
50
+ self.assertTrue(completed.completed)
51
+ self.assertEqual(tasks[0].id, "501")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ unittest.main()