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.
- benethos_mailbox_client/__init__.py +62 -0
- benethos_mailbox_client/answers.py +85 -0
- benethos_mailbox_client/attachments.py +71 -0
- benethos_mailbox_client/calls.py +64 -0
- benethos_mailbox_client/client.py +242 -0
- benethos_mailbox_client/endpoints/__init__.py +47 -0
- benethos_mailbox_client/endpoints/accounts.py +42 -0
- benethos_mailbox_client/endpoints/compose.py +40 -0
- benethos_mailbox_client/endpoints/drafts.py +48 -0
- benethos_mailbox_client/endpoints/folders.py +37 -0
- benethos_mailbox_client/endpoints/generic.py +28 -0
- benethos_mailbox_client/endpoints/messages.py +169 -0
- benethos_mailbox_client/endpoints/sending.py +37 -0
- benethos_mailbox_client/environment.py +104 -0
- benethos_mailbox_client/errors.py +30 -0
- benethos_mailbox_client/models.py +100 -0
- benethos_mailbox_client/py.typed +0 -0
- benethos_mailbox_client/sync.py +232 -0
- benethos_mailbox_client-0.3.0.dist-info/METADATA +143 -0
- benethos_mailbox_client-0.3.0.dist-info/RECORD +22 -0
- benethos_mailbox_client-0.3.0.dist-info/WHEEL +4 -0
- benethos_mailbox_client-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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}
|