agentletter 0.1.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.
- agentletter-0.1.0/.gitignore +41 -0
- agentletter-0.1.0/LICENSE +21 -0
- agentletter-0.1.0/PKG-INFO +132 -0
- agentletter-0.1.0/README.md +104 -0
- agentletter-0.1.0/pyproject.toml +40 -0
- agentletter-0.1.0/src/agentletter/__init__.py +41 -0
- agentletter-0.1.0/src/agentletter/_version.py +1 -0
- agentletter-0.1.0/src/agentletter/client.py +262 -0
- agentletter-0.1.0/src/agentletter/errors.py +67 -0
- agentletter-0.1.0/src/agentletter/py.typed +0 -0
- agentletter-0.1.0/src/agentletter/types.py +59 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
/node_modules
|
|
5
|
+
/.pnp
|
|
6
|
+
.pnp.*
|
|
7
|
+
.yarn/*
|
|
8
|
+
!.yarn/patches
|
|
9
|
+
!.yarn/plugins
|
|
10
|
+
!.yarn/releases
|
|
11
|
+
!.yarn/versions
|
|
12
|
+
|
|
13
|
+
# testing
|
|
14
|
+
/coverage
|
|
15
|
+
|
|
16
|
+
# next.js
|
|
17
|
+
/.next/
|
|
18
|
+
/out/
|
|
19
|
+
|
|
20
|
+
# production
|
|
21
|
+
/build
|
|
22
|
+
|
|
23
|
+
# misc
|
|
24
|
+
.DS_Store
|
|
25
|
+
*.pem
|
|
26
|
+
|
|
27
|
+
# debug
|
|
28
|
+
npm-debug.log*
|
|
29
|
+
yarn-debug.log*
|
|
30
|
+
yarn-error.log*
|
|
31
|
+
.pnpm-debug.log*
|
|
32
|
+
|
|
33
|
+
# env files (can opt-in for committing if needed)
|
|
34
|
+
.env*
|
|
35
|
+
|
|
36
|
+
# vercel
|
|
37
|
+
.vercel
|
|
38
|
+
|
|
39
|
+
# typescript
|
|
40
|
+
*.tsbuildinfo
|
|
41
|
+
next-env.d.ts
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agent Letter
|
|
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,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentletter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The physical-mail API for AI agents. Send a real, tracked, compliant letter with one call.
|
|
5
|
+
Project-URL: Homepage, https://agentletter.dev
|
|
6
|
+
Project-URL: Documentation, https://agentletter.dev
|
|
7
|
+
Project-URL: Source, https://github.com/noetiq/agentletter
|
|
8
|
+
Author-email: Falco Schneider <falco@noetiq.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,ai,api,direct-mail,letters,mail,mcp,usps
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Communications
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Requires-Dist: httpx<1,>=0.23
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Agent Letter — Python SDK
|
|
30
|
+
|
|
31
|
+
The physical-mail API for AI agents. Send a real, tracked, compliant letter with one call — the postal equivalent of giving an agent an email address.
|
|
32
|
+
|
|
33
|
+
> Private beta. Request access at [agentletter.dev](https://agentletter.dev).
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install agentletter
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from agentletter import AgentLetter
|
|
45
|
+
|
|
46
|
+
client = AgentLetter() # reads AGENTLETTER_API_KEY from the environment
|
|
47
|
+
|
|
48
|
+
letter = client.send(
|
|
49
|
+
to={
|
|
50
|
+
"name": "Jane Doe",
|
|
51
|
+
"line1": "1 Market St",
|
|
52
|
+
"city": "San Francisco",
|
|
53
|
+
"state": "CA",
|
|
54
|
+
"zip": "94105",
|
|
55
|
+
},
|
|
56
|
+
body=pdf_bytes, # PDF bytes — or html="..." / template="..."
|
|
57
|
+
certified=True, # proof of delivery
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
print(letter.id, letter.status) # "ltr_9f2a" "in_transit"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Set your key once:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
export AGENTLETTER_API_KEY="sk_live_..."
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Sending content
|
|
70
|
+
|
|
71
|
+
Provide exactly one of:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
client.send(to=addr, body=pdf_bytes) # a PDF
|
|
75
|
+
client.send(to=addr, html="<h1>Notice</h1>...") # rendered HTML
|
|
76
|
+
client.send(to=addr, template="tmpl_123",
|
|
77
|
+
variables={"name": "Jane", "amount": "$420"}) # a saved template
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Typed addresses are supported too:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from agentletter import Address
|
|
84
|
+
|
|
85
|
+
client.send(
|
|
86
|
+
to=Address(name="Jane Doe", line1="1 Market St",
|
|
87
|
+
city="San Francisco", state="CA", zip="94105"),
|
|
88
|
+
body=pdf_bytes,
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Tracking
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
letter = client.get("ltr_9f2a")
|
|
96
|
+
print(letter.status, letter.tracking_number, letter.expected_delivery)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Async
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import asyncio
|
|
103
|
+
from agentletter import AsyncAgentLetter
|
|
104
|
+
|
|
105
|
+
async def main():
|
|
106
|
+
async with AsyncAgentLetter() as client:
|
|
107
|
+
letter = await client.send(to=addr, body=pdf_bytes, certified=True)
|
|
108
|
+
print(letter.id)
|
|
109
|
+
|
|
110
|
+
asyncio.run(main())
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Errors
|
|
114
|
+
|
|
115
|
+
All errors subclass `AgentLetterError`:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from agentletter import AgentLetterError, AuthenticationError, RateLimitError
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
client.send(to=addr, body=pdf_bytes)
|
|
122
|
+
except AuthenticationError:
|
|
123
|
+
... # bad / missing key
|
|
124
|
+
except RateLimitError:
|
|
125
|
+
... # back off and retry
|
|
126
|
+
except AgentLetterError as e:
|
|
127
|
+
print(e.status_code, e.message)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Agent Letter — Python SDK
|
|
2
|
+
|
|
3
|
+
The physical-mail API for AI agents. Send a real, tracked, compliant letter with one call — the postal equivalent of giving an agent an email address.
|
|
4
|
+
|
|
5
|
+
> Private beta. Request access at [agentletter.dev](https://agentletter.dev).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install agentletter
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from agentletter import AgentLetter
|
|
17
|
+
|
|
18
|
+
client = AgentLetter() # reads AGENTLETTER_API_KEY from the environment
|
|
19
|
+
|
|
20
|
+
letter = client.send(
|
|
21
|
+
to={
|
|
22
|
+
"name": "Jane Doe",
|
|
23
|
+
"line1": "1 Market St",
|
|
24
|
+
"city": "San Francisco",
|
|
25
|
+
"state": "CA",
|
|
26
|
+
"zip": "94105",
|
|
27
|
+
},
|
|
28
|
+
body=pdf_bytes, # PDF bytes — or html="..." / template="..."
|
|
29
|
+
certified=True, # proof of delivery
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
print(letter.id, letter.status) # "ltr_9f2a" "in_transit"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Set your key once:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export AGENTLETTER_API_KEY="sk_live_..."
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Sending content
|
|
42
|
+
|
|
43
|
+
Provide exactly one of:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
client.send(to=addr, body=pdf_bytes) # a PDF
|
|
47
|
+
client.send(to=addr, html="<h1>Notice</h1>...") # rendered HTML
|
|
48
|
+
client.send(to=addr, template="tmpl_123",
|
|
49
|
+
variables={"name": "Jane", "amount": "$420"}) # a saved template
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Typed addresses are supported too:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from agentletter import Address
|
|
56
|
+
|
|
57
|
+
client.send(
|
|
58
|
+
to=Address(name="Jane Doe", line1="1 Market St",
|
|
59
|
+
city="San Francisco", state="CA", zip="94105"),
|
|
60
|
+
body=pdf_bytes,
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Tracking
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
letter = client.get("ltr_9f2a")
|
|
68
|
+
print(letter.status, letter.tracking_number, letter.expected_delivery)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Async
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import asyncio
|
|
75
|
+
from agentletter import AsyncAgentLetter
|
|
76
|
+
|
|
77
|
+
async def main():
|
|
78
|
+
async with AsyncAgentLetter() as client:
|
|
79
|
+
letter = await client.send(to=addr, body=pdf_bytes, certified=True)
|
|
80
|
+
print(letter.id)
|
|
81
|
+
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Errors
|
|
86
|
+
|
|
87
|
+
All errors subclass `AgentLetterError`:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from agentletter import AgentLetterError, AuthenticationError, RateLimitError
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
client.send(to=addr, body=pdf_bytes)
|
|
94
|
+
except AuthenticationError:
|
|
95
|
+
... # bad / missing key
|
|
96
|
+
except RateLimitError:
|
|
97
|
+
... # back off and retry
|
|
98
|
+
except AgentLetterError as e:
|
|
99
|
+
print(e.status_code, e.message)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentletter"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The physical-mail API for AI agents. Send a real, tracked, compliant letter with one call."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Falco Schneider", email = "falco@noetiq.com" }]
|
|
13
|
+
keywords = ["ai", "agents", "mail", "direct-mail", "letters", "api", "mcp", "usps"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Topic :: Communications",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = ["httpx>=0.23,<1"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://agentletter.dev"
|
|
33
|
+
Documentation = "https://agentletter.dev"
|
|
34
|
+
Source = "https://github.com/noetiq/agentletter"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/agentletter"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["src/agentletter", "README.md", "LICENSE"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Agent Letter — the physical-mail API for AI agents.
|
|
2
|
+
|
|
3
|
+
Send a real, tracked, compliant letter with one call.
|
|
4
|
+
|
|
5
|
+
from agentletter import AgentLetter
|
|
6
|
+
|
|
7
|
+
client = AgentLetter() # reads AGENTLETTER_API_KEY
|
|
8
|
+
letter = client.send(
|
|
9
|
+
to={"name": "Jane Doe", "line1": "1 Market St",
|
|
10
|
+
"city": "San Francisco", "state": "CA", "zip": "94105"},
|
|
11
|
+
body=pdf_bytes,
|
|
12
|
+
certified=True,
|
|
13
|
+
)
|
|
14
|
+
print(letter.id, letter.status)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .client import AgentLetter, AsyncAgentLetter
|
|
19
|
+
from .errors import (
|
|
20
|
+
AgentLetterError,
|
|
21
|
+
APIError,
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
InvalidRequestError,
|
|
24
|
+
NotFoundError,
|
|
25
|
+
RateLimitError,
|
|
26
|
+
)
|
|
27
|
+
from .types import Address, Letter
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"__version__",
|
|
31
|
+
"AgentLetter",
|
|
32
|
+
"AsyncAgentLetter",
|
|
33
|
+
"Address",
|
|
34
|
+
"Letter",
|
|
35
|
+
"AgentLetterError",
|
|
36
|
+
"APIError",
|
|
37
|
+
"AuthenticationError",
|
|
38
|
+
"InvalidRequestError",
|
|
39
|
+
"NotFoundError",
|
|
40
|
+
"RateLimitError",
|
|
41
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""HTTP client for the Agent Letter API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .errors import APIError, AgentLetterError, error_from_response
|
|
14
|
+
from .types import Address, Letter
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.agentletter.dev/v1"
|
|
17
|
+
DEFAULT_TIMEOUT = 30.0
|
|
18
|
+
DEFAULT_MAX_RETRIES = 2
|
|
19
|
+
|
|
20
|
+
AddressInput = Union[Address, Mapping[str, Any]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_api_key(api_key: Optional[str]) -> str:
|
|
24
|
+
key = api_key or os.environ.get("AGENTLETTER_API_KEY")
|
|
25
|
+
if not key:
|
|
26
|
+
raise AgentLetterError(
|
|
27
|
+
"No API key provided. Pass api_key=... or set the "
|
|
28
|
+
"AGENTLETTER_API_KEY environment variable."
|
|
29
|
+
)
|
|
30
|
+
return key
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _address_dict(value: AddressInput) -> Dict[str, Any]:
|
|
34
|
+
if isinstance(value, Address):
|
|
35
|
+
return value.to_dict()
|
|
36
|
+
return dict(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_payload(
|
|
40
|
+
*,
|
|
41
|
+
to: AddressInput,
|
|
42
|
+
body: Optional[bytes],
|
|
43
|
+
html: Optional[str],
|
|
44
|
+
template: Optional[str],
|
|
45
|
+
variables: Optional[Mapping[str, Any]],
|
|
46
|
+
from_: Optional[AddressInput],
|
|
47
|
+
certified: bool,
|
|
48
|
+
color: bool,
|
|
49
|
+
double_sided: bool,
|
|
50
|
+
metadata: Optional[Mapping[str, Any]],
|
|
51
|
+
) -> Dict[str, Any]:
|
|
52
|
+
provided = [name for name, v in (("body", body), ("html", html), ("template", template)) if v is not None]
|
|
53
|
+
if len(provided) != 1:
|
|
54
|
+
raise AgentLetterError(
|
|
55
|
+
"Provide exactly one of: body (PDF bytes), html (str), or template (str). "
|
|
56
|
+
f"Got: {provided or 'none'}."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if body is not None:
|
|
60
|
+
content: Dict[str, Any] = {"pdf_base64": base64.b64encode(body).decode("ascii")}
|
|
61
|
+
elif html is not None:
|
|
62
|
+
content = {"html": html}
|
|
63
|
+
else:
|
|
64
|
+
content = {"template": template}
|
|
65
|
+
if variables:
|
|
66
|
+
content["variables"] = dict(variables)
|
|
67
|
+
|
|
68
|
+
payload: Dict[str, Any] = {
|
|
69
|
+
"to": _address_dict(to),
|
|
70
|
+
"body": content,
|
|
71
|
+
"certified": certified,
|
|
72
|
+
"color": color,
|
|
73
|
+
"double_sided": double_sided,
|
|
74
|
+
}
|
|
75
|
+
if from_ is not None:
|
|
76
|
+
payload["from"] = _address_dict(from_)
|
|
77
|
+
if metadata:
|
|
78
|
+
payload["metadata"] = dict(metadata)
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _BaseClient:
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
api_key: Optional[str] = None,
|
|
86
|
+
*,
|
|
87
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
88
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
89
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._api_key = _resolve_api_key(api_key)
|
|
92
|
+
self._base_url = base_url.rstrip("/")
|
|
93
|
+
self._timeout = timeout
|
|
94
|
+
self._max_retries = max_retries
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def _headers(self) -> Dict[str, str]:
|
|
98
|
+
return {
|
|
99
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
"User-Agent": f"agentletter-python/{__version__}",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def _parse(self, response: httpx.Response) -> Any:
|
|
105
|
+
try:
|
|
106
|
+
data = response.json()
|
|
107
|
+
except ValueError:
|
|
108
|
+
data = response.text
|
|
109
|
+
if response.status_code >= 400:
|
|
110
|
+
raise error_from_response(response.status_code, data)
|
|
111
|
+
return data
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AgentLetter(_BaseClient):
|
|
115
|
+
"""Synchronous Agent Letter client.
|
|
116
|
+
|
|
117
|
+
Example::
|
|
118
|
+
|
|
119
|
+
from agentletter import AgentLetter
|
|
120
|
+
|
|
121
|
+
client = AgentLetter() # reads AGENTLETTER_API_KEY
|
|
122
|
+
letter = client.send(
|
|
123
|
+
to={"name": "Jane Doe", "line1": "1 Market St",
|
|
124
|
+
"city": "San Francisco", "state": "CA", "zip": "94105"},
|
|
125
|
+
body=pdf_bytes,
|
|
126
|
+
certified=True,
|
|
127
|
+
)
|
|
128
|
+
print(letter.id, letter.status)
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
132
|
+
super().__init__(*args, **kwargs)
|
|
133
|
+
self._client = httpx.Client(base_url=self._base_url, timeout=self._timeout)
|
|
134
|
+
|
|
135
|
+
def _request(self, method: str, path: str, *, json: Optional[dict] = None) -> Any:
|
|
136
|
+
last_exc: Optional[Exception] = None
|
|
137
|
+
for attempt in range(self._max_retries + 1):
|
|
138
|
+
try:
|
|
139
|
+
response = self._client.request(
|
|
140
|
+
method, path, headers=self._headers, json=json
|
|
141
|
+
)
|
|
142
|
+
except httpx.HTTPError as exc:
|
|
143
|
+
last_exc = APIError(f"Network error talking to Agent Letter: {exc}")
|
|
144
|
+
if attempt < self._max_retries:
|
|
145
|
+
time.sleep(0.5 * (2**attempt))
|
|
146
|
+
continue
|
|
147
|
+
raise last_exc from exc
|
|
148
|
+
if response.status_code in (429, 500, 502, 503, 504) and attempt < self._max_retries:
|
|
149
|
+
time.sleep(0.5 * (2**attempt))
|
|
150
|
+
continue
|
|
151
|
+
return self._parse(response)
|
|
152
|
+
raise last_exc or APIError("Request failed.")
|
|
153
|
+
|
|
154
|
+
def send(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
to: AddressInput,
|
|
158
|
+
body: Optional[bytes] = None,
|
|
159
|
+
html: Optional[str] = None,
|
|
160
|
+
template: Optional[str] = None,
|
|
161
|
+
variables: Optional[Mapping[str, Any]] = None,
|
|
162
|
+
from_: Optional[AddressInput] = None,
|
|
163
|
+
certified: bool = False,
|
|
164
|
+
color: bool = False,
|
|
165
|
+
double_sided: bool = True,
|
|
166
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
167
|
+
) -> Letter:
|
|
168
|
+
"""Send a physical letter. Provide one of body / html / template."""
|
|
169
|
+
payload = _build_payload(
|
|
170
|
+
to=to, body=body, html=html, template=template, variables=variables,
|
|
171
|
+
from_=from_, certified=certified, color=color,
|
|
172
|
+
double_sided=double_sided, metadata=metadata,
|
|
173
|
+
)
|
|
174
|
+
data = self._request("POST", "/letters", json=payload)
|
|
175
|
+
return Letter.from_dict(data)
|
|
176
|
+
|
|
177
|
+
def get(self, letter_id: str) -> Letter:
|
|
178
|
+
"""Retrieve a letter by id."""
|
|
179
|
+
data = self._request("GET", f"/letters/{letter_id}")
|
|
180
|
+
return Letter.from_dict(data)
|
|
181
|
+
|
|
182
|
+
def cancel(self, letter_id: str) -> Letter:
|
|
183
|
+
"""Cancel a letter that has not yet been printed."""
|
|
184
|
+
data = self._request("POST", f"/letters/{letter_id}/cancel")
|
|
185
|
+
return Letter.from_dict(data)
|
|
186
|
+
|
|
187
|
+
def close(self) -> None:
|
|
188
|
+
self._client.close()
|
|
189
|
+
|
|
190
|
+
def __enter__(self) -> "AgentLetter":
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def __exit__(self, *args: Any) -> None:
|
|
194
|
+
self.close()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class AsyncAgentLetter(_BaseClient):
|
|
198
|
+
"""Asynchronous Agent Letter client (same API as :class:`AgentLetter`)."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
201
|
+
super().__init__(*args, **kwargs)
|
|
202
|
+
self._client = httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout)
|
|
203
|
+
|
|
204
|
+
async def _request(self, method: str, path: str, *, json: Optional[dict] = None) -> Any:
|
|
205
|
+
import asyncio
|
|
206
|
+
|
|
207
|
+
last_exc: Optional[Exception] = None
|
|
208
|
+
for attempt in range(self._max_retries + 1):
|
|
209
|
+
try:
|
|
210
|
+
response = await self._client.request(
|
|
211
|
+
method, path, headers=self._headers, json=json
|
|
212
|
+
)
|
|
213
|
+
except httpx.HTTPError as exc:
|
|
214
|
+
last_exc = APIError(f"Network error talking to Agent Letter: {exc}")
|
|
215
|
+
if attempt < self._max_retries:
|
|
216
|
+
await asyncio.sleep(0.5 * (2**attempt))
|
|
217
|
+
continue
|
|
218
|
+
raise last_exc from exc
|
|
219
|
+
if response.status_code in (429, 500, 502, 503, 504) and attempt < self._max_retries:
|
|
220
|
+
await asyncio.sleep(0.5 * (2**attempt))
|
|
221
|
+
continue
|
|
222
|
+
return self._parse(response)
|
|
223
|
+
raise last_exc or APIError("Request failed.")
|
|
224
|
+
|
|
225
|
+
async def send(
|
|
226
|
+
self,
|
|
227
|
+
*,
|
|
228
|
+
to: AddressInput,
|
|
229
|
+
body: Optional[bytes] = None,
|
|
230
|
+
html: Optional[str] = None,
|
|
231
|
+
template: Optional[str] = None,
|
|
232
|
+
variables: Optional[Mapping[str, Any]] = None,
|
|
233
|
+
from_: Optional[AddressInput] = None,
|
|
234
|
+
certified: bool = False,
|
|
235
|
+
color: bool = False,
|
|
236
|
+
double_sided: bool = True,
|
|
237
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
238
|
+
) -> Letter:
|
|
239
|
+
payload = _build_payload(
|
|
240
|
+
to=to, body=body, html=html, template=template, variables=variables,
|
|
241
|
+
from_=from_, certified=certified, color=color,
|
|
242
|
+
double_sided=double_sided, metadata=metadata,
|
|
243
|
+
)
|
|
244
|
+
data = await self._request("POST", "/letters", json=payload)
|
|
245
|
+
return Letter.from_dict(data)
|
|
246
|
+
|
|
247
|
+
async def get(self, letter_id: str) -> Letter:
|
|
248
|
+
data = await self._request("GET", f"/letters/{letter_id}")
|
|
249
|
+
return Letter.from_dict(data)
|
|
250
|
+
|
|
251
|
+
async def cancel(self, letter_id: str) -> Letter:
|
|
252
|
+
data = await self._request("POST", f"/letters/{letter_id}/cancel")
|
|
253
|
+
return Letter.from_dict(data)
|
|
254
|
+
|
|
255
|
+
async def aclose(self) -> None:
|
|
256
|
+
await self._client.aclose()
|
|
257
|
+
|
|
258
|
+
async def __aenter__(self) -> "AsyncAgentLetter":
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
262
|
+
await self.aclose()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Exception types raised by the Agent Letter SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AgentLetterError(Exception):
|
|
9
|
+
"""Base class for all Agent Letter errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
status_code: Optional[int] = None,
|
|
16
|
+
body: Any = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.message = message
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self.body = body
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthenticationError(AgentLetterError):
|
|
25
|
+
"""Raised when the API key is missing or invalid (HTTP 401/403)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidRequestError(AgentLetterError):
|
|
29
|
+
"""Raised when the request is malformed or fails validation (HTTP 400/422)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotFoundError(AgentLetterError):
|
|
33
|
+
"""Raised when a resource does not exist (HTTP 404)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RateLimitError(AgentLetterError):
|
|
37
|
+
"""Raised when the rate limit is exceeded (HTTP 429)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class APIError(AgentLetterError):
|
|
41
|
+
"""Raised for unexpected server errors (HTTP 5xx) or transport failures."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def error_from_response(status_code: int, body: Any) -> AgentLetterError:
|
|
45
|
+
"""Map an HTTP status code + parsed body to the right exception type."""
|
|
46
|
+
message = _extract_message(body) or f"Agent Letter request failed ({status_code})."
|
|
47
|
+
if status_code in (401, 403):
|
|
48
|
+
return AuthenticationError(message, status_code=status_code, body=body)
|
|
49
|
+
if status_code == 404:
|
|
50
|
+
return NotFoundError(message, status_code=status_code, body=body)
|
|
51
|
+
if status_code == 429:
|
|
52
|
+
return RateLimitError(message, status_code=status_code, body=body)
|
|
53
|
+
if status_code in (400, 422):
|
|
54
|
+
return InvalidRequestError(message, status_code=status_code, body=body)
|
|
55
|
+
return APIError(message, status_code=status_code, body=body)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_message(body: Any) -> Optional[str]:
|
|
59
|
+
if isinstance(body, dict):
|
|
60
|
+
err = body.get("error")
|
|
61
|
+
if isinstance(err, dict):
|
|
62
|
+
return err.get("message")
|
|
63
|
+
if isinstance(err, str):
|
|
64
|
+
return err
|
|
65
|
+
if isinstance(body.get("message"), str):
|
|
66
|
+
return body["message"]
|
|
67
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Data types for the Agent Letter SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Address:
|
|
11
|
+
"""A postal recipient or sender address."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
line1: str
|
|
15
|
+
city: str
|
|
16
|
+
state: str
|
|
17
|
+
zip: str
|
|
18
|
+
line2: Optional[str] = None
|
|
19
|
+
country: str = "US"
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
22
|
+
data = {
|
|
23
|
+
"name": self.name,
|
|
24
|
+
"line1": self.line1,
|
|
25
|
+
"city": self.city,
|
|
26
|
+
"state": self.state,
|
|
27
|
+
"zip": self.zip,
|
|
28
|
+
"country": self.country,
|
|
29
|
+
}
|
|
30
|
+
if self.line2:
|
|
31
|
+
data["line2"] = self.line2
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Letter:
|
|
37
|
+
"""A letter created through the API."""
|
|
38
|
+
|
|
39
|
+
id: str
|
|
40
|
+
status: str
|
|
41
|
+
to: Dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
certified: bool = False
|
|
43
|
+
tracking_number: Optional[str] = None
|
|
44
|
+
expected_delivery: Optional[str] = None
|
|
45
|
+
created_at: Optional[str] = None
|
|
46
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Letter":
|
|
50
|
+
return cls(
|
|
51
|
+
id=data.get("id", ""),
|
|
52
|
+
status=data.get("status", "unknown"),
|
|
53
|
+
to=data.get("to", {}) or {},
|
|
54
|
+
certified=bool(data.get("certified", False)),
|
|
55
|
+
tracking_number=data.get("tracking_number"),
|
|
56
|
+
expected_delivery=data.get("expected_delivery"),
|
|
57
|
+
created_at=data.get("created_at"),
|
|
58
|
+
raw=data,
|
|
59
|
+
)
|