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.
Files changed (36) hide show
  1. benethos_mailbox_client-0.3.0/.gitignore +62 -0
  2. benethos_mailbox_client-0.3.0/LICENSE +21 -0
  3. benethos_mailbox_client-0.3.0/PKG-INFO +143 -0
  4. benethos_mailbox_client-0.3.0/README.md +117 -0
  5. benethos_mailbox_client-0.3.0/pyproject.toml +54 -0
  6. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/__init__.py +62 -0
  7. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/answers.py +85 -0
  8. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/attachments.py +71 -0
  9. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/calls.py +64 -0
  10. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/client.py +242 -0
  11. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/__init__.py +47 -0
  12. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/accounts.py +42 -0
  13. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/compose.py +40 -0
  14. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/drafts.py +48 -0
  15. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/folders.py +37 -0
  16. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/generic.py +28 -0
  17. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/messages.py +169 -0
  18. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/endpoints/sending.py +37 -0
  19. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/environment.py +104 -0
  20. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/errors.py +30 -0
  21. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/models.py +100 -0
  22. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/py.typed +0 -0
  23. benethos_mailbox_client-0.3.0/src/benethos_mailbox_client/sync.py +232 -0
  24. benethos_mailbox_client-0.3.0/tests/conftest.py +68 -0
  25. benethos_mailbox_client-0.3.0/tests/endpoints/test_accounts.py +36 -0
  26. benethos_mailbox_client-0.3.0/tests/endpoints/test_compose.py +17 -0
  27. benethos_mailbox_client-0.3.0/tests/endpoints/test_drafts.py +41 -0
  28. benethos_mailbox_client-0.3.0/tests/endpoints/test_folders.py +23 -0
  29. benethos_mailbox_client-0.3.0/tests/endpoints/test_messages.py +95 -0
  30. benethos_mailbox_client-0.3.0/tests/endpoints/test_sending.py +27 -0
  31. benethos_mailbox_client-0.3.0/tests/fake_api.py +71 -0
  32. benethos_mailbox_client-0.3.0/tests/test_answers.py +118 -0
  33. benethos_mailbox_client-0.3.0/tests/test_architecture.py +156 -0
  34. benethos_mailbox_client-0.3.0/tests/test_attachments.py +60 -0
  35. benethos_mailbox_client-0.3.0/tests/test_calls.py +36 -0
  36. 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
+ [![CI](https://github.com/benethos-hub/mailbox/actions/workflows/ci.yml/badge.svg)](https://github.com/benethos-hub/mailbox/actions/workflows/ci.yml)
30
+ [![PyPI benethos-mailbox-client](https://img.shields.io/pypi/v/benethos-mailbox-client?label=PyPI%20benethos-mailbox-client)](https://pypi.org/project/benethos-mailbox-client/)
31
+ [![Python](https://img.shields.io/pypi/pyversions/benethos-mailbox-client)](https://pypi.org/project/benethos-mailbox-client/)
32
+ [![License](https://img.shields.io/badge/license-MIT-blue)](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
+ [![CI](https://github.com/benethos-hub/mailbox/actions/workflows/ci.yml/badge.svg)](https://github.com/benethos-hub/mailbox/actions/workflows/ci.yml)
4
+ [![PyPI benethos-mailbox-client](https://img.shields.io/pypi/v/benethos-mailbox-client?label=PyPI%20benethos-mailbox-client)](https://pypi.org/project/benethos-mailbox-client/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/benethos-mailbox-client)](https://pypi.org/project/benethos-mailbox-client/)
6
+ [![License](https://img.shields.io/badge/license-MIT-blue)](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