benethos-mailbox-client 0.3.0__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.
- benethos_mailbox_client-0.3.0/.gitignore +62 -0
- benethos_mailbox_client-0.3.0/LICENSE +21 -0
- benethos_mailbox_client-0.3.0/PKG-INFO +143 -0
- benethos_mailbox_client-0.3.0/README.md +117 -0
- benethos_mailbox_client-0.3.0/pyproject.toml +54 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/__init__.py +62 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/answers.py +85 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/attachments.py +71 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/calls.py +64 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/client.py +242 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/__init__.py +47 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/accounts.py +42 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/compose.py +40 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/drafts.py +48 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/folders.py +37 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/generic.py +28 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/messages.py +169 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/sending.py +37 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/environment.py +104 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/errors.py +30 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/models.py +100 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/py.typed +0 -0
- benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/sync.py +232 -0
- benethos_mailbox_client-0.3.0/tests/conftest.py +68 -0
- benethos_mailbox_client-0.3.0/tests/endpoints/test_accounts.py +36 -0
- benethos_mailbox_client-0.3.0/tests/endpoints/test_compose.py +17 -0
- benethos_mailbox_client-0.3.0/tests/endpoints/test_drafts.py +41 -0
- benethos_mailbox_client-0.3.0/tests/endpoints/test_folders.py +23 -0
- benethos_mailbox_client-0.3.0/tests/endpoints/test_messages.py +95 -0
- benethos_mailbox_client-0.3.0/tests/endpoints/test_sending.py +27 -0
- benethos_mailbox_client-0.3.0/tests/fake_api.py +71 -0
- benethos_mailbox_client-0.3.0/tests/test_answers.py +118 -0
- benethos_mailbox_client-0.3.0/tests/test_architecture.py +156 -0
- benethos_mailbox_client-0.3.0/tests/test_attachments.py +60 -0
- benethos_mailbox_client-0.3.0/tests/test_calls.py +36 -0
- benethos_mailbox_client-0.3.0/tests/test_environment.py +127 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Virtual environment
|
|
2
|
+
.venv/
|
|
3
|
+
venv/
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*.egg-info/
|
|
9
|
+
.eggs/
|
|
10
|
+
build/
|
|
11
|
+
dist/
|
|
12
|
+
|
|
13
|
+
# Caching / coverage
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
.cache/
|
|
18
|
+
*.sqlite
|
|
19
|
+
.coverage
|
|
20
|
+
.coverage.*
|
|
21
|
+
htmlcov/
|
|
22
|
+
coverage.xml
|
|
23
|
+
|
|
24
|
+
# IDE / OS
|
|
25
|
+
.vscode/
|
|
26
|
+
.idea/
|
|
27
|
+
.DS_Store
|
|
28
|
+
Thumbs.db
|
|
29
|
+
|
|
30
|
+
# Local configuration and secrets
|
|
31
|
+
.env
|
|
32
|
+
# config/: per package, only the templates are versioned
|
|
33
|
+
config/**
|
|
34
|
+
!config/**/
|
|
35
|
+
!config/*/.env.example
|
|
36
|
+
*.token.json
|
|
37
|
+
# containers/*/secrets/: the master key of a deployment, the passwords
|
|
38
|
+
# and the CA of the test mail server
|
|
39
|
+
containers/**/secrets/
|
|
40
|
+
# containers/production/service.env: settings, among them perhaps a secret
|
|
41
|
+
containers/**/service.env
|
|
42
|
+
# containers/production/: the MCP server instances and their routes in Caddy
|
|
43
|
+
containers/production/compose.override.yaml
|
|
44
|
+
containers/production/caddy.d/*.caddy
|
|
45
|
+
|
|
46
|
+
# Service data: the database holds encrypted credentials, backups the same.
|
|
47
|
+
# data/: per package, only the folders are versioned (.gitkeep)
|
|
48
|
+
data/**
|
|
49
|
+
!data/**/
|
|
50
|
+
*.db
|
|
51
|
+
*.db-journal
|
|
52
|
+
*.db-wal
|
|
53
|
+
*.db-shm
|
|
54
|
+
**/backups/**
|
|
55
|
+
!**/backups/**/
|
|
56
|
+
|
|
57
|
+
# Live checks: test server address and test accounts stay local
|
|
58
|
+
live/.env
|
|
59
|
+
|
|
60
|
+
# .gitkeep holds an empty folder and is kept wherever it is. Only works
|
|
61
|
+
# below folders ignored file by file (as above), not below an ignored folder.
|
|
62
|
+
!.gitkeep
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Benedikt Oswald
|
|
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,143 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: benethos-mailbox-client
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Python client of the REST API of mailbox-service: the mail of all your mailboxes (IMAP, JMAP, POP3, Microsoft 365, Outlook.com) through one API, async and sync.
|
|
5
|
+
Project-URL: Homepage, https://github.com/benethos-hub/mailbox/tree/main/packages/mailbox-client#readme
|
|
6
|
+
Project-URL: Repository, https://github.com/benethos-hub/mailbox
|
|
7
|
+
Project-URL: Issues, https://github.com/benethos-hub/mailbox/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/benethos-hub/mailbox/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Benedikt Oswald
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api-client,email,email-automation,imap,jmap,microsoft-365,multi-account,outlook,pop3,rest-client,self-hosted
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Communications :: Email
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: httpx>=0.28
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# mailbox-client
|
|
28
|
+
|
|
29
|
+
[](https://github.com/benethos-hub/mailbox/actions/workflows/ci.yml)
|
|
30
|
+
[](https://pypi.org/project/benethos-mailbox-client/)
|
|
31
|
+
[](https://pypi.org/project/benethos-mailbox-client/)
|
|
32
|
+
[](https://github.com/benethos-hub/mailbox/blob/main/LICENSE)
|
|
33
|
+
|
|
34
|
+
> **Beta, version 0.3.0.** Usable with real accounts. A breaking change
|
|
35
|
+
> of the API or the configuration is announced in the changelog. Stored
|
|
36
|
+
> data is carried forward by migrations.
|
|
37
|
+
|
|
38
|
+
The Python client of the REST API of
|
|
39
|
+
[`mailbox-service`](https://github.com/benethos-hub/mailbox/tree/main/packages/mailbox-service),
|
|
40
|
+
on PyPI as `benethos-mailbox-client`. It reads, searches, sorts, drafts
|
|
41
|
+
and sends mail of the accounts the service holds, as far as its token
|
|
42
|
+
allows. The MCP server of the project,
|
|
43
|
+
[`mailbox-mcp`](https://github.com/benethos-hub/mailbox/tree/main/packages/mailbox-mcp),
|
|
44
|
+
is built on it.
|
|
45
|
+
|
|
46
|
+
It knows the service through its REST API alone, and needs nothing but
|
|
47
|
+
[httpx](https://www.python-httpx.org/). The REST API follows the status
|
|
48
|
+
above. This package's Python interface has no stability promise yet: it
|
|
49
|
+
follows what the MCP server and the project's own checks need.
|
|
50
|
+
|
|
51
|
+
What the project is for: [the repository's README](https://github.com/benethos-hub/mailbox#readme).
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
uv add benethos-mailbox-client # or: pip install benethos-mailbox-client
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Use
|
|
60
|
+
|
|
61
|
+
Make a token on your user's page in the service's UI. The client reads
|
|
62
|
+
it from `MAILBOX_SERVICE_TOKEN`, and the address of the service from
|
|
63
|
+
`MAILBOX_SERVICE_URL` (default `http://127.0.0.1:8080`). Both can be
|
|
64
|
+
passed as arguments instead.
|
|
65
|
+
|
|
66
|
+
For async code:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from benethos_mailbox_client import MailboxClient
|
|
70
|
+
|
|
71
|
+
async with MailboxClient() as mailbox:
|
|
72
|
+
me = await mailbox.me()
|
|
73
|
+
for account in me.accounts:
|
|
74
|
+
page = await mailbox.list_messages(account.id, folder="inbox", limit=10)
|
|
75
|
+
for summary in page.items:
|
|
76
|
+
print(account.email, summary["subject"])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
For code without an event loop, `SyncMailboxClient` has the same
|
|
80
|
+
methods (`close()` for `aclose()`) and answers the same records:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from benethos_mailbox_client import SyncMailboxClient, message_body
|
|
84
|
+
|
|
85
|
+
with SyncMailboxClient() as mailbox:
|
|
86
|
+
body = message_body(
|
|
87
|
+
to=[("someone@example.org", None)],
|
|
88
|
+
cc=[],
|
|
89
|
+
bcc=[],
|
|
90
|
+
subject="Hello",
|
|
91
|
+
text="Hi there",
|
|
92
|
+
html=None,
|
|
93
|
+
reference=None,
|
|
94
|
+
)
|
|
95
|
+
mailbox.send_message("acc_...", body, idempotency_key="hello-1")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`request(method, path, ...)` reaches any route of the API the methods do
|
|
99
|
+
not cover and answers its JSON. The routes and their fields:
|
|
100
|
+
[`docs/openapi.json`](https://github.com/benethos-hub/mailbox/blob/main/docs/openapi.json).
|
|
101
|
+
|
|
102
|
+
The token travels in a header. Over http the client talks only to this
|
|
103
|
+
machine. To another machine it needs https, or
|
|
104
|
+
`MAILBOX_SERVICE_ALLOW_HTTP=1` (`allow_http=True`) for a network you
|
|
105
|
+
trust, such as between containers.
|
|
106
|
+
|
|
107
|
+
A client made without an address or a token reads the environment when
|
|
108
|
+
it is made. A program that makes clients for a long time can read it
|
|
109
|
+
once, with `from_environment()`:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from benethos_mailbox_client import MailboxClient, from_environment
|
|
113
|
+
|
|
114
|
+
found = from_environment()
|
|
115
|
+
client = MailboxClient(found.url, found.token, allow_http=found.allow_http)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Errors
|
|
119
|
+
|
|
120
|
+
Everything the client raises is a `MailboxError`:
|
|
121
|
+
|
|
122
|
+
| Error | When |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `ConfigurationError` | no token, or an address it refuses |
|
|
125
|
+
| `ServiceUnavailableError` | the service cannot be reached |
|
|
126
|
+
| `ServiceTimeoutError` | the service took the request but did not answer in time |
|
|
127
|
+
| `ApiError` | the API answered with an error: `status`, `code`, `message` |
|
|
128
|
+
|
|
129
|
+
## Inside
|
|
130
|
+
|
|
131
|
+
Each endpoint is described once, in `endpoints/`, one module per
|
|
132
|
+
resource of the API: its method, path, query, body and how its answer
|
|
133
|
+
becomes a record. That package sends nothing. `MailboxClient` sends
|
|
134
|
+
those requests with `httpx.AsyncClient`, `SyncMailboxClient` with
|
|
135
|
+
`httpx.Client`, and a method of either is a line or two,
|
|
136
|
+
`get_attachment`, which streams, a few more. What both share stands
|
|
137
|
+
beside them: the address and the token (`environment.py`), the shape
|
|
138
|
+
of a request (`calls.py`), how an answer or a failure is read
|
|
139
|
+
(`answers.py`) and an attachment read in chunks (`attachments.py`).
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT, see [LICENSE](https://github.com/benethos-hub/mailbox/blob/main/LICENSE).
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# mailbox-client
|
|
2
|
+
|
|
3
|
+
[](https://github.com/benethos-hub/mailbox/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/benethos-mailbox-client/)
|
|
5
|
+
[](https://pypi.org/project/benethos-mailbox-client/)
|
|
6
|
+
[](https://github.com/benethos-hub/mailbox/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
> **Beta, version 0.3.0.** Usable with real accounts. A breaking change
|
|
9
|
+
> of the API or the configuration is announced in the changelog. Stored
|
|
10
|
+
> data is carried forward by migrations.
|
|
11
|
+
|
|
12
|
+
The Python client of the REST API of
|
|
13
|
+
[`mailbox-service`](https://github.com/benethos-hub/mailbox/tree/main/packages/mailbox-service),
|
|
14
|
+
on PyPI as `benethos-mailbox-client`. It reads, searches, sorts, drafts
|
|
15
|
+
and sends mail of the accounts the service holds, as far as its token
|
|
16
|
+
allows. The MCP server of the project,
|
|
17
|
+
[`mailbox-mcp`](https://github.com/benethos-hub/mailbox/tree/main/packages/mailbox-mcp),
|
|
18
|
+
is built on it.
|
|
19
|
+
|
|
20
|
+
It knows the service through its REST API alone, and needs nothing but
|
|
21
|
+
[httpx](https://www.python-httpx.org/). The REST API follows the status
|
|
22
|
+
above. This package's Python interface has no stability promise yet: it
|
|
23
|
+
follows what the MCP server and the project's own checks need.
|
|
24
|
+
|
|
25
|
+
What the project is for: [the repository's README](https://github.com/benethos-hub/mailbox#readme).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
uv add benethos-mailbox-client # or: pip install benethos-mailbox-client
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
Make a token on your user's page in the service's UI. The client reads
|
|
36
|
+
it from `MAILBOX_SERVICE_TOKEN`, and the address of the service from
|
|
37
|
+
`MAILBOX_SERVICE_URL` (default `http://127.0.0.1:8080`). Both can be
|
|
38
|
+
passed as arguments instead.
|
|
39
|
+
|
|
40
|
+
For async code:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from benethos_mailbox_client import MailboxClient
|
|
44
|
+
|
|
45
|
+
async with MailboxClient() as mailbox:
|
|
46
|
+
me = await mailbox.me()
|
|
47
|
+
for account in me.accounts:
|
|
48
|
+
page = await mailbox.list_messages(account.id, folder="inbox", limit=10)
|
|
49
|
+
for summary in page.items:
|
|
50
|
+
print(account.email, summary["subject"])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For code without an event loop, `SyncMailboxClient` has the same
|
|
54
|
+
methods (`close()` for `aclose()`) and answers the same records:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from benethos_mailbox_client import SyncMailboxClient, message_body
|
|
58
|
+
|
|
59
|
+
with SyncMailboxClient() as mailbox:
|
|
60
|
+
body = message_body(
|
|
61
|
+
to=[("someone@example.org", None)],
|
|
62
|
+
cc=[],
|
|
63
|
+
bcc=[],
|
|
64
|
+
subject="Hello",
|
|
65
|
+
text="Hi there",
|
|
66
|
+
html=None,
|
|
67
|
+
reference=None,
|
|
68
|
+
)
|
|
69
|
+
mailbox.send_message("acc_...", body, idempotency_key="hello-1")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`request(method, path, ...)` reaches any route of the API the methods do
|
|
73
|
+
not cover and answers its JSON. The routes and their fields:
|
|
74
|
+
[`docs/openapi.json`](https://github.com/benethos-hub/mailbox/blob/main/docs/openapi.json).
|
|
75
|
+
|
|
76
|
+
The token travels in a header. Over http the client talks only to this
|
|
77
|
+
machine. To another machine it needs https, or
|
|
78
|
+
`MAILBOX_SERVICE_ALLOW_HTTP=1` (`allow_http=True`) for a network you
|
|
79
|
+
trust, such as between containers.
|
|
80
|
+
|
|
81
|
+
A client made without an address or a token reads the environment when
|
|
82
|
+
it is made. A program that makes clients for a long time can read it
|
|
83
|
+
once, with `from_environment()`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from benethos_mailbox_client import MailboxClient, from_environment
|
|
87
|
+
|
|
88
|
+
found = from_environment()
|
|
89
|
+
client = MailboxClient(found.url, found.token, allow_http=found.allow_http)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Errors
|
|
93
|
+
|
|
94
|
+
Everything the client raises is a `MailboxError`:
|
|
95
|
+
|
|
96
|
+
| Error | When |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `ConfigurationError` | no token, or an address it refuses |
|
|
99
|
+
| `ServiceUnavailableError` | the service cannot be reached |
|
|
100
|
+
| `ServiceTimeoutError` | the service took the request but did not answer in time |
|
|
101
|
+
| `ApiError` | the API answered with an error: `status`, `code`, `message` |
|
|
102
|
+
|
|
103
|
+
## Inside
|
|
104
|
+
|
|
105
|
+
Each endpoint is described once, in `endpoints/`, one module per
|
|
106
|
+
resource of the API: its method, path, query, body and how its answer
|
|
107
|
+
becomes a record. That package sends nothing. `MailboxClient` sends
|
|
108
|
+
those requests with `httpx.AsyncClient`, `SyncMailboxClient` with
|
|
109
|
+
`httpx.Client`, and a method of either is a line or two,
|
|
110
|
+
`get_attachment`, which streams, a few more. What both share stands
|
|
111
|
+
beside them: the address and the token (`environment.py`), the shape
|
|
112
|
+
of a request (`calls.py`), how an answer or a failure is read
|
|
113
|
+
(`answers.py`) and an attachment read in chunks (`attachments.py`).
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT, see [LICENSE](https://github.com/benethos-hub/mailbox/blob/main/LICENSE).
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "benethos-mailbox-client"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Python client of the REST API of mailbox-service: the mail of all your mailboxes (IMAP, JMAP, POP3, Microsoft 365, Outlook.com) through one API, async and sync."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Benedikt Oswald" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"email",
|
|
16
|
+
"imap",
|
|
17
|
+
"jmap",
|
|
18
|
+
"pop3",
|
|
19
|
+
"outlook",
|
|
20
|
+
"microsoft-365",
|
|
21
|
+
"multi-account",
|
|
22
|
+
"rest-client",
|
|
23
|
+
"api-client",
|
|
24
|
+
"email-automation",
|
|
25
|
+
"self-hosted",
|
|
26
|
+
]
|
|
27
|
+
classifiers = [
|
|
28
|
+
"Development Status :: 4 - Beta",
|
|
29
|
+
"Intended Audience :: Developers",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Programming Language :: Python :: 3.13",
|
|
35
|
+
"Programming Language :: Python :: 3.14",
|
|
36
|
+
"Topic :: Communications :: Email",
|
|
37
|
+
"Typing :: Typed",
|
|
38
|
+
]
|
|
39
|
+
# One library: the client speaks HTTP and nothing else. It knows the
|
|
40
|
+
# service only through its REST API.
|
|
41
|
+
dependencies = [
|
|
42
|
+
# The minimum is a version without a known vulnerability: the job
|
|
43
|
+
# lowest-versions of ci.yml checks it against OSV.
|
|
44
|
+
"httpx>=0.28",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://github.com/benethos-hub/mailbox/tree/main/packages/mailbox-client#readme"
|
|
49
|
+
Repository = "https://github.com/benethos-hub/mailbox"
|
|
50
|
+
Issues = "https://github.com/benethos-hub/mailbox/issues"
|
|
51
|
+
Changelog = "https://github.com/benethos-hub/mailbox/blob/main/CHANGELOG.md"
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
packages = ["src/benethos_mailbox_client"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""mailbox-client, the Python client of the REST API of mailbox-service.
|
|
2
|
+
|
|
3
|
+
``MailboxClient`` is for async code, ``SyncMailboxClient`` for code
|
|
4
|
+
without an event loop. Both send the same requests and answer the same
|
|
5
|
+
records."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
from .client import MailboxClient
|
|
12
|
+
from .endpoints import message_body
|
|
13
|
+
from .environment import Environment, from_environment
|
|
14
|
+
from .errors import (
|
|
15
|
+
ApiError,
|
|
16
|
+
ConfigurationError,
|
|
17
|
+
MailboxError,
|
|
18
|
+
ServiceTimeoutError,
|
|
19
|
+
ServiceUnavailableError,
|
|
20
|
+
)
|
|
21
|
+
from .models import (
|
|
22
|
+
Attachment,
|
|
23
|
+
Changes,
|
|
24
|
+
Folder,
|
|
25
|
+
Me,
|
|
26
|
+
MeAccount,
|
|
27
|
+
Outcome,
|
|
28
|
+
Page,
|
|
29
|
+
Recipient,
|
|
30
|
+
Sending,
|
|
31
|
+
Sent,
|
|
32
|
+
)
|
|
33
|
+
from .sync import SyncMailboxClient
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
__version__ = version("benethos-mailbox-client")
|
|
37
|
+
except PackageNotFoundError: # pragma: no cover - running from a bare tree
|
|
38
|
+
__version__ = "0.0.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"ApiError",
|
|
42
|
+
"Attachment",
|
|
43
|
+
"Changes",
|
|
44
|
+
"ConfigurationError",
|
|
45
|
+
"Environment",
|
|
46
|
+
"Folder",
|
|
47
|
+
"MailboxClient",
|
|
48
|
+
"MailboxError",
|
|
49
|
+
"Me",
|
|
50
|
+
"MeAccount",
|
|
51
|
+
"Outcome",
|
|
52
|
+
"Page",
|
|
53
|
+
"Recipient",
|
|
54
|
+
"Sending",
|
|
55
|
+
"Sent",
|
|
56
|
+
"ServiceTimeoutError",
|
|
57
|
+
"ServiceUnavailableError",
|
|
58
|
+
"SyncMailboxClient",
|
|
59
|
+
"__version__",
|
|
60
|
+
"from_environment",
|
|
61
|
+
"message_body",
|
|
62
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""How an answer of the API, or a failure on the way to it, is read: the
|
|
2
|
+
JSON of an answer, the error envelope as an ``ApiError``, a service that
|
|
3
|
+
cannot be reached or does not answer in time. The same for both
|
|
4
|
+
clients."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .calls import Call, T
|
|
13
|
+
from .errors import ApiError, MailboxError, ServiceTimeoutError, ServiceUnavailableError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def answer(response: httpx.Response) -> Any:
|
|
17
|
+
"""The JSON of a read answer, None for no content. Raises ``ApiError``
|
|
18
|
+
for an error and for an answer that is not JSON."""
|
|
19
|
+
if response.is_error:
|
|
20
|
+
raise api_error(response)
|
|
21
|
+
if response.status_code == 204:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
return response.json()
|
|
25
|
+
except ValueError:
|
|
26
|
+
raise ApiError(
|
|
27
|
+
response.status_code, "unexpected_response", "the answer is not JSON"
|
|
28
|
+
) from None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read(call: Call[T], response: httpx.Response) -> T:
|
|
32
|
+
"""What the caller gets of ``response``. An answer of another shape
|
|
33
|
+
than the API describes, such as one without a field the call reads, is
|
|
34
|
+
an ``ApiError`` too, not an error deep inside the reading."""
|
|
35
|
+
found = answer(response)
|
|
36
|
+
try:
|
|
37
|
+
return call.read(found)
|
|
38
|
+
except (KeyError, TypeError, ValueError, AttributeError):
|
|
39
|
+
raise ApiError(
|
|
40
|
+
response.status_code,
|
|
41
|
+
"unexpected_response",
|
|
42
|
+
"the answer is not what the API describes",
|
|
43
|
+
) from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def api_error(response: httpx.Response) -> ApiError:
|
|
47
|
+
"""The error envelope as an ApiError. A validation failure (422) has no
|
|
48
|
+
envelope but a list of what was wrong where, which is what a caller
|
|
49
|
+
needs to correct the call."""
|
|
50
|
+
try:
|
|
51
|
+
body = response.json()
|
|
52
|
+
except ValueError:
|
|
53
|
+
body = None
|
|
54
|
+
if isinstance(body, dict) and isinstance(body.get("error"), dict):
|
|
55
|
+
error = body["error"]
|
|
56
|
+
if "code" in error and "message" in error:
|
|
57
|
+
code, message = str(error["code"]), str(error["message"])
|
|
58
|
+
return ApiError(response.status_code, code, message)
|
|
59
|
+
if isinstance(body, dict) and isinstance(body.get("detail"), list):
|
|
60
|
+
reasons = [
|
|
61
|
+
f"{'.'.join(str(p) for p in item.get('loc', ()) if p != 'body')}: "
|
|
62
|
+
f"{item.get('msg', '')}"
|
|
63
|
+
for item in body["detail"]
|
|
64
|
+
if isinstance(item, dict)
|
|
65
|
+
]
|
|
66
|
+
if reasons:
|
|
67
|
+
reason = "; ".join(reasons)
|
|
68
|
+
return ApiError(response.status_code, "validation_error", reason)
|
|
69
|
+
return ApiError(response.status_code, "unexpected_response", response.reason_phrase)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def failure(exc: httpx.TransportError, base_url: str, seconds: float) -> MailboxError:
|
|
73
|
+
"""What went wrong on the way: a service that answers too slowly is
|
|
74
|
+
running, one that cannot be reached is not."""
|
|
75
|
+
if isinstance(exc, httpx.TimeoutException) and not isinstance(
|
|
76
|
+
exc, httpx.ConnectTimeout
|
|
77
|
+
):
|
|
78
|
+
return ServiceTimeoutError(
|
|
79
|
+
f"mailbox-service did not answer within {seconds:g} s. "
|
|
80
|
+
"Try a narrower request."
|
|
81
|
+
)
|
|
82
|
+
return ServiceUnavailableError(
|
|
83
|
+
f"mailbox-service is not reachable at {base_url}. "
|
|
84
|
+
"Start it with `benethos-mailbox-service serve`."
|
|
85
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""An attachment's bytes, read in chunks up to the caller's limit, and
|
|
2
|
+
what the answer's headers say of it: its type, its charset and its file
|
|
3
|
+
name. Both clients collect the chunks here, each in its own way."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import codecs
|
|
8
|
+
import re
|
|
9
|
+
from urllib.parse import unquote
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .models import Attachment
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Collected:
|
|
17
|
+
"""The bytes of an answer read in chunks, up to a limit. ``add``
|
|
18
|
+
answers False once the limit is passed: reading stops there."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, max_bytes: int) -> None:
|
|
21
|
+
self.max_bytes = max_bytes
|
|
22
|
+
self.chunks: list[bytes] = []
|
|
23
|
+
self.read = 0
|
|
24
|
+
self.complete = True
|
|
25
|
+
|
|
26
|
+
def add(self, chunk: bytes) -> bool:
|
|
27
|
+
self.chunks.append(chunk)
|
|
28
|
+
self.read += len(chunk)
|
|
29
|
+
if self.read > self.max_bytes:
|
|
30
|
+
self.complete = False
|
|
31
|
+
return self.complete
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def attachment(headers: httpx.Headers, collected: Collected) -> Attachment:
|
|
35
|
+
"""The attachment the headers describe, with the bytes read of it."""
|
|
36
|
+
media, _, options = headers.get(
|
|
37
|
+
"content-type", "application/octet-stream"
|
|
38
|
+
).partition(";")
|
|
39
|
+
charset = re.search(r"charset=\"?([\w.:-]+)", options)
|
|
40
|
+
name = re.search(
|
|
41
|
+
r"filename\*=UTF-8''([^;]+)", headers.get("content-disposition", "")
|
|
42
|
+
)
|
|
43
|
+
return Attachment(
|
|
44
|
+
data=b"".join(collected.chunks)[: collected.max_bytes],
|
|
45
|
+
content_type=_media_type(media),
|
|
46
|
+
charset=_known_charset(charset.group(1)) if charset else None,
|
|
47
|
+
filename=unquote(name.group(1)) if name else None,
|
|
48
|
+
complete=collected.complete,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# A media type, type/subtype in the characters RFC 6838 allows. The sender
|
|
53
|
+
# of a mail chose it, and a caller may show it.
|
|
54
|
+
_MEDIA_TYPE = re.compile(r"[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _media_type(value: str) -> str:
|
|
58
|
+
"""``value`` when it is a media type and nothing else, else the type of
|
|
59
|
+
unknown bytes."""
|
|
60
|
+
media = value.strip().lower()
|
|
61
|
+
if len(media) <= 127 and _MEDIA_TYPE.fullmatch(media):
|
|
62
|
+
return media
|
|
63
|
+
return "application/octet-stream"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _known_charset(name: str) -> str | None:
|
|
67
|
+
try:
|
|
68
|
+
codecs.lookup(name)
|
|
69
|
+
except LookupError:
|
|
70
|
+
return None
|
|
71
|
+
return name
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The request a call makes, described without sending it: its method,
|
|
2
|
+
its path below ``/v1``, its parameters and body, how long to wait, and
|
|
3
|
+
how its answer becomes what the caller gets. ``client`` and ``sync``
|
|
4
|
+
send a ``Call``, each in its own way."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Generic, TypeVar
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
# Seconds to wait: for the service to take the connection, for an answer,
|
|
16
|
+
# and for an attachment, which may be large.
|
|
17
|
+
CONNECT_TIMEOUT = 5.0
|
|
18
|
+
TIMEOUT = 30.0
|
|
19
|
+
ATTACHMENT_TIMEOUT = 120.0
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Call(Generic[T]):
|
|
26
|
+
"""One request to the API, and how its answer becomes what the caller
|
|
27
|
+
gets. ``read`` takes the JSON of the answer, None for no content."""
|
|
28
|
+
|
|
29
|
+
method: str
|
|
30
|
+
path: str
|
|
31
|
+
read: Callable[[Any], T]
|
|
32
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
json: dict[str, Any] | None = None
|
|
34
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
35
|
+
timeout: float = TIMEOUT
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def timeouts(self) -> httpx.Timeout:
|
|
39
|
+
return timeouts(self.timeout)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def timeouts(seconds: float = TIMEOUT) -> httpx.Timeout:
|
|
43
|
+
"""How long to wait for the connection, and then for the answer."""
|
|
44
|
+
return httpx.Timeout(seconds, connect=CONNECT_TIMEOUT)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def given(values: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
48
|
+
"""Without the entries that are None: those stay out of a request."""
|
|
49
|
+
return {k: v for k, v in (values or {}).items() if v is not None}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def path(*parts: str) -> str:
|
|
53
|
+
"""A path below ``/v1``, each part quoted: an id may come from anyone."""
|
|
54
|
+
return "/v1/" + "/".join(quote(part, safe="") for part in parts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def as_is(found: Any) -> Any:
|
|
58
|
+
"""The reading of a call whose answer is passed on as it comes."""
|
|
59
|
+
return found
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def nothing(found: Any) -> None:
|
|
63
|
+
"""The reading of a call whose answer has no content."""
|
|
64
|
+
return None
|