loftbox 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
loftbox/__init__.py ADDED
@@ -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
+ ]
loftbox/client.py ADDED
@@ -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"))
loftbox/errors.py ADDED
@@ -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]
loftbox/models.py ADDED
@@ -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,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,7 @@
1
+ loftbox/__init__.py,sha256=ZiddTJenBTlafj7pRpk4Abwi_8sLsTNU872DymbK1As,821
2
+ loftbox/client.py,sha256=JUtk39n7D2BwnIxoflFmzeI3WW3N94VmyaKmX68JS-Q,15263
3
+ loftbox/errors.py,sha256=nCabAnIQoTqXN3UbAqNatfK0VzINfGsB4h--iHKVd-8,2831
4
+ loftbox/models.py,sha256=89itoVdkc-u448_T46q-5TH9WEUZpTZ2HU4Zt-gp0ks,2964
5
+ loftbox-0.1.0.dist-info/METADATA,sha256=nAiH_zZ-ySql7pxPqbCgZuRrQXFR9x8DV6kHIfJ9tSc,3040
6
+ loftbox-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ loftbox-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any