benethos-mailbox-client 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
@@ -0,0 +1,242 @@
1
+ """``MailboxClient``, the client for async code: it sends the calls of
2
+ ``endpoints`` with ``httpx.AsyncClient``. Each method is one line, what
3
+ it asks and what it answers is described in ``endpoints``."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from types import TracebackType
8
+ from typing import Any, TypeVar
9
+
10
+ import httpx
11
+
12
+ from . import endpoints
13
+ from .answers import api_error, failure, read
14
+ from .attachments import Collected, attachment
15
+ from .calls import Call, timeouts
16
+ from .environment import connection
17
+ from .models import Attachment, Changes, Folder, Me, Outcome, Page, Sent
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ class MailboxClient:
23
+ """The REST API of mailbox-service, for async code.
24
+
25
+ ``base_url`` defaults to ``MAILBOX_SERVICE_URL``, ``token`` to
26
+ ``MAILBOX_SERVICE_TOKEN``. Raises ``ConfigurationError`` without a
27
+ token, and for http to another machine unless ``allow_http`` (else
28
+ ``MAILBOX_SERVICE_ALLOW_HTTP``) allows it. ``transport`` is for tests,
29
+ e.g. ``httpx.MockTransport``."""
30
+
31
+ def __init__(
32
+ self,
33
+ base_url: str | None = None,
34
+ token: str | None = None,
35
+ transport: httpx.AsyncBaseTransport | None = None,
36
+ allow_http: bool | None = None,
37
+ ) -> None:
38
+ found = connection(base_url, token, allow_http)
39
+ self.base_url = found.base_url
40
+ self._http = httpx.AsyncClient(
41
+ base_url=found.base_url,
42
+ headers=found.headers,
43
+ timeout=timeouts(),
44
+ transport=transport,
45
+ )
46
+
47
+ async def __aenter__(self) -> MailboxClient:
48
+ return self
49
+
50
+ async def __aexit__(
51
+ self,
52
+ kind: type[BaseException] | None,
53
+ error: BaseException | None,
54
+ traceback: TracebackType | None,
55
+ ) -> None:
56
+ await self.aclose()
57
+
58
+ async def aclose(self) -> None:
59
+ await self._http.aclose()
60
+
61
+ async def send(self, call: Call[T]) -> T:
62
+ """Send ``call`` and read its answer."""
63
+ try:
64
+ response = await self._http.request(
65
+ call.method,
66
+ call.path,
67
+ params=call.params,
68
+ json=call.json,
69
+ headers=call.headers,
70
+ timeout=call.timeouts,
71
+ )
72
+ except httpx.TransportError as exc:
73
+ raise failure(exc, self.base_url, call.timeout) from None
74
+ return read(call, response)
75
+
76
+ async def request(
77
+ self,
78
+ method: str,
79
+ path: str,
80
+ *,
81
+ params: dict[str, Any] | None = None,
82
+ json: dict[str, Any] | None = None,
83
+ headers: dict[str, str] | None = None,
84
+ ) -> Any:
85
+ """Any request: the JSON the API answers, None for no content.
86
+ Parameters and fields that are None are left out."""
87
+ call = endpoints.request(
88
+ method, path, params=params, json=json, headers=headers
89
+ )
90
+ return await self.send(call)
91
+
92
+ # --- the caller and the accounts --------------------------------------------------
93
+
94
+ async def me(self) -> Me:
95
+ return await self.send(endpoints.me())
96
+
97
+ # --- folders ----------------------------------------------------------------------
98
+
99
+ async def list_folders(self, account_id: str) -> list[Folder]:
100
+ return await self.send(endpoints.list_folders(account_id))
101
+
102
+ async def create_folder(
103
+ self, account_id: str, name: str, parent_id: str | None
104
+ ) -> Folder:
105
+ return await self.send(endpoints.create_folder(account_id, name, parent_id))
106
+
107
+ # --- messages ---------------------------------------------------------------------
108
+
109
+ async def list_messages(
110
+ self,
111
+ account_id: str | None,
112
+ *,
113
+ folder: str | None = None,
114
+ text: str | None = None,
115
+ sender: str | None = None,
116
+ to: str | None = None,
117
+ subject: str | None = None,
118
+ after: str | None = None,
119
+ before: str | None = None,
120
+ unread: bool | None = None,
121
+ starred: bool | None = None,
122
+ has_attachments: bool | None = None,
123
+ limit: int,
124
+ cursor: str | None = None,
125
+ ) -> Page:
126
+ return await self.send(
127
+ endpoints.list_messages(
128
+ account_id,
129
+ folder=folder,
130
+ text=text,
131
+ sender=sender,
132
+ to=to,
133
+ subject=subject,
134
+ after=after,
135
+ before=before,
136
+ unread=unread,
137
+ starred=starred,
138
+ has_attachments=has_attachments,
139
+ limit=limit,
140
+ cursor=cursor,
141
+ )
142
+ )
143
+
144
+ async def list_changes(
145
+ self, account_id: str | None, *, since: str | None, limit: int
146
+ ) -> Changes:
147
+ return await self.send(
148
+ endpoints.list_changes(account_id, since=since, limit=limit)
149
+ )
150
+
151
+ async def get_message(self, account_id: str, message_id: str) -> dict[str, Any]:
152
+ return await self.send(endpoints.get_message(account_id, message_id))
153
+
154
+ async def get_attachment(
155
+ self, account_id: str, message_id: str, attachment_id: str, max_bytes: int
156
+ ) -> Attachment:
157
+ """The attachment's bytes up to ``max_bytes``, with its type, charset
158
+ and file name. Reading stops where the limit is passed."""
159
+ call = endpoints.get_attachment(account_id, message_id, attachment_id)
160
+ collected = Collected(max_bytes)
161
+ try:
162
+ async with self._http.stream(
163
+ call.method, call.path, timeout=call.timeouts
164
+ ) as response:
165
+ if response.is_error:
166
+ await response.aread()
167
+ raise api_error(response)
168
+ async for chunk in response.aiter_bytes():
169
+ if not collected.add(chunk):
170
+ break
171
+ except httpx.TransportError as exc:
172
+ raise failure(exc, self.base_url, call.timeout) from None
173
+ return attachment(response.headers, collected)
174
+
175
+ async def update_messages(
176
+ self,
177
+ account_id: str,
178
+ message_ids: list[str],
179
+ *,
180
+ unread: bool | None = None,
181
+ starred: bool | None = None,
182
+ folder_id: str | None = None,
183
+ ) -> Outcome:
184
+ return await self.send(
185
+ endpoints.update_messages(
186
+ account_id,
187
+ message_ids,
188
+ unread=unread,
189
+ starred=starred,
190
+ folder_id=folder_id,
191
+ )
192
+ )
193
+
194
+ async def delete_message(
195
+ self, account_id: str, message_id: str, *, permanent: bool = False
196
+ ) -> None:
197
+ await self.send(
198
+ endpoints.delete_message(account_id, message_id, permanent=permanent)
199
+ )
200
+
201
+ async def trash_messages(self, account_id: str, message_ids: list[str]) -> Outcome:
202
+ return await self.send(endpoints.trash_messages(account_id, message_ids))
203
+
204
+ # --- drafts and sending -----------------------------------------------------------
205
+
206
+ async def list_drafts(
207
+ self, account_id: str, limit: int, cursor: str | None = None
208
+ ) -> Page:
209
+ return await self.send(endpoints.list_drafts(account_id, limit, cursor))
210
+
211
+ async def create_draft(
212
+ self, account_id: str, message: dict[str, Any]
213
+ ) -> dict[str, Any]:
214
+ return await self.send(endpoints.create_draft(account_id, message))
215
+
216
+ async def update_draft(
217
+ self,
218
+ account_id: str,
219
+ draft_id: str,
220
+ message: dict[str, Any],
221
+ keep_attachments: list[str] | None = None,
222
+ ) -> dict[str, Any]:
223
+ return await self.send(
224
+ endpoints.update_draft(account_id, draft_id, message, keep_attachments)
225
+ )
226
+
227
+ async def delete_draft(self, account_id: str, draft_id: str) -> None:
228
+ await self.send(endpoints.delete_draft(account_id, draft_id))
229
+
230
+ async def send_message(
231
+ self, account_id: str, message: dict[str, Any], idempotency_key: str
232
+ ) -> Sent:
233
+ return await self.send(
234
+ endpoints.send_message(account_id, message, idempotency_key)
235
+ )
236
+
237
+ async def send_draft(
238
+ self, account_id: str, draft_id: str, idempotency_key: str
239
+ ) -> Sent:
240
+ return await self.send(
241
+ endpoints.send_draft(account_id, draft_id, idempotency_key)
242
+ )
@@ -0,0 +1,47 @@
1
+ """Each endpoint of the API the client knows, described once: the
2
+ request it makes and how its answer becomes a record of ``models``, one
3
+ module per resource of the API.
4
+
5
+ A function here sends nothing. It answers a ``Call``, which ``client``
6
+ and ``sync`` send. Nothing above this package spells out a path, a query
7
+ name or a field of the API.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .accounts import me
13
+ from .compose import message_body
14
+ from .drafts import create_draft, delete_draft, list_drafts, update_draft
15
+ from .folders import create_folder, list_folders
16
+ from .generic import request
17
+ from .messages import (
18
+ delete_message,
19
+ get_attachment,
20
+ get_message,
21
+ list_changes,
22
+ list_messages,
23
+ trash_messages,
24
+ update_messages,
25
+ )
26
+ from .sending import send_draft, send_message
27
+
28
+ __all__ = [
29
+ "create_draft",
30
+ "create_folder",
31
+ "delete_draft",
32
+ "delete_message",
33
+ "get_attachment",
34
+ "get_message",
35
+ "list_changes",
36
+ "list_drafts",
37
+ "list_folders",
38
+ "list_messages",
39
+ "me",
40
+ "message_body",
41
+ "request",
42
+ "send_draft",
43
+ "send_message",
44
+ "trash_messages",
45
+ "update_draft",
46
+ "update_messages",
47
+ ]
@@ -0,0 +1,42 @@
1
+ """The caller and its accounts: ``/v1/me``, which every token may ask,
2
+ read into ``Me``."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from ..calls import Call, path
9
+ from ..models import Me, MeAccount, Sending
10
+
11
+
12
+ def me() -> Call[Me]:
13
+ return Call("GET", path("me"), _me)
14
+
15
+
16
+ def _me(found: dict[str, Any]) -> Me:
17
+ return Me(
18
+ accounts=[
19
+ MeAccount(
20
+ id=str(a["id"]),
21
+ email=str(a["email"]),
22
+ display_name=a.get("display_name"),
23
+ operations=frozenset(a.get("operations", [])),
24
+ warnings=frozenset(a.get("warnings", [])),
25
+ sending=tuple(_sending(s) for s in a.get("sending", [])),
26
+ capabilities=(
27
+ frozenset(a["capabilities"]) if "capabilities" in a else None
28
+ ),
29
+ )
30
+ for a in found.get("accounts", [])
31
+ ],
32
+ operations=frozenset(found.get("operations", [])),
33
+ )
34
+
35
+
36
+ def _sending(item: dict[str, Any]) -> Sending:
37
+ recipients = item.get("recipients")
38
+ return Sending(
39
+ recipients=tuple(recipients) if recipients is not None else None,
40
+ max_per_day=item.get("max_sends_per_day"),
41
+ left=item.get("sends_left"),
42
+ )
@@ -0,0 +1,40 @@
1
+ """What drafts and sending share: the body of a message as the API
2
+ takes it, made from recipients, a subject, a text and a reference."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from ..models import Recipient
9
+
10
+
11
+ def message_body(
12
+ *,
13
+ to: list[Recipient],
14
+ cc: list[Recipient],
15
+ bcc: list[Recipient],
16
+ subject: str,
17
+ text: str,
18
+ html: str | None,
19
+ reference: tuple[str, str] | None,
20
+ ) -> dict[str, Any]:
21
+ """The body of a draft or a message to send, as the API takes it.
22
+ ``reference``: the id of the message answered or forwarded, and the
23
+ action (reply, reply_all, forward)."""
24
+ body: dict[str, Any] = {
25
+ "to": [_recipient(r) for r in to],
26
+ "cc": [_recipient(r) for r in cc],
27
+ "bcc": [_recipient(r) for r in bcc],
28
+ "subject": subject,
29
+ "text": text,
30
+ }
31
+ if html is not None:
32
+ body["html"] = html
33
+ if reference is not None:
34
+ body["reference"] = {"message_id": reference[0], "action": reference[1]}
35
+ return body
36
+
37
+
38
+ def _recipient(recipient: Recipient) -> dict[str, str]:
39
+ email, name = recipient
40
+ return {"email": email, "name": name} if name else {"email": email}