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.
@@ -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/*
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pyo
4
+ .pyd
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .env
9
+ .pytest_cache/
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
@@ -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()