loftbox 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.
- loftbox-0.1.0/.github/workflows/ci.yml +28 -0
- loftbox-0.1.0/.github/workflows/publish.yml +37 -0
- loftbox-0.1.0/.gitignore +9 -0
- loftbox-0.1.0/PKG-INFO +104 -0
- loftbox-0.1.0/README.md +85 -0
- loftbox-0.1.0/examples/quickstart.py +61 -0
- loftbox-0.1.0/loftbox/__init__.py +48 -0
- loftbox-0.1.0/loftbox/client.py +449 -0
- loftbox-0.1.0/loftbox/errors.py +88 -0
- loftbox-0.1.0/loftbox/models.py +113 -0
- loftbox-0.1.0/pyproject.toml +34 -0
- loftbox-0.1.0/tests/__init__.py +0 -0
- loftbox-0.1.0/tests/test_client.py +193 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.9", "3.12"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- name: Install
|
|
20
|
+
run: pip install -e ".[dev]"
|
|
21
|
+
- name: Lint (ruff)
|
|
22
|
+
run: |
|
|
23
|
+
ruff check loftbox tests
|
|
24
|
+
ruff format --check loftbox tests
|
|
25
|
+
- name: Type check (mypy)
|
|
26
|
+
run: mypy loftbox
|
|
27
|
+
- name: Test
|
|
28
|
+
run: pytest -q
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# v* 태그 푸시 시 PyPI 게시. 게시 권한은 PYPI_API_TOKEN 시크릿(레포 설정에
|
|
4
|
+
# caspar 가 주입). 태그 버전과 pyproject version 이 일치해야 함.
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
tags: ["v*"]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
publish:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
- name: Install build tooling
|
|
21
|
+
run: pip install build twine
|
|
22
|
+
- name: Verify tag matches pyproject version
|
|
23
|
+
run: |
|
|
24
|
+
TAG="${GITHUB_REF_NAME#v}"
|
|
25
|
+
VER=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
26
|
+
if [ "$TAG" != "$VER" ]; then
|
|
27
|
+
echo "tag $TAG != pyproject version $VER"; exit 1
|
|
28
|
+
fi
|
|
29
|
+
- name: Build
|
|
30
|
+
run: python -m build
|
|
31
|
+
- name: Check metadata
|
|
32
|
+
run: twine check dist/*
|
|
33
|
+
- name: Publish
|
|
34
|
+
env:
|
|
35
|
+
TWINE_USERNAME: __token__
|
|
36
|
+
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
|
37
|
+
run: twine upload dist/*
|
loftbox-0.1.0/.gitignore
ADDED
loftbox-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loftbox
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LoftBox Python SDK - Email infrastructure for AI agents
|
|
5
|
+
Project-URL: Homepage, https://loftbox.net
|
|
6
|
+
Project-URL: Repository, https://github.com/TheMagicTower/loftbox-sdk-python
|
|
7
|
+
Author: LoftBox
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: ai-agents,email,inbox,loftbox,smtp
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: httpx>=0.25
|
|
12
|
+
Requires-Dist: pydantic>=2
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
16
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# LoftBox Python SDK
|
|
21
|
+
|
|
22
|
+
AI 에이전트를 위한 이메일 인프라 SDK.
|
|
23
|
+
|
|
24
|
+
## 설치
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install loftbox
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
요구사항: Python 3.9+.
|
|
31
|
+
|
|
32
|
+
## 빠른 시작
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from loftbox import LoftBox
|
|
36
|
+
|
|
37
|
+
with LoftBox(api_key="lb_live_xxx") as client:
|
|
38
|
+
# 에이전트 + 메일박스
|
|
39
|
+
agent = client.agents.create(name="Support Bot", slug="support-bot")
|
|
40
|
+
mailbox = client.mailboxes.create(agent.id, local_part="support")
|
|
41
|
+
|
|
42
|
+
# 발송 (멱등 키로 중복 방지)
|
|
43
|
+
msg = client.messages.send(
|
|
44
|
+
mailbox_id=mailbox.id,
|
|
45
|
+
to=["recipient@example.com"],
|
|
46
|
+
subject="Hello",
|
|
47
|
+
body_text="World",
|
|
48
|
+
idempotency_key="welcome-42",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# 수신 폴링 → ack
|
|
52
|
+
inbox = client.mailboxes.list_inbox(mailbox.id)
|
|
53
|
+
client.mailboxes.ack_inbox(mailbox.id, [m.id for m in inbox.data])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 기능
|
|
57
|
+
|
|
58
|
+
- **발송**: `messages.send(...)` — 텍스트/HTML/Markdown 본문, 첨부, cc, 답장 헤더
|
|
59
|
+
- **예약 발송**: `send(..., send_at="2030-01-01T09:00:00Z")` (미래 RFC3339)
|
|
60
|
+
- **멱등 발송**: `send(..., idempotency_key="...")` — 중복 발송 방지
|
|
61
|
+
- **수신**: `mailboxes.list_inbox(...)` 폴링 + `ack_inbox(...)`. `message.extracted_text` 로 인용 제거된 답장 본문
|
|
62
|
+
- **라벨**: `messages.add_labels(...)`, `remove_label(...)`, `list(label=...)`
|
|
63
|
+
- **전문검색**: `messages.list(q="...")`, `threads.list(q="...")`
|
|
64
|
+
- **스레드**: `threads.list(...)`, `list_messages(...)`
|
|
65
|
+
- **승인 워크플로**: `messages.approve(id, reason=...)`, `reject(...)`
|
|
66
|
+
- **웹훅**: `webhooks.create(agent_id, url, event_types)`
|
|
67
|
+
- **도메인 / suppression**: `domains.*`, `suppressions.*`
|
|
68
|
+
|
|
69
|
+
## 오류 처리
|
|
70
|
+
|
|
71
|
+
모든 호출은 실패 시 `LoftBoxError` 하위 예외를 던집니다:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from loftbox import RateLimitError, NotFoundError, ValidationError
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
client.messages.send(...)
|
|
78
|
+
except RateLimitError as e:
|
|
79
|
+
print(f"{e.retry_after_secs}s 후 재시도")
|
|
80
|
+
except (NotFoundError, ValidationError) as e:
|
|
81
|
+
print(e.status_code, e.message)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## 페이지네이션
|
|
85
|
+
|
|
86
|
+
목록 메서드는 `Page` 를 반환합니다 (`.data`, `.next_cursor`):
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
page = client.messages.list(mailbox_id=mailbox.id, limit=50)
|
|
90
|
+
while True:
|
|
91
|
+
for m in page.data:
|
|
92
|
+
...
|
|
93
|
+
if not page.next_cursor:
|
|
94
|
+
break
|
|
95
|
+
page = client.messages.list(mailbox_id=mailbox.id, limit=50, cursor=page.next_cursor)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 예제
|
|
99
|
+
|
|
100
|
+
`examples/quickstart.py` 참고.
|
|
101
|
+
|
|
102
|
+
## 라이선스
|
|
103
|
+
|
|
104
|
+
MIT
|
loftbox-0.1.0/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# LoftBox Python SDK
|
|
2
|
+
|
|
3
|
+
AI 에이전트를 위한 이메일 인프라 SDK.
|
|
4
|
+
|
|
5
|
+
## 설치
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install loftbox
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
요구사항: Python 3.9+.
|
|
12
|
+
|
|
13
|
+
## 빠른 시작
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from loftbox import LoftBox
|
|
17
|
+
|
|
18
|
+
with LoftBox(api_key="lb_live_xxx") as client:
|
|
19
|
+
# 에이전트 + 메일박스
|
|
20
|
+
agent = client.agents.create(name="Support Bot", slug="support-bot")
|
|
21
|
+
mailbox = client.mailboxes.create(agent.id, local_part="support")
|
|
22
|
+
|
|
23
|
+
# 발송 (멱등 키로 중복 방지)
|
|
24
|
+
msg = client.messages.send(
|
|
25
|
+
mailbox_id=mailbox.id,
|
|
26
|
+
to=["recipient@example.com"],
|
|
27
|
+
subject="Hello",
|
|
28
|
+
body_text="World",
|
|
29
|
+
idempotency_key="welcome-42",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# 수신 폴링 → ack
|
|
33
|
+
inbox = client.mailboxes.list_inbox(mailbox.id)
|
|
34
|
+
client.mailboxes.ack_inbox(mailbox.id, [m.id for m in inbox.data])
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 기능
|
|
38
|
+
|
|
39
|
+
- **발송**: `messages.send(...)` — 텍스트/HTML/Markdown 본문, 첨부, cc, 답장 헤더
|
|
40
|
+
- **예약 발송**: `send(..., send_at="2030-01-01T09:00:00Z")` (미래 RFC3339)
|
|
41
|
+
- **멱등 발송**: `send(..., idempotency_key="...")` — 중복 발송 방지
|
|
42
|
+
- **수신**: `mailboxes.list_inbox(...)` 폴링 + `ack_inbox(...)`. `message.extracted_text` 로 인용 제거된 답장 본문
|
|
43
|
+
- **라벨**: `messages.add_labels(...)`, `remove_label(...)`, `list(label=...)`
|
|
44
|
+
- **전문검색**: `messages.list(q="...")`, `threads.list(q="...")`
|
|
45
|
+
- **스레드**: `threads.list(...)`, `list_messages(...)`
|
|
46
|
+
- **승인 워크플로**: `messages.approve(id, reason=...)`, `reject(...)`
|
|
47
|
+
- **웹훅**: `webhooks.create(agent_id, url, event_types)`
|
|
48
|
+
- **도메인 / suppression**: `domains.*`, `suppressions.*`
|
|
49
|
+
|
|
50
|
+
## 오류 처리
|
|
51
|
+
|
|
52
|
+
모든 호출은 실패 시 `LoftBoxError` 하위 예외를 던집니다:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from loftbox import RateLimitError, NotFoundError, ValidationError
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
client.messages.send(...)
|
|
59
|
+
except RateLimitError as e:
|
|
60
|
+
print(f"{e.retry_after_secs}s 후 재시도")
|
|
61
|
+
except (NotFoundError, ValidationError) as e:
|
|
62
|
+
print(e.status_code, e.message)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 페이지네이션
|
|
66
|
+
|
|
67
|
+
목록 메서드는 `Page` 를 반환합니다 (`.data`, `.next_cursor`):
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
page = client.messages.list(mailbox_id=mailbox.id, limit=50)
|
|
71
|
+
while True:
|
|
72
|
+
for m in page.data:
|
|
73
|
+
...
|
|
74
|
+
if not page.next_cursor:
|
|
75
|
+
break
|
|
76
|
+
page = client.messages.list(mailbox_id=mailbox.id, limit=50, cursor=page.next_cursor)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 예제
|
|
80
|
+
|
|
81
|
+
`examples/quickstart.py` 참고.
|
|
82
|
+
|
|
83
|
+
## 라이선스
|
|
84
|
+
|
|
85
|
+
MIT
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""LoftBox Python SDK 퀵스타트.
|
|
2
|
+
|
|
3
|
+
실행:
|
|
4
|
+
export LOFTBOX_API_KEY=lb_live_xxx
|
|
5
|
+
python examples/quickstart.py
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from loftbox import LoftBox, RateLimitError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
api_key = os.environ["LOFTBOX_API_KEY"]
|
|
15
|
+
|
|
16
|
+
with LoftBox(api_key=api_key) as client:
|
|
17
|
+
# 1. 에이전트 + 메일박스 준비 (최초 1회).
|
|
18
|
+
agent = client.agents.create(name="Support Bot", slug="support-bot")
|
|
19
|
+
mailbox = client.mailboxes.create(agent.id, local_part="support")
|
|
20
|
+
print(f"mailbox: {mailbox.address}")
|
|
21
|
+
|
|
22
|
+
# 2. 발송 (멱등 키로 중복 방지).
|
|
23
|
+
try:
|
|
24
|
+
msg = client.messages.send(
|
|
25
|
+
mailbox_id=mailbox.id,
|
|
26
|
+
to=["customer@example.com"],
|
|
27
|
+
subject="안녕하세요",
|
|
28
|
+
body_text="LoftBox 에서 보냅니다.",
|
|
29
|
+
idempotency_key="welcome-customer-42",
|
|
30
|
+
)
|
|
31
|
+
print(f"sent: {msg.id} status={msg.status}")
|
|
32
|
+
except RateLimitError as e:
|
|
33
|
+
print(f"rate limited, retry after {e.retry_after_secs}s")
|
|
34
|
+
|
|
35
|
+
# 3. 예약 발송 (1시간 뒤).
|
|
36
|
+
from datetime import datetime, timedelta, timezone
|
|
37
|
+
|
|
38
|
+
client.messages.send(
|
|
39
|
+
mailbox_id=mailbox.id,
|
|
40
|
+
to=["customer@example.com"],
|
|
41
|
+
subject="리마인더",
|
|
42
|
+
body_text="예약 발송 메시지",
|
|
43
|
+
send_at=(datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# 4. 수신 폴링 → 처리 → ack.
|
|
47
|
+
inbox = client.mailboxes.list_inbox(mailbox.id, limit=20)
|
|
48
|
+
for incoming in inbox.data:
|
|
49
|
+
print(f"received: {incoming.subject} (extracted: {incoming.extracted_text!r})")
|
|
50
|
+
if inbox.data:
|
|
51
|
+
client.mailboxes.ack_inbox(mailbox.id, [m.id for m in inbox.data])
|
|
52
|
+
|
|
53
|
+
# 5. 라벨링 + 전문검색.
|
|
54
|
+
if inbox.data:
|
|
55
|
+
client.messages.add_labels(inbox.data[0].id, ["needs-reply", "vip"])
|
|
56
|
+
results = client.messages.list(q="invoice", label="vip", limit=10)
|
|
57
|
+
print(f"search hits: {len(results.data)}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""LoftBox Python SDK — AI 에이전트를 위한 이메일 인프라."""
|
|
2
|
+
|
|
3
|
+
from .client import LoftBox
|
|
4
|
+
from .errors import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
ConflictError,
|
|
7
|
+
LoftBoxError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PermissionError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
from .models import (
|
|
14
|
+
Agent,
|
|
15
|
+
Attachment,
|
|
16
|
+
Domain,
|
|
17
|
+
DomainStatus,
|
|
18
|
+
Mailbox,
|
|
19
|
+
Message,
|
|
20
|
+
Page,
|
|
21
|
+
Suppression,
|
|
22
|
+
Thread,
|
|
23
|
+
Webhook,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
__all__ = [
|
|
28
|
+
"LoftBox",
|
|
29
|
+
# models
|
|
30
|
+
"Agent",
|
|
31
|
+
"Attachment",
|
|
32
|
+
"Domain",
|
|
33
|
+
"DomainStatus",
|
|
34
|
+
"Mailbox",
|
|
35
|
+
"Message",
|
|
36
|
+
"Page",
|
|
37
|
+
"Suppression",
|
|
38
|
+
"Thread",
|
|
39
|
+
"Webhook",
|
|
40
|
+
# errors
|
|
41
|
+
"LoftBoxError",
|
|
42
|
+
"AuthenticationError",
|
|
43
|
+
"PermissionError",
|
|
44
|
+
"NotFoundError",
|
|
45
|
+
"ConflictError",
|
|
46
|
+
"RateLimitError",
|
|
47
|
+
"ValidationError",
|
|
48
|
+
]
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""LoftBox API 클라이언트 (동기, httpx 기반).
|
|
2
|
+
|
|
3
|
+
AI 에이전트를 위한 이메일 인프라. 핵심 플로우: 회원가입 → 에이전트/메일박스
|
|
4
|
+
생성 → 발송 → 수신 폴링/ack → 스레드 → 웹훅 → 승인.
|
|
5
|
+
|
|
6
|
+
사용 예:
|
|
7
|
+
from loftbox import LoftBox
|
|
8
|
+
|
|
9
|
+
client = LoftBox(api_key="lb_live_xxx")
|
|
10
|
+
msg = client.messages.send(
|
|
11
|
+
mailbox_id="mb_xxx",
|
|
12
|
+
to=["recipient@example.com"],
|
|
13
|
+
subject="Hello",
|
|
14
|
+
body_text="World",
|
|
15
|
+
)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Dict, List, Optional, Type
|
|
21
|
+
from urllib.parse import quote
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
from pydantic import BaseModel
|
|
25
|
+
|
|
26
|
+
from .errors import LoftBoxError, error_for_status
|
|
27
|
+
from .models import (
|
|
28
|
+
Agent,
|
|
29
|
+
Attachment,
|
|
30
|
+
Domain,
|
|
31
|
+
DomainStatus,
|
|
32
|
+
Mailbox,
|
|
33
|
+
Message,
|
|
34
|
+
Page,
|
|
35
|
+
Suppression,
|
|
36
|
+
Thread,
|
|
37
|
+
Webhook,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
DEFAULT_BASE_URL = "https://api.loftbox.net"
|
|
41
|
+
DEFAULT_TIMEOUT = 30.0
|
|
42
|
+
USER_AGENT = "loftbox-python/0.1.0"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LoftBox:
|
|
46
|
+
"""LoftBox API 클라이언트.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
api_key: API 키 (`Authorization: Bearer` 로 전송).
|
|
50
|
+
base_url: API 베이스 URL (기본 https://api.loftbox.net).
|
|
51
|
+
timeout: 요청 타임아웃(초).
|
|
52
|
+
http_client: 직접 구성한 httpx.Client (테스트/프록시용). 주면 timeout 은
|
|
53
|
+
그 클라이언트 설정을 따른다.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
api_key: str,
|
|
59
|
+
base_url: Optional[str] = None,
|
|
60
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
61
|
+
http_client: Optional[httpx.Client] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
if not api_key:
|
|
64
|
+
raise ValueError("api_key 는 필수입니다")
|
|
65
|
+
self.api_key = api_key
|
|
66
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
67
|
+
self._owns_client = http_client is None
|
|
68
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
69
|
+
|
|
70
|
+
# 리소스 네임스페이스
|
|
71
|
+
self.auth = _Auth(self)
|
|
72
|
+
self.agents = _Agents(self)
|
|
73
|
+
self.mailboxes = _Mailboxes(self)
|
|
74
|
+
self.messages = _Messages(self)
|
|
75
|
+
self.threads = _Threads(self)
|
|
76
|
+
self.webhooks = _Webhooks(self)
|
|
77
|
+
self.domains = _Domains(self)
|
|
78
|
+
self.suppressions = _Suppressions(self)
|
|
79
|
+
self.attachments = _Attachments(self)
|
|
80
|
+
|
|
81
|
+
# -- transport ----------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _request(
|
|
84
|
+
self,
|
|
85
|
+
method: str,
|
|
86
|
+
path: str,
|
|
87
|
+
*,
|
|
88
|
+
json: Optional[Dict[str, Any]] = None,
|
|
89
|
+
params: Optional[Dict[str, Any]] = None,
|
|
90
|
+
headers: Optional[Dict[str, str]] = None,
|
|
91
|
+
) -> Any:
|
|
92
|
+
url = f"{self.base_url}{path}"
|
|
93
|
+
hdrs = {
|
|
94
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
95
|
+
"Content-Type": "application/json",
|
|
96
|
+
"Accept": "application/json",
|
|
97
|
+
"User-Agent": USER_AGENT,
|
|
98
|
+
}
|
|
99
|
+
if headers:
|
|
100
|
+
hdrs.update(headers)
|
|
101
|
+
# None 값 query param 제거.
|
|
102
|
+
clean_params = {k: v for k, v in (params or {}).items() if v is not None}
|
|
103
|
+
try:
|
|
104
|
+
resp = self._http.request(
|
|
105
|
+
method, url, json=json, params=clean_params or None, headers=hdrs
|
|
106
|
+
)
|
|
107
|
+
except httpx.HTTPError as e: # 네트워크/타임아웃 등
|
|
108
|
+
raise LoftBoxError(f"요청 실패: {e}") from e
|
|
109
|
+
|
|
110
|
+
request_id = resp.headers.get("x-request-id")
|
|
111
|
+
if resp.status_code >= 400:
|
|
112
|
+
body: Any = None
|
|
113
|
+
message = f"HTTP {resp.status_code}"
|
|
114
|
+
retry_after_secs: Optional[int] = None
|
|
115
|
+
try:
|
|
116
|
+
body = resp.json()
|
|
117
|
+
except Exception:
|
|
118
|
+
body = resp.text or None
|
|
119
|
+
if body:
|
|
120
|
+
message = str(body)
|
|
121
|
+
if isinstance(body, dict):
|
|
122
|
+
# LoftBox 오류 wire shape: {"error": {message, code, retry_after, ...}}.
|
|
123
|
+
# top-level message/detail 도 방어적으로 허용.
|
|
124
|
+
err = body.get("error")
|
|
125
|
+
if isinstance(err, dict):
|
|
126
|
+
message = err.get("message") or message
|
|
127
|
+
ra = err.get("retry_after")
|
|
128
|
+
if isinstance(ra, int):
|
|
129
|
+
retry_after_secs = ra
|
|
130
|
+
else:
|
|
131
|
+
message = (
|
|
132
|
+
body.get("message")
|
|
133
|
+
or (err if isinstance(err, str) else None)
|
|
134
|
+
or body.get("detail")
|
|
135
|
+
or message
|
|
136
|
+
)
|
|
137
|
+
# Retry-After 헤더가 있으면 우선(표준).
|
|
138
|
+
header_ra = resp.headers.get("retry-after")
|
|
139
|
+
if header_ra and header_ra.isdigit():
|
|
140
|
+
retry_after_secs = int(header_ra)
|
|
141
|
+
raise error_for_status(
|
|
142
|
+
resp.status_code,
|
|
143
|
+
message,
|
|
144
|
+
body=body,
|
|
145
|
+
request_id=request_id,
|
|
146
|
+
retry_after_secs=retry_after_secs,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if resp.status_code == 204 or not resp.content:
|
|
150
|
+
return None
|
|
151
|
+
return resp.json()
|
|
152
|
+
|
|
153
|
+
# -- lifecycle ----------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def close(self) -> None:
|
|
156
|
+
"""소유한 httpx 클라이언트를 닫는다(외부 주입 클라이언트는 닫지 않음)."""
|
|
157
|
+
if self._owns_client:
|
|
158
|
+
self._http.close()
|
|
159
|
+
|
|
160
|
+
def __enter__(self) -> "LoftBox":
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
def __exit__(self, *exc: object) -> None:
|
|
164
|
+
self.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _Resource:
|
|
168
|
+
def __init__(self, client: LoftBox) -> None:
|
|
169
|
+
self._c = client
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _Auth(_Resource):
|
|
173
|
+
def signup(
|
|
174
|
+
self,
|
|
175
|
+
email: str,
|
|
176
|
+
organization_name: str,
|
|
177
|
+
slug: Optional[str] = None,
|
|
178
|
+
) -> Dict[str, Any]:
|
|
179
|
+
"""조직 가입 요청 — 이메일 검증 링크 발송. 반환은 서버 안내 페이로드."""
|
|
180
|
+
return self._c._request(
|
|
181
|
+
"POST",
|
|
182
|
+
"/v1/auth/signup",
|
|
183
|
+
json={"email": email, "organization_name": organization_name, "slug": slug},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def verify_signup(self, email: str, verification_token: str) -> Dict[str, Any]:
|
|
187
|
+
"""이메일 + 검증 토큰으로 가입 확정."""
|
|
188
|
+
return self._c._request(
|
|
189
|
+
"POST",
|
|
190
|
+
"/v1/auth/signup/verify",
|
|
191
|
+
json={"email": email, "verification_token": verification_token},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class _Agents(_Resource):
|
|
196
|
+
def create(
|
|
197
|
+
self,
|
|
198
|
+
name: str,
|
|
199
|
+
slug: str,
|
|
200
|
+
*,
|
|
201
|
+
description: Optional[str] = None,
|
|
202
|
+
purpose: Optional[str] = None,
|
|
203
|
+
external_id: Optional[str] = None,
|
|
204
|
+
owner_label: Optional[str] = None,
|
|
205
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
206
|
+
) -> Agent:
|
|
207
|
+
body = {
|
|
208
|
+
"name": name,
|
|
209
|
+
"slug": slug,
|
|
210
|
+
"description": description,
|
|
211
|
+
"purpose": purpose,
|
|
212
|
+
"external_id": external_id,
|
|
213
|
+
"owner_label": owner_label,
|
|
214
|
+
"metadata": metadata,
|
|
215
|
+
}
|
|
216
|
+
return Agent.model_validate(self._c._request("POST", "/v1/agents", json=body))
|
|
217
|
+
|
|
218
|
+
def get(self, agent_id: str) -> Agent:
|
|
219
|
+
return Agent.model_validate(self._c._request("GET", f"/v1/agents/{agent_id}"))
|
|
220
|
+
|
|
221
|
+
def list(self, *, limit: Optional[int] = None, cursor: Optional[str] = None) -> Page[Agent]:
|
|
222
|
+
raw = self._c._request("GET", "/v1/agents", params={"limit": limit, "cursor": cursor})
|
|
223
|
+
return _page(raw, Agent)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _Mailboxes(_Resource):
|
|
227
|
+
def create(
|
|
228
|
+
self,
|
|
229
|
+
agent_id: str,
|
|
230
|
+
local_part: str,
|
|
231
|
+
*,
|
|
232
|
+
domain_id: Optional[str] = None,
|
|
233
|
+
display_name: Optional[str] = None,
|
|
234
|
+
webhook_url: Optional[str] = None,
|
|
235
|
+
retention_days: Optional[int] = None,
|
|
236
|
+
) -> Mailbox:
|
|
237
|
+
body = {
|
|
238
|
+
"local_part": local_part,
|
|
239
|
+
"domain_id": domain_id,
|
|
240
|
+
"display_name": display_name,
|
|
241
|
+
"webhook_url": webhook_url,
|
|
242
|
+
"retention_days": retention_days,
|
|
243
|
+
}
|
|
244
|
+
return Mailbox.model_validate(
|
|
245
|
+
self._c._request("POST", f"/v1/agents/{agent_id}/mailboxes", json=body)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def list_by_agent(self, agent_id: str) -> Page[Mailbox]:
|
|
249
|
+
raw = self._c._request("GET", f"/v1/agents/{agent_id}/mailboxes")
|
|
250
|
+
return _page(raw, Mailbox)
|
|
251
|
+
|
|
252
|
+
def list_inbox(
|
|
253
|
+
self,
|
|
254
|
+
mailbox_id: str,
|
|
255
|
+
*,
|
|
256
|
+
limit: Optional[int] = None,
|
|
257
|
+
cursor: Optional[str] = None,
|
|
258
|
+
) -> Page[Message]:
|
|
259
|
+
"""미확인(unacked) 수신 메시지 폴링."""
|
|
260
|
+
raw = self._c._request(
|
|
261
|
+
"GET",
|
|
262
|
+
f"/v1/mailboxes/{mailbox_id}/inbox",
|
|
263
|
+
params={"limit": limit, "cursor": cursor},
|
|
264
|
+
)
|
|
265
|
+
return _page(raw, Message)
|
|
266
|
+
|
|
267
|
+
def ack_inbox(self, mailbox_id: str, message_ids: List[str]) -> Any:
|
|
268
|
+
"""수신 메시지 확인 처리 — 다음 폴링에서 제외."""
|
|
269
|
+
return self._c._request(
|
|
270
|
+
"POST",
|
|
271
|
+
f"/v1/mailboxes/{mailbox_id}/inbox/ack",
|
|
272
|
+
json={"message_ids": message_ids},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class _Messages(_Resource):
|
|
277
|
+
def send(
|
|
278
|
+
self,
|
|
279
|
+
mailbox_id: str,
|
|
280
|
+
to: List[str],
|
|
281
|
+
subject: str,
|
|
282
|
+
*,
|
|
283
|
+
body_text: Optional[str] = None,
|
|
284
|
+
body_html: Optional[str] = None,
|
|
285
|
+
body_markdown: Optional[str] = None,
|
|
286
|
+
cc: Optional[List[str]] = None,
|
|
287
|
+
in_reply_to: Optional[str] = None,
|
|
288
|
+
references: Optional[List[str]] = None,
|
|
289
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
290
|
+
attachments: Optional[List[Dict[str, Any]]] = None,
|
|
291
|
+
send_at: Optional[str] = None,
|
|
292
|
+
idempotency_key: Optional[str] = None,
|
|
293
|
+
) -> Message:
|
|
294
|
+
"""발송 큐에 메시지 진입.
|
|
295
|
+
|
|
296
|
+
send_at(RFC3339 미래 시각)을 주면 예약발송. idempotency_key 를 주면
|
|
297
|
+
같은 키+같은 내용 재요청은 원본 메시지를 그대로 반환(중복 발송 방지).
|
|
298
|
+
"""
|
|
299
|
+
body: Dict[str, Any] = {
|
|
300
|
+
"mailbox_id": mailbox_id,
|
|
301
|
+
"to": to,
|
|
302
|
+
"subject": subject,
|
|
303
|
+
"body_text": body_text,
|
|
304
|
+
"body_html": body_html,
|
|
305
|
+
"body_markdown": body_markdown,
|
|
306
|
+
"cc": cc or [],
|
|
307
|
+
"in_reply_to": in_reply_to,
|
|
308
|
+
"references": references or [],
|
|
309
|
+
"metadata": metadata,
|
|
310
|
+
"attachments": attachments or [],
|
|
311
|
+
"send_at": send_at,
|
|
312
|
+
}
|
|
313
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
314
|
+
return Message.model_validate(
|
|
315
|
+
self._c._request("POST", "/v1/messages", json=body, headers=headers)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def get(self, message_id: str) -> Message:
|
|
319
|
+
return Message.model_validate(self._c._request("GET", f"/v1/messages/{message_id}"))
|
|
320
|
+
|
|
321
|
+
def list(
|
|
322
|
+
self,
|
|
323
|
+
*,
|
|
324
|
+
mailbox_id: Optional[str] = None,
|
|
325
|
+
direction: Optional[str] = None,
|
|
326
|
+
status: Optional[str] = None,
|
|
327
|
+
label: Optional[str] = None,
|
|
328
|
+
q: Optional[str] = None,
|
|
329
|
+
limit: Optional[int] = None,
|
|
330
|
+
cursor: Optional[str] = None,
|
|
331
|
+
) -> Page[Message]:
|
|
332
|
+
"""메시지 목록 — mailbox/direction/status/label 필터, q 전문검색."""
|
|
333
|
+
raw = self._c._request(
|
|
334
|
+
"GET",
|
|
335
|
+
"/v1/messages",
|
|
336
|
+
params={
|
|
337
|
+
"mailbox_id": mailbox_id,
|
|
338
|
+
"direction": direction,
|
|
339
|
+
"status": status,
|
|
340
|
+
"label": label,
|
|
341
|
+
"q": q,
|
|
342
|
+
"limit": limit,
|
|
343
|
+
"cursor": cursor,
|
|
344
|
+
},
|
|
345
|
+
)
|
|
346
|
+
return _page(raw, Message)
|
|
347
|
+
|
|
348
|
+
def add_labels(self, message_id: str, labels: List[str]) -> Message:
|
|
349
|
+
return Message.model_validate(
|
|
350
|
+
self._c._request("POST", f"/v1/messages/{message_id}/labels", json={"labels": labels})
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def remove_label(self, message_id: str, label: str) -> Message:
|
|
354
|
+
# 라벨을 경로 세그먼트로 안전 인코딩(공백/슬래시 등). safe="" 로 '/' 도 인코딩.
|
|
355
|
+
seg = quote(label, safe="")
|
|
356
|
+
return Message.model_validate(
|
|
357
|
+
self._c._request("DELETE", f"/v1/messages/{message_id}/labels/{seg}")
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def approve(self, message_id: str, reason: str) -> Message:
|
|
361
|
+
return Message.model_validate(
|
|
362
|
+
self._c._request("POST", f"/v1/messages/{message_id}/approve", json={"reason": reason})
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def reject(self, message_id: str, reason: str) -> Message:
|
|
366
|
+
return Message.model_validate(
|
|
367
|
+
self._c._request("POST", f"/v1/messages/{message_id}/reject", json={"reason": reason})
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class _Threads(_Resource):
|
|
372
|
+
def list(
|
|
373
|
+
self,
|
|
374
|
+
*,
|
|
375
|
+
mailbox_id: Optional[str] = None,
|
|
376
|
+
q: Optional[str] = None,
|
|
377
|
+
limit: Optional[int] = None,
|
|
378
|
+
cursor: Optional[str] = None,
|
|
379
|
+
) -> Page[Thread]:
|
|
380
|
+
raw = self._c._request(
|
|
381
|
+
"GET",
|
|
382
|
+
"/v1/threads",
|
|
383
|
+
params={"mailbox_id": mailbox_id, "q": q, "limit": limit, "cursor": cursor},
|
|
384
|
+
)
|
|
385
|
+
return _page(raw, Thread)
|
|
386
|
+
|
|
387
|
+
def list_messages(self, thread_id: str) -> Page[Message]:
|
|
388
|
+
raw = self._c._request("GET", f"/v1/threads/{thread_id}/messages")
|
|
389
|
+
return _page(raw, Message)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class _Webhooks(_Resource):
|
|
393
|
+
def create(self, agent_id: str, url: str, event_types: List[str]) -> Webhook:
|
|
394
|
+
return Webhook.model_validate(
|
|
395
|
+
self._c._request(
|
|
396
|
+
"POST",
|
|
397
|
+
f"/v1/agents/{agent_id}/webhooks",
|
|
398
|
+
json={"url": url, "event_types": event_types},
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class _Domains(_Resource):
|
|
404
|
+
def create(self, domain: str) -> Domain:
|
|
405
|
+
return Domain.model_validate(
|
|
406
|
+
self._c._request("POST", "/v1/domains", json={"domain": domain})
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def list(self) -> Page[Domain]:
|
|
410
|
+
return _page(self._c._request("GET", "/v1/domains"), Domain)
|
|
411
|
+
|
|
412
|
+
def status(self, domain_id: str) -> DomainStatus:
|
|
413
|
+
return DomainStatus.model_validate(
|
|
414
|
+
self._c._request("GET", f"/v1/domains/{domain_id}/status")
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class _Suppressions(_Resource):
|
|
419
|
+
def list(
|
|
420
|
+
self, *, limit: Optional[int] = None, before: Optional[str] = None
|
|
421
|
+
) -> Page[Suppression]:
|
|
422
|
+
raw = self._c._request("GET", "/v1/suppressions", params={"limit": limit, "before": before})
|
|
423
|
+
return _page(raw, Suppression)
|
|
424
|
+
|
|
425
|
+
def create(self, address: str) -> Suppression:
|
|
426
|
+
return Suppression.model_validate(
|
|
427
|
+
self._c._request("POST", "/v1/suppressions", json={"address": address})
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def remove(self, suppression_id: str) -> None:
|
|
431
|
+
self._c._request("DELETE", f"/v1/suppressions/{suppression_id}")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class _Attachments(_Resource):
|
|
435
|
+
def list_for_message(self, message_id: str) -> Page[Attachment]:
|
|
436
|
+
raw = self._c._request("GET", f"/v1/messages/{message_id}/attachments")
|
|
437
|
+
return _page(raw, Attachment)
|
|
438
|
+
|
|
439
|
+
def presigned_url(self, attachment_id: str) -> Dict[str, Any]:
|
|
440
|
+
return self._c._request("GET", f"/v1/attachments/{attachment_id}/url")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _page(raw: Any, model: Type[BaseModel]) -> Page:
|
|
444
|
+
"""{data:[...], next_cursor} 또는 단순 배열 응답을 Page 로 정규화."""
|
|
445
|
+
if isinstance(raw, list):
|
|
446
|
+
return Page(data=[model.model_validate(x) for x in raw], next_cursor=None)
|
|
447
|
+
src = raw or {}
|
|
448
|
+
data = [model.model_validate(x) for x in src.get("data", [])]
|
|
449
|
+
return Page(data=data, next_cursor=src.get("next_cursor"))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""LoftBox SDK 예외."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LoftBoxError(Exception):
|
|
9
|
+
"""LoftBox API 호출 실패의 기본 예외.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
status_code: HTTP 상태 코드 (네트워크 오류 시 None).
|
|
13
|
+
message: 서버가 준 오류 메시지(가능하면) 또는 예외 메시지.
|
|
14
|
+
body: 파싱된 응답 본문(dict) 또는 원문(str), 없으면 None.
|
|
15
|
+
request_id: 서버 X-Request-Id 헤더(있으면) — 지원 문의용.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str,
|
|
21
|
+
*,
|
|
22
|
+
status_code: Optional[int] = None,
|
|
23
|
+
body: object = None,
|
|
24
|
+
request_id: Optional[str] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status_code = status_code
|
|
28
|
+
self.message = message
|
|
29
|
+
self.body = body
|
|
30
|
+
self.request_id = request_id
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthenticationError(LoftBoxError):
|
|
34
|
+
"""401 — API 키가 없거나 유효하지 않음."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PermissionError(LoftBoxError):
|
|
38
|
+
"""403 — API 키에 필요한 scope 가 없음."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotFoundError(LoftBoxError):
|
|
42
|
+
"""404 — 리소스를 찾을 수 없음."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConflictError(LoftBoxError):
|
|
46
|
+
"""409 — 멱등 키 충돌 / 상태 충돌(예: suppression 차단)."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RateLimitError(LoftBoxError):
|
|
50
|
+
"""429 — 발송 rate limit 초과.
|
|
51
|
+
|
|
52
|
+
`retry_after_secs` 가 있으면 그만큼 기다린 뒤 재시도.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self, *args: object, retry_after_secs: Optional[int] = None, **kwargs: object
|
|
57
|
+
) -> None:
|
|
58
|
+
super().__init__(*args, **kwargs) # type: ignore[arg-type]
|
|
59
|
+
self.retry_after_secs = retry_after_secs
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ValidationError(LoftBoxError):
|
|
63
|
+
"""400 — 요청 검증 실패."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def error_for_status(
|
|
67
|
+
status_code: int,
|
|
68
|
+
message: str,
|
|
69
|
+
*,
|
|
70
|
+
body: object = None,
|
|
71
|
+
request_id: Optional[str] = None,
|
|
72
|
+
retry_after_secs: Optional[int] = None,
|
|
73
|
+
) -> LoftBoxError:
|
|
74
|
+
"""HTTP 상태 코드를 구체 예외 타입으로 매핑."""
|
|
75
|
+
common = {"status_code": status_code, "body": body, "request_id": request_id}
|
|
76
|
+
if status_code in (400, 422):
|
|
77
|
+
return ValidationError(message, **common) # type: ignore[arg-type]
|
|
78
|
+
if status_code == 401:
|
|
79
|
+
return AuthenticationError(message, **common) # type: ignore[arg-type]
|
|
80
|
+
if status_code == 403:
|
|
81
|
+
return PermissionError(message, **common) # type: ignore[arg-type]
|
|
82
|
+
if status_code == 404:
|
|
83
|
+
return NotFoundError(message, **common) # type: ignore[arg-type]
|
|
84
|
+
if status_code == 409:
|
|
85
|
+
return ConflictError(message, **common) # type: ignore[arg-type]
|
|
86
|
+
if status_code == 429:
|
|
87
|
+
return RateLimitError(message, retry_after_secs=retry_after_secs, **common) # type: ignore[arg-type]
|
|
88
|
+
return LoftBoxError(message, **common) # type: ignore[arg-type]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""LoftBox 데이터 모델 (pydantic v2).
|
|
2
|
+
|
|
3
|
+
API 응답을 그대로 받되, 서버가 필드를 추가해도 깨지지 않도록 `extra="allow"`.
|
|
4
|
+
알 수 없는 필드는 보존되어 `.model_extra` 로 접근 가능.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Generic, List, Optional, TypeVar
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Base(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="allow")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Agent(_Base):
|
|
22
|
+
id: str
|
|
23
|
+
slug: Optional[str] = None
|
|
24
|
+
name: str
|
|
25
|
+
description: Optional[str] = None
|
|
26
|
+
created_at: Optional[datetime] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Mailbox(_Base):
|
|
30
|
+
id: str
|
|
31
|
+
agent_id: Optional[str] = None
|
|
32
|
+
address: str
|
|
33
|
+
display_name: Optional[str] = None
|
|
34
|
+
active: bool = True
|
|
35
|
+
created_at: Optional[datetime] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Attachment(_Base):
|
|
39
|
+
id: str
|
|
40
|
+
filename: Optional[str] = None
|
|
41
|
+
content_type: Optional[str] = None
|
|
42
|
+
size_bytes: Optional[int] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Message(_Base):
|
|
46
|
+
id: str
|
|
47
|
+
public_id: Optional[str] = None
|
|
48
|
+
mailbox_id: Optional[str] = None
|
|
49
|
+
thread_id: Optional[str] = None
|
|
50
|
+
direction: Optional[str] = None
|
|
51
|
+
status: Optional[str] = None
|
|
52
|
+
subject: Optional[str] = None
|
|
53
|
+
body_text: Optional[str] = None
|
|
54
|
+
body_html: Optional[str] = None
|
|
55
|
+
body_markdown: Optional[str] = None
|
|
56
|
+
# #229 수신 답장 본문(인용 제거)
|
|
57
|
+
extracted_text: Optional[str] = None
|
|
58
|
+
# #236 라벨
|
|
59
|
+
labels: List[str] = Field(default_factory=list)
|
|
60
|
+
# #241 예약발송 시각
|
|
61
|
+
scheduled_at: Optional[datetime] = None
|
|
62
|
+
sent_at: Optional[datetime] = None
|
|
63
|
+
received_at: Optional[datetime] = None
|
|
64
|
+
created_at: Optional[datetime] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Thread(_Base):
|
|
68
|
+
id: str
|
|
69
|
+
mailbox_id: Optional[str] = None
|
|
70
|
+
subject: Optional[str] = None
|
|
71
|
+
last_message_at: Optional[datetime] = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Webhook(_Base):
|
|
75
|
+
id: str
|
|
76
|
+
url: str
|
|
77
|
+
event_types: List[str] = Field(default_factory=list)
|
|
78
|
+
# 생성 응답에서 1회만 반환되는 서명 시크릿. 이후 조회에서는 None.
|
|
79
|
+
# 받은 즉시 안전한 곳에 저장할 것 — 로그에 남기지 말 것.
|
|
80
|
+
secret: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Domain(_Base):
|
|
84
|
+
id: str
|
|
85
|
+
domain: Optional[str] = None
|
|
86
|
+
status: Optional[str] = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DomainStatus(_Base):
|
|
90
|
+
"""`domains.status()` 응답 — id 없이 도메인 검증 상태."""
|
|
91
|
+
|
|
92
|
+
domain: Optional[str] = None
|
|
93
|
+
status: Optional[str] = None
|
|
94
|
+
inbound: Optional[object] = None
|
|
95
|
+
outbound: Optional[object] = None
|
|
96
|
+
next_actions: Optional[object] = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Suppression(_Base):
|
|
100
|
+
id: str
|
|
101
|
+
address: str
|
|
102
|
+
reason: Optional[str] = None
|
|
103
|
+
created_at: Optional[datetime] = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Page(_Base, Generic[T]):
|
|
107
|
+
"""cursor 페이지네이션 응답 래퍼.
|
|
108
|
+
|
|
109
|
+
`data` 는 항목 리스트, `next_cursor` 가 있으면 다음 페이지 요청에 전달.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
data: List[T] = Field(default_factory=list)
|
|
113
|
+
next_cursor: Optional[str] = None
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "loftbox"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "LoftBox Python SDK - Email infrastructure for AI agents"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "LoftBox" }]
|
|
9
|
+
keywords = ["email", "ai-agents", "smtp", "inbox", "loftbox"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.25",
|
|
12
|
+
"pydantic>=2",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://loftbox.net"
|
|
17
|
+
Repository = "https://github.com/TheMagicTower/loftbox-sdk-python"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
# 프레임워크 통합(LangChain/CrewAI)은 구현 후 추가 — 미구현 광고 금지.
|
|
21
|
+
dev = ["pytest>=7", "respx>=0.20", "mypy>=1.8", "ruff>=0.4"]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["loftbox"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 100
|
|
32
|
+
|
|
33
|
+
[tool.mypy]
|
|
34
|
+
ignore_missing_imports = true
|
|
File without changes
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""LoftBox SDK 단위 테스트 — httpx.MockTransport 로 네트워크 없이 검증."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Callable, List, Tuple
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from loftbox import (
|
|
12
|
+
ConflictError,
|
|
13
|
+
LoftBox,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
Captured = List[httpx.Request]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_client(handler: Callable[[httpx.Request], httpx.Response]) -> Tuple[LoftBox, Captured]:
|
|
23
|
+
captured: Captured = []
|
|
24
|
+
|
|
25
|
+
def wrapper(request: httpx.Request) -> httpx.Response:
|
|
26
|
+
captured.append(request)
|
|
27
|
+
return handler(request)
|
|
28
|
+
|
|
29
|
+
transport = httpx.MockTransport(wrapper)
|
|
30
|
+
http = httpx.Client(transport=transport, base_url="https://api.test")
|
|
31
|
+
client = LoftBox(api_key="lb_test_key", base_url="https://api.test", http_client=http)
|
|
32
|
+
return client, captured
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_requires_api_key() -> None:
|
|
36
|
+
with pytest.raises(ValueError):
|
|
37
|
+
LoftBox(api_key="")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_send_shapes_request_and_parses_message() -> None:
|
|
41
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
42
|
+
assert req.method == "POST"
|
|
43
|
+
assert req.url.path == "/v1/messages"
|
|
44
|
+
assert req.headers["authorization"] == "Bearer lb_test_key"
|
|
45
|
+
assert req.headers["idempotency-key"] == "key-1"
|
|
46
|
+
body = json.loads(req.content)
|
|
47
|
+
assert body["mailbox_id"] == "mb_1"
|
|
48
|
+
assert body["to"] == ["a@example.com"]
|
|
49
|
+
assert body["send_at"] == "2030-01-01T00:00:00+00:00"
|
|
50
|
+
return httpx.Response(201, json={"id": "msg_1", "status": "queued", "labels": []})
|
|
51
|
+
|
|
52
|
+
client, _ = make_client(handler)
|
|
53
|
+
msg = client.messages.send(
|
|
54
|
+
mailbox_id="mb_1",
|
|
55
|
+
to=["a@example.com"],
|
|
56
|
+
subject="hi",
|
|
57
|
+
body_text="b",
|
|
58
|
+
send_at="2030-01-01T00:00:00+00:00",
|
|
59
|
+
idempotency_key="key-1",
|
|
60
|
+
)
|
|
61
|
+
assert msg.id == "msg_1"
|
|
62
|
+
assert msg.status == "queued"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_list_messages_filters_and_pagination() -> None:
|
|
66
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
67
|
+
assert req.url.path == "/v1/messages"
|
|
68
|
+
params = dict(req.url.params)
|
|
69
|
+
assert params["label"] == "vip"
|
|
70
|
+
assert params["q"] == "invoice"
|
|
71
|
+
# None 값은 빠져야 함.
|
|
72
|
+
assert "status" not in params
|
|
73
|
+
return httpx.Response(
|
|
74
|
+
200,
|
|
75
|
+
json={"data": [{"id": "m1", "labels": ["vip"]}], "next_cursor": "c2"},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
client, _ = make_client(handler)
|
|
79
|
+
page = client.messages.list(label="vip", q="invoice", limit=10)
|
|
80
|
+
assert len(page.data) == 1
|
|
81
|
+
assert page.data[0].id == "m1"
|
|
82
|
+
assert page.next_cursor == "c2"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_list_handles_bare_array_response() -> None:
|
|
86
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
87
|
+
return httpx.Response(200, json=[{"id": "d1", "domain": "x.com"}])
|
|
88
|
+
|
|
89
|
+
client, _ = make_client(handler)
|
|
90
|
+
page = client.domains.list()
|
|
91
|
+
assert len(page.data) == 1
|
|
92
|
+
assert page.data[0].id == "d1"
|
|
93
|
+
assert page.next_cursor is None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_ack_inbox_posts_ids() -> None:
|
|
97
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
98
|
+
assert req.url.path == "/v1/mailboxes/mb_1/inbox/ack"
|
|
99
|
+
assert json.loads(req.content)["message_ids"] == ["m1", "m2"]
|
|
100
|
+
return httpx.Response(200, json={"acked": 2})
|
|
101
|
+
|
|
102
|
+
client, _ = make_client(handler)
|
|
103
|
+
client.mailboxes.ack_inbox("mb_1", ["m1", "m2"])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_remove_label_uses_path_segment() -> None:
|
|
107
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
108
|
+
assert req.method == "DELETE"
|
|
109
|
+
assert req.url.path == "/v1/messages/msg_1/labels/vip"
|
|
110
|
+
return httpx.Response(200, json={"id": "msg_1", "labels": []})
|
|
111
|
+
|
|
112
|
+
client, _ = make_client(handler)
|
|
113
|
+
msg = client.messages.remove_label("msg_1", "vip")
|
|
114
|
+
assert msg.labels == []
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_error_mapping() -> None:
|
|
118
|
+
cases = [
|
|
119
|
+
(400, ValidationError),
|
|
120
|
+
(404, NotFoundError),
|
|
121
|
+
(409, ConflictError),
|
|
122
|
+
]
|
|
123
|
+
for code, exc in cases:
|
|
124
|
+
client, _ = make_client(lambda req, c=code: httpx.Response(c, json={"message": f"err {c}"}))
|
|
125
|
+
with pytest.raises(exc) as ei:
|
|
126
|
+
client.messages.get("msg_x")
|
|
127
|
+
assert ei.value.status_code == code
|
|
128
|
+
assert "err" in ei.value.message
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_rate_limit_retry_after() -> None:
|
|
132
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
133
|
+
return httpx.Response(429, headers={"Retry-After": "12"}, json={"message": "slow down"})
|
|
134
|
+
|
|
135
|
+
client, _ = make_client(handler)
|
|
136
|
+
with pytest.raises(RateLimitError) as ei:
|
|
137
|
+
client.messages.send(mailbox_id="mb_1", to=["a@b.com"], subject="s", body_text="b")
|
|
138
|
+
assert ei.value.retry_after_secs == 12
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_nested_error_shape_and_retry_after_body() -> None:
|
|
142
|
+
# LoftBox 실제 오류 wire shape: {"error": {message, retry_after, ...}}.
|
|
143
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
144
|
+
return httpx.Response(
|
|
145
|
+
429,
|
|
146
|
+
json={"error": {"message": "rate limited", "code": 429, "retry_after": 7}},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
client, _ = make_client(handler)
|
|
150
|
+
with pytest.raises(RateLimitError) as ei:
|
|
151
|
+
client.messages.send(mailbox_id="mb_1", to=["a@b.com"], subject="s", body_text="b")
|
|
152
|
+
assert ei.value.message == "rate limited"
|
|
153
|
+
assert ei.value.retry_after_secs == 7
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_verify_signup_sends_email_and_token() -> None:
|
|
157
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
158
|
+
assert req.url.path == "/v1/auth/signup/verify"
|
|
159
|
+
body = json.loads(req.content)
|
|
160
|
+
assert body == {"email": "a@b.com", "verification_token": "tok-1"}
|
|
161
|
+
return httpx.Response(200, json={"ok": True})
|
|
162
|
+
|
|
163
|
+
client, _ = make_client(handler)
|
|
164
|
+
client.auth.verify_signup("a@b.com", "tok-1")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_remove_label_encodes_special_chars() -> None:
|
|
168
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
169
|
+
# 'needs review/urgent' → 슬래시·공백 인코딩되어 단일 세그먼트로(raw_path).
|
|
170
|
+
assert req.url.raw_path.decode() == "/v1/messages/msg_1/labels/needs%20review%2Furgent"
|
|
171
|
+
return httpx.Response(200, json={"id": "msg_1", "labels": []})
|
|
172
|
+
|
|
173
|
+
client, _ = make_client(handler)
|
|
174
|
+
client.messages.remove_label("msg_1", "needs review/urgent")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_domain_status_parses_without_id() -> None:
|
|
178
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
179
|
+
return httpx.Response(
|
|
180
|
+
200,
|
|
181
|
+
json={"domain": "x.com", "status": "verified", "inbound": {"mx": True}},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
client, _ = make_client(handler)
|
|
185
|
+
st = client.domains.status("dom_1")
|
|
186
|
+
assert st.domain == "x.com"
|
|
187
|
+
assert st.status == "verified"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_context_manager_closes() -> None:
|
|
191
|
+
client, _ = make_client(lambda req: httpx.Response(200, json={"data": []}))
|
|
192
|
+
with client as c:
|
|
193
|
+
c.agents.list()
|