aronline-sdk 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.
Files changed (42) hide show
  1. aronline/__init__.py +119 -0
  2. aronline/client.py +75 -0
  3. aronline/errors.py +60 -0
  4. aronline/http/__init__.py +5 -0
  5. aronline/http/error_envelope.py +61 -0
  6. aronline/http/json_body.py +30 -0
  7. aronline/http/transport.py +158 -0
  8. aronline/legacy/__init__.py +32 -0
  9. aronline/legacy/area.py +127 -0
  10. aronline/legacy/errors.py +53 -0
  11. aronline/legacy/models/__init__.py +91 -0
  12. aronline/legacy/models/envio.py +127 -0
  13. aronline/legacy/models/full.py +64 -0
  14. aronline/legacy/models/gw_template.py +65 -0
  15. aronline/legacy/models/regua.py +13 -0
  16. aronline/legacy/models/sending_proof.py +27 -0
  17. aronline/legacy/models/status.py +149 -0
  18. aronline/legacy/models/webhook.py +77 -0
  19. aronline/legacy/resources/__init__.py +7 -0
  20. aronline/legacy/resources/base.py +14 -0
  21. aronline/legacy/resources/status.py +68 -0
  22. aronline/legacy/resources/templates.py +79 -0
  23. aronline/legacy/transport.py +229 -0
  24. aronline/models/__init__.py +25 -0
  25. aronline/models/allowlist_entry.py +15 -0
  26. aronline/models/channel.py +17 -0
  27. aronline/models/freshness.py +25 -0
  28. aronline/models/tag.py +16 -0
  29. aronline/models/template.py +35 -0
  30. aronline/models/version.py +15 -0
  31. aronline/py.typed +0 -0
  32. aronline/resources/__init__.py +17 -0
  33. aronline/resources/allowlist.py +24 -0
  34. aronline/resources/base.py +14 -0
  35. aronline/resources/freshness.py +24 -0
  36. aronline/resources/tags.py +29 -0
  37. aronline/resources/templates.py +37 -0
  38. aronline/resources/version.py +23 -0
  39. aronline_sdk-0.3.0.dist-info/METADATA +430 -0
  40. aronline_sdk-0.3.0.dist-info/RECORD +42 -0
  41. aronline_sdk-0.3.0.dist-info/WHEEL +4 -0
  42. aronline_sdk-0.3.0.dist-info/licenses/LICENSE +201 -0
aronline/__init__.py ADDED
@@ -0,0 +1,119 @@
1
+ """Official SDK for the AR Online API.
2
+
3
+ The SDK speaks two surfaces. The /v3 resources at the top of ``Client`` are the
4
+ clean contract. ``client.legacy`` speaks the old gateway exactly as its public
5
+ documentation describes it -- idiosyncrasies included -- because an integration
6
+ written against the old API needs typed calls today, and normalizing the old
7
+ contract would break the callers the area exists to keep working.
8
+
9
+ The /v1 and /v2 mirrors are not covered: they answer the old contracts byte for
10
+ byte, and a typed client that "improved" them would break the same callers.
11
+ """
12
+
13
+ from aronline.client import Client
14
+ from aronline.errors import ApiError, ErrorDetail
15
+ from aronline.http.transport import DEFAULT_BASE_URL, DEFAULT_TIMEOUT
16
+ from aronline.legacy.area import LegacyArea
17
+ from aronline.legacy.errors import LegacyApiError
18
+ from aronline.legacy.models import (
19
+ GW_TEMPLATE_TYPES,
20
+ SMS_TYPE_SENDS,
21
+ Anexo,
22
+ CanalCarta,
23
+ CanalSms,
24
+ CanalVoz,
25
+ CanalWhatsapp,
26
+ EnvioRequest,
27
+ EnvioResponse,
28
+ EnvioValidation,
29
+ FinalizarReguaResult,
30
+ FullChannelDetail,
31
+ GwTemplate,
32
+ GwTemplateType,
33
+ GwTemplateWriteResult,
34
+ SendingProof,
35
+ SmsAnswer,
36
+ SmsTypeSend,
37
+ StatusCarta,
38
+ StatusEmail,
39
+ StatusEvent,
40
+ StatusFull,
41
+ StatusHistory,
42
+ StatusLast,
43
+ StatusSms,
44
+ StatusVoz,
45
+ StatusWhatsapp,
46
+ UpdateGwTemplate,
47
+ WebhookChannel,
48
+ WebhookMetadata,
49
+ WebhookPayloadV1,
50
+ WebhookPayloadV2,
51
+ WebhookStatusPayload,
52
+ )
53
+ from aronline.legacy.transport import DEFAULT_LEGACY_BASE_URL
54
+ from aronline.models import (
55
+ CHANNELS,
56
+ AllowlistEntry,
57
+ Channel,
58
+ Freshness,
59
+ Tag,
60
+ Template,
61
+ TemplateVariable,
62
+ Version,
63
+ )
64
+
65
+ #: This package's version -- the same string ``pyproject.toml`` carries.
66
+ VERSION = "0.3.0"
67
+
68
+ __all__ = [
69
+ "CHANNELS",
70
+ "DEFAULT_BASE_URL",
71
+ "DEFAULT_LEGACY_BASE_URL",
72
+ "DEFAULT_TIMEOUT",
73
+ "GW_TEMPLATE_TYPES",
74
+ "SMS_TYPE_SENDS",
75
+ "VERSION",
76
+ "AllowlistEntry",
77
+ "Anexo",
78
+ "ApiError",
79
+ "CanalCarta",
80
+ "CanalSms",
81
+ "CanalVoz",
82
+ "CanalWhatsapp",
83
+ "Channel",
84
+ "Client",
85
+ "EnvioRequest",
86
+ "EnvioResponse",
87
+ "EnvioValidation",
88
+ "ErrorDetail",
89
+ "FinalizarReguaResult",
90
+ "Freshness",
91
+ "FullChannelDetail",
92
+ "GwTemplate",
93
+ "GwTemplateType",
94
+ "GwTemplateWriteResult",
95
+ "LegacyApiError",
96
+ "LegacyArea",
97
+ "SendingProof",
98
+ "SmsAnswer",
99
+ "SmsTypeSend",
100
+ "StatusCarta",
101
+ "StatusEmail",
102
+ "StatusEvent",
103
+ "StatusFull",
104
+ "StatusHistory",
105
+ "StatusLast",
106
+ "StatusSms",
107
+ "StatusVoz",
108
+ "StatusWhatsapp",
109
+ "Tag",
110
+ "Template",
111
+ "TemplateVariable",
112
+ "UpdateGwTemplate",
113
+ "Version",
114
+ "WebhookChannel",
115
+ "WebhookMetadata",
116
+ "WebhookPayloadV1",
117
+ "WebhookPayloadV2",
118
+ "WebhookStatusPayload",
119
+ ]
aronline/client.py ADDED
@@ -0,0 +1,75 @@
1
+ """The client -- the one thing you construct."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import urllib.request
6
+
7
+ from aronline.http.transport import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, Transport
8
+ from aronline.legacy.area import LegacyArea
9
+ from aronline.legacy.transport import DEFAULT_LEGACY_BASE_URL, LegacyTransport
10
+ from aronline.resources.allowlist import AllowlistResource
11
+ from aronline.resources.freshness import FreshnessResource
12
+ from aronline.resources.tags import TagsResource
13
+ from aronline.resources.templates import TemplatesResource
14
+ from aronline.resources.version import VersionResource
15
+
16
+ __all__ = ["Client"]
17
+
18
+
19
+ class Client:
20
+ """The AR Online API client.
21
+
22
+ ::
23
+
24
+ from aronline import Client
25
+
26
+ client = Client(token=os.environ["AR_TOKEN"])
27
+ templates = client.templates.list(channel="whatsapp")
28
+
29
+ It owns the transport and hands it to each resource; the resources are the
30
+ public surface. Nothing above this line knows that HTTP is involved.
31
+
32
+ ``client.legacy`` is the second surface: the old gateway's contract, spoken
33
+ exactly as documented, with its own address and its own credential
34
+ (``legacy_token``). It exists so an integration on the old API gets typed
35
+ calls today and migrates to /v3 by SDK update, not by rewrite.
36
+
37
+ Each credential is optional: pass only the one for the surface you use.
38
+ Neither leaks into the other's calls.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ token: str | None = None,
45
+ legacy_token: str | None = None,
46
+ base_url: str = DEFAULT_BASE_URL,
47
+ legacy_base_url: str = DEFAULT_LEGACY_BASE_URL,
48
+ timeout: float = DEFAULT_TIMEOUT,
49
+ opener: urllib.request.OpenerDirector | None = None,
50
+ ) -> None:
51
+ transport = Transport(
52
+ token=token,
53
+ base_url=base_url,
54
+ timeout=timeout,
55
+ opener=opener,
56
+ )
57
+ legacy_transport = LegacyTransport(
58
+ token=legacy_token,
59
+ base_url=legacy_base_url,
60
+ timeout=timeout,
61
+ opener=opener,
62
+ )
63
+
64
+ #: Message templates.
65
+ self.templates = TemplatesResource(transport)
66
+ #: Your labels.
67
+ self.tags = TagsResource(transport)
68
+ #: Your allowed recipients.
69
+ self.allowlist = AllowlistResource(transport)
70
+ #: How fresh the copy of the data is.
71
+ self.freshness = FreshnessResource(transport)
72
+ #: Which version is running. The one route that needs no token.
73
+ self.version = VersionResource(transport)
74
+ #: The legacy gateway surface -- today's contract, idiosyncrasies included.
75
+ self.legacy = LegacyArea(legacy_transport)
aronline/errors.py ADDED
@@ -0,0 +1,60 @@
1
+ """What a refusal from the API looks like on this side."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypedDict
6
+
7
+ __all__ = ["ApiError", "ErrorDetail"]
8
+
9
+
10
+ class ErrorDetail(TypedDict):
11
+ """One field-level complaint inside a validation error."""
12
+
13
+ field: str
14
+ code: str
15
+ message: str
16
+
17
+
18
+ class ApiError(Exception):
19
+ """A refusal from the API, raised so a failed call cannot pass for a good one.
20
+
21
+ ``request_id`` is a first-class attribute on purpose: it is the first thing
22
+ support asks for, and an SDK that swallowed it would force whoever hit the
23
+ failure to reproduce it with curl just to find the number.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ status: int,
30
+ code: str,
31
+ message: str,
32
+ request_id: str | None,
33
+ field: str | None = None,
34
+ details: list[ErrorDetail] | None = None,
35
+ retry_after_seconds: float | None = None,
36
+ ) -> None:
37
+ super().__init__(message)
38
+
39
+ #: The HTTP status. Zero when the API was never reached.
40
+ self.status = status
41
+ #: The catalog code -- ``not_found``, ``forbidden``, ``rate_limited``, ...
42
+ self.code = code
43
+ #: The message the API sent, in pt-BR.
44
+ self.message = message
45
+ #: From the error body, or from the ``X-Request-Id`` header when absent.
46
+ self.request_id = request_id
47
+ #: The field at fault, when the refusal is about one.
48
+ self.field = field
49
+ #: One entry per rejected field, on validation errors.
50
+ self.details = details
51
+ #: From the ``Retry-After`` header, on 429 and 503.
52
+ self.retry_after_seconds = retry_after_seconds
53
+
54
+ @property
55
+ def retryable(self) -> bool:
56
+ """True for the statuses the API says are worth trying again."""
57
+ return self.status in (429, 503)
58
+
59
+ def __repr__(self) -> str:
60
+ return f"ApiError(status={self.status}, code={self.code!r}, request_id={self.request_id!r})"
@@ -0,0 +1,5 @@
1
+ """The only part of the SDK that knows HTTP exists."""
2
+
3
+ from aronline.http.transport import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, Transport
4
+
5
+ __all__ = ["DEFAULT_BASE_URL", "DEFAULT_TIMEOUT", "Transport"]
@@ -0,0 +1,61 @@
1
+ """Turning a refused response into an ``ApiError``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from aronline.errors import ApiError
8
+
9
+ __all__ = ["to_api_error"]
10
+
11
+
12
+ def to_api_error(
13
+ status: int,
14
+ body: Any,
15
+ request_id: str | None,
16
+ retry_after: str | None,
17
+ ) -> ApiError:
18
+ """Build the error a refusal becomes.
19
+
20
+ A refusal that does not carry the envelope is still a refusal: a proxy
21
+ answering 502 in HTML has to fail the same way, or whoever hit it goes
22
+ looking for a bug in their own parsing code.
23
+ """
24
+ envelope = _read_envelope(body)
25
+
26
+ return ApiError(
27
+ status=status,
28
+ code=envelope.get("code") or "invalid_response",
29
+ message=envelope.get("message") or f"a API respondeu {status} sem o corpo de erro esperado",
30
+ request_id=envelope.get("request_id") or request_id,
31
+ field=envelope.get("field"),
32
+ details=envelope.get("details"),
33
+ retry_after_seconds=_read_retry_after(retry_after),
34
+ )
35
+
36
+
37
+ def _read_envelope(body: Any) -> dict[str, Any]:
38
+ if not isinstance(body, dict):
39
+ return {}
40
+
41
+ error = body.get("error")
42
+
43
+ return error if isinstance(error, dict) else {}
44
+
45
+
46
+ def _read_retry_after(header: str | None) -> float | None:
47
+ """Seconds, or nothing.
48
+
49
+ ``Retry-After`` also has an HTTP-date form. The API only ever sends
50
+ seconds, so anything else is dropped rather than guessed at -- a wrong
51
+ delay is worse than no delay.
52
+ """
53
+ if header is None:
54
+ return None
55
+
56
+ try:
57
+ seconds = float(header)
58
+ except ValueError:
59
+ return None
60
+
61
+ return seconds if seconds >= 0 else None
@@ -0,0 +1,30 @@
1
+ """Reading a response body that claims to be JSON.
2
+
3
+ Both transports need the same distinction: "the body was not JSON" is not the
4
+ same thing as "the body was ``null``". A proxy answering HTML has to fail like
5
+ any other refusal, while a route that legitimately answers ``null`` has to keep
6
+ answering it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any
13
+
14
+ __all__ = ["UNPARSED", "parse_json"]
15
+
16
+
17
+ class _Unparsed:
18
+ """Tells "the body was not JSON" apart from "the body was ``null``"."""
19
+
20
+
21
+ #: What :func:`parse_json` returns when the text is not JSON at all.
22
+ UNPARSED = _Unparsed()
23
+
24
+
25
+ def parse_json(text: str) -> Any:
26
+ """The parsed body, or :data:`UNPARSED` when the text is not JSON."""
27
+ try:
28
+ return json.loads(text)
29
+ except ValueError:
30
+ return UNPARSED
@@ -0,0 +1,158 @@
1
+ """The HTTP layer, and the only place in the SDK that knows it exists."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+ from aronline.errors import ApiError
11
+ from aronline.http.error_envelope import to_api_error
12
+ from aronline.http.json_body import UNPARSED, parse_json
13
+
14
+ __all__ = ["DEFAULT_BASE_URL", "DEFAULT_TIMEOUT", "Transport"]
15
+
16
+ #: Where /v3 lives. Override it for staging or for a local process.
17
+ DEFAULT_BASE_URL = "https://v3.ar-online.com.br"
18
+
19
+ #: How long a call waits before giving up, in seconds.
20
+ DEFAULT_TIMEOUT = 30.0
21
+
22
+
23
+ class Transport:
24
+ """Builds the request, reads the answer, and raises what went wrong.
25
+
26
+ Everything above it -- the resources, the client -- deals in functions and
27
+ dictionaries. That is the whole point of the package: whoever installs it
28
+ should never build a URL, set a header, read a status or unwrap an
29
+ envelope.
30
+
31
+ It uses ``urllib`` from the standard library on purpose: an SDK with no
32
+ dependencies is one that never conflicts with what the application already
33
+ pinned.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ token: str | None = None,
40
+ base_url: str = DEFAULT_BASE_URL,
41
+ timeout: float = DEFAULT_TIMEOUT,
42
+ opener: urllib.request.OpenerDirector | None = None,
43
+ ) -> None:
44
+ self._base_url = base_url.rstrip("/")
45
+ self._token = token
46
+ self._timeout = timeout
47
+ self._opener = opener or urllib.request.build_opener()
48
+
49
+ def envelope(
50
+ self,
51
+ path: str,
52
+ *,
53
+ query: dict[str, str | None] | None = None,
54
+ authenticated: bool = True,
55
+ ) -> Any:
56
+ """For the routes that answer ``{"data": ...}`` -- templates, tags, allowlist.
57
+
58
+ Not every route wraps its answer, which is the one trap of this API:
59
+ freshness and version answer the object itself. Unwrapping all of them,
60
+ or none, breaks half the calls, so the choice is made per route by
61
+ whichever method the resource calls.
62
+ """
63
+ body = self.bare(path, query=query, authenticated=authenticated)
64
+
65
+ if not isinstance(body, dict) or "data" not in body:
66
+ raise ApiError(
67
+ status=200,
68
+ code="invalid_response",
69
+ message=f"{path} respondeu sem o envelope 'data' que a rota promete",
70
+ request_id=None,
71
+ )
72
+
73
+ return body["data"]
74
+
75
+ def bare(
76
+ self,
77
+ path: str,
78
+ *,
79
+ query: dict[str, str | None] | None = None,
80
+ authenticated: bool = True,
81
+ ) -> Any:
82
+ """For the routes that answer the object directly -- freshness, version."""
83
+ # Refused here, before the socket: a 401 round trip teaches nothing
84
+ # that the missing token does not already say.
85
+ if authenticated and self._token is None:
86
+ raise ApiError(
87
+ status=401,
88
+ code="unauthenticated",
89
+ message=f"{path} exige um token; construa o cliente com token=...",
90
+ request_id=None,
91
+ )
92
+
93
+ request = urllib.request.Request(
94
+ self.url(path, query),
95
+ method="GET",
96
+ headers=self._headers(authenticated),
97
+ )
98
+
99
+ status, text, headers = self._send(request)
100
+ body = parse_json(text)
101
+ request_id = headers.get("X-Request-Id")
102
+
103
+ if status >= 400:
104
+ raise to_api_error(status, body, request_id, headers.get("Retry-After"))
105
+
106
+ # A 200 that is not JSON is something other than the API answering. It
107
+ # fails like any other refusal -- a raw JSONDecodeError leaking out
108
+ # here would send whoever hit it looking for a bug in their own code.
109
+ if body is UNPARSED:
110
+ raise ApiError(
111
+ status=status,
112
+ code="invalid_response",
113
+ message="a resposta não é JSON — algo respondeu no lugar da API",
114
+ request_id=request_id,
115
+ )
116
+
117
+ return body
118
+
119
+ def url(self, path: str, query: dict[str, str | None] | None = None) -> str:
120
+ """The absolute URL of a path. Exposed because the tests assert on it."""
121
+ filled = {key: value for key, value in (query or {}).items() if value is not None}
122
+ suffix = f"?{urllib.parse.urlencode(filled)}" if filled else ""
123
+
124
+ return f"{self._base_url}{path}{suffix}"
125
+
126
+ def _headers(self, authenticated: bool) -> dict[str, str]:
127
+ headers = {"Accept": "application/json"}
128
+
129
+ if authenticated and self._token is not None:
130
+ headers["Authorization"] = f"Bearer {self._token}"
131
+
132
+ return headers
133
+
134
+ def _send(self, request: urllib.request.Request) -> tuple[int, str, Any]:
135
+ try:
136
+ with self._opener.open(request, timeout=self._timeout) as response:
137
+ return response.status, _read(response), response.headers
138
+ except urllib.error.HTTPError as error:
139
+ # urllib raises on 4xx and 5xx. They are answers, not failures:
140
+ # the body carries the catalog, and the caller needs it.
141
+ with error:
142
+ return error.code, _read(error), error.headers
143
+ except (urllib.error.URLError, OSError) as error:
144
+ # Timeout and connection refused arrive as opaque platform errors.
145
+ # They become the SDK's own error so a caller has one type to catch
146
+ # instead of three.
147
+ raise ApiError(
148
+ status=0,
149
+ code="unreachable",
150
+ message=f"não foi possível falar com {self._base_url}: {error}",
151
+ request_id=None,
152
+ ) from error
153
+
154
+
155
+ def _read(response: Any) -> str:
156
+ payload: bytes = response.read()
157
+
158
+ return payload.decode("utf-8", errors="replace")
@@ -0,0 +1,32 @@
1
+ """The legacy gateway surface -- today's contract, idiosyncrasies included.
2
+
3
+ The /v3 resources at the top of the package are the clean contract. This area
4
+ speaks the old gateway exactly as its public documentation describes it, so an
5
+ integration written against the old API gets typed calls today; when a /v3
6
+ equivalent lands, the legacy function swaps transport without changing its
7
+ signature.
8
+
9
+ The names here follow the **legacy vocabulary** -- ``laudo``, ``regua``,
10
+ ``voz``, ``carta``, ``EnvioRequest``. It is a deliberate exception to the
11
+ project's English rule: translating them would create a vocabulary that exists
12
+ in no documentation anywhere. Only the case convention is adapted to Python.
13
+ """
14
+
15
+ from aronline.legacy.area import LegacyArea
16
+ from aronline.legacy.errors import LegacyApiError
17
+ from aronline.legacy.resources import (
18
+ LegacyResource,
19
+ LegacyStatusResource,
20
+ LegacyTemplatesResource,
21
+ )
22
+ from aronline.legacy.transport import DEFAULT_LEGACY_BASE_URL, LegacyTransport
23
+
24
+ __all__ = [
25
+ "DEFAULT_LEGACY_BASE_URL",
26
+ "LegacyApiError",
27
+ "LegacyArea",
28
+ "LegacyResource",
29
+ "LegacyStatusResource",
30
+ "LegacyTemplatesResource",
31
+ "LegacyTransport",
32
+ ]
@@ -0,0 +1,127 @@
1
+ """The legacy gateway, as functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import urllib.parse
8
+ from typing import Any, cast
9
+
10
+ from aronline.legacy.errors import LegacyApiError
11
+ from aronline.legacy.models.envio import EnvioRequest, EnvioResponse
12
+ from aronline.legacy.models.regua import FinalizarReguaResult
13
+ from aronline.legacy.models.sending_proof import SendingProof
14
+ from aronline.legacy.resources.status import LegacyStatusResource
15
+ from aronline.legacy.resources.templates import LegacyTemplatesResource
16
+ from aronline.legacy.transport import LegacyTransport
17
+
18
+ __all__ = ["LegacyArea"]
19
+
20
+
21
+ class LegacyArea:
22
+ """Everything docs.ar-online.com.br documents of the gateway, spoken exactly
23
+ as the old API speaks it.
24
+
25
+ ::
26
+
27
+ from aronline import Client
28
+
29
+ client = Client(legacy_token=os.environ["AR_GW_TOKEN"])
30
+ sent = client.legacy.send({"nameTo": "João", "subject": "…", "content": "…"})
31
+ status = client.legacy.status.email(sent["idEmail"])
32
+
33
+ This area exists so an integration written against the old contract gets
34
+ typed calls today. As /v3 grows an equivalent for a route, the function here
35
+ swaps its transport without changing shape -- the migration happens under
36
+ your feet, not in your code. Each function's documentation names its /v3
37
+ equivalent when one exists.
38
+ """
39
+
40
+ def __init__(self, transport: LegacyTransport) -> None:
41
+ self._transport = transport
42
+
43
+ #: Per-channel status and the consolidated forensic view.
44
+ self.status = LegacyStatusResource(transport)
45
+ #: The gateway's template routes. The /v3 equivalent for reads is
46
+ #: ``client.templates``.
47
+ self.templates = LegacyTemplatesResource(transport)
48
+
49
+ def send(self, envio: EnvioRequest) -> EnvioResponse:
50
+ """Sends a notification -- ``POST /gw/email``, the multichannel route
51
+ despite the name.
52
+
53
+ Processing is asynchronous: keep the returned ``idEmail``, it is the
54
+ handle for every status and proof question later.
55
+
56
+ No /v3 equivalent yet.
57
+ """
58
+ return cast("EnvioResponse", self._transport.json("POST", "/gw/email", body=envio))
59
+
60
+ def sending_proof(self, id_email: str) -> SendingProof:
61
+ """The sending proof as a PDF.
62
+
63
+ The wire carries it in base64 inside JSON; this decodes it for you and
64
+ keeps the raw string reachable. While the e-mail has no delivery status
65
+ the gateway answers a message instead, and ``pdf`` comes back ``None``
66
+ -- ask again later. That is an answer, not a refusal.
67
+
68
+ No /v3 equivalent yet.
69
+ """
70
+ body: Any = self._transport.json("GET", f"/gw/sending-proof/{_quote(id_email)}")
71
+ content = body.get("content") if isinstance(body, dict) else None
72
+
73
+ if not isinstance(content, str):
74
+ message = body.get("message") if isinstance(body, dict) else None
75
+
76
+ return {
77
+ "pdf": None,
78
+ "content_base64": None,
79
+ "message": message if isinstance(message, str) else None,
80
+ }
81
+
82
+ return {"pdf": _decode_base64(content), "content_base64": content, "message": None}
83
+
84
+ def laudo(self, id_email: str) -> bytes:
85
+ """The expert-evidence report -- the one route that answers the PDF
86
+ binary directly, no base64, no JSON.
87
+
88
+ No /v3 equivalent yet.
89
+ """
90
+ return self._transport.binary(f"/gw/email/laudo/{_quote(id_email)}")
91
+
92
+ def finalizar_regua(self, id_email: str) -> FinalizarReguaResult:
93
+ """Stops the notification ladder for this send.
94
+
95
+ A GET with a side effect -- that is the old contract, and the SDK does
96
+ not "fix" it to POST. A caller who saw ``GET`` and assumed it was safe
97
+ to repeat would be repeating a write.
98
+
99
+ No /v3 equivalent yet.
100
+ """
101
+ return cast(
102
+ "FinalizarReguaResult",
103
+ self._transport.json("GET", f"/regua-notificacao/finalizar/{_quote(id_email)}"),
104
+ )
105
+
106
+
107
+ def _quote(id_email: str) -> str:
108
+ """Escaped, so a crooked id cannot become another path."""
109
+ return urllib.parse.quote(id_email, safe="")
110
+
111
+
112
+ def _decode_base64(content: str) -> bytes:
113
+ """The proof, decoded, or the SDK's own error.
114
+
115
+ ``validate=True`` on purpose: without it, base64 that is not base64 decodes
116
+ to garbage bytes, and the caller writes a corrupt PDF to disk instead of
117
+ finding out that the gateway answered something unexpected.
118
+ """
119
+ try:
120
+ return base64.b64decode(content, validate=True)
121
+ except (binascii.Error, ValueError) as error:
122
+ raise LegacyApiError(
123
+ status=200,
124
+ http_status=200,
125
+ message="o comprovante veio com base64 ilegível",
126
+ body={"content": content},
127
+ ) from error