diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e550cce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: pip install -e ".[dev]" + - name: Lint (ruff) + run: | + ruff check loftbox tests + ruff format --check loftbox tests + - name: Type check (mypy) + run: mypy loftbox + - name: Test + run: pytest -q diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..3098c57 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,37 @@ +name: Publish to PyPI + +# v* 태그 푸시 시 PyPI 게시. 게시 권한은 PYPI_API_TOKEN 시크릿(레포 설정에 +# caspar 가 주입). 태그 버전과 pyproject version 이 일치해야 함. +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install build tooling + run: pip install build twine + - name: Verify tag matches pyproject version + run: | + TAG="${GITHUB_REF_NAME#v}" + VER=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + if [ "$TAG" != "$VER" ]; then + echo "tag $TAG != pyproject version $VER"; exit 1 + fi + - name: Build + run: python -m build + - name: Check metadata + run: twine check dist/* + - name: Publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload dist/* diff --git a/README.md b/README.md index 1768338..cf7fbd8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # LoftBox Python SDK -AI 에이전트를 위한 이메일 인프라 SDK +AI 에이전트를 위한 이메일 인프라 SDK. ## 설치 @@ -8,30 +8,78 @@ AI 에이전트를 위한 이메일 인프라 SDK pip install loftbox ``` -## 사용법 +요구사항: Python 3.9+. + +## 빠른 시작 ```python from loftbox import LoftBox -client = LoftBox(api_key="lb_live_xxx") -client.messages.send( - mailbox_id="mb_xxx", - to="recipient@example.com", - subject="Hello", - body_text="World" -) +with LoftBox(api_key="lb_live_xxx") as client: + # 에이전트 + 메일박스 + agent = client.agents.create(name="Support Bot", slug="support-bot") + mailbox = client.mailboxes.create(agent.id, local_part="support") + + # 발송 (멱등 키로 중복 방지) + msg = client.messages.send( + mailbox_id=mailbox.id, + to=["recipient@example.com"], + subject="Hello", + body_text="World", + idempotency_key="welcome-42", + ) + + # 수신 폴링 → ack + inbox = client.mailboxes.list_inbox(mailbox.id) + client.mailboxes.ack_inbox(mailbox.id, [m.id for m in inbox.data]) ``` -## 프레임워크 통합 +## 기능 -### LangChain +- **발송**: `messages.send(...)` — 텍스트/HTML/Markdown 본문, 첨부, cc, 답장 헤더 +- **예약 발송**: `send(..., send_at="2030-01-01T09:00:00Z")` (미래 RFC3339) +- **멱등 발송**: `send(..., idempotency_key="...")` — 중복 발송 방지 +- **수신**: `mailboxes.list_inbox(...)` 폴링 + `ack_inbox(...)`. `message.extracted_text` 로 인용 제거된 답장 본문 +- **라벨**: `messages.add_labels(...)`, `remove_label(...)`, `list(label=...)` +- **전문검색**: `messages.list(q="...")`, `threads.list(q="...")` +- **스레드**: `threads.list(...)`, `list_messages(...)` +- **승인 워크플로**: `messages.approve(id, reason=...)`, `reject(...)` +- **웹훅**: `webhooks.create(agent_id, url, event_types)` +- **도메인 / suppression**: `domains.*`, `suppressions.*` -```bash -pip install loftbox[langchain] +## 오류 처리 + +모든 호출은 실패 시 `LoftBoxError` 하위 예외를 던집니다: + +```python +from loftbox import RateLimitError, NotFoundError, ValidationError + +try: + client.messages.send(...) +except RateLimitError as e: + print(f"{e.retry_after_secs}s 후 재시도") +except (NotFoundError, ValidationError) as e: + print(e.status_code, e.message) ``` -### CrewAI +## 페이지네이션 -```bash -pip install loftbox[crewai] +목록 메서드는 `Page` 를 반환합니다 (`.data`, `.next_cursor`): + +```python +page = client.messages.list(mailbox_id=mailbox.id, limit=50) +while True: + for m in page.data: + ... + if not page.next_cursor: + break + page = client.messages.list(mailbox_id=mailbox.id, limit=50, cursor=page.next_cursor) ``` + +## 예제 + +`examples/quickstart.py` 참고. + +## 라이선스 + +MIT diff --git a/examples/.gitkeep b/examples/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/examples/quickstart.py b/examples/quickstart.py new file mode 100644 index 0000000..3be53df --- /dev/null +++ b/examples/quickstart.py @@ -0,0 +1,61 @@ +"""LoftBox Python SDK 퀵스타트. + +실행: + export LOFTBOX_API_KEY=lb_live_xxx + python examples/quickstart.py +""" + +import os + +from loftbox import LoftBox, RateLimitError + + +def main() -> None: + api_key = os.environ["LOFTBOX_API_KEY"] + + with LoftBox(api_key=api_key) as client: + # 1. 에이전트 + 메일박스 준비 (최초 1회). + agent = client.agents.create(name="Support Bot", slug="support-bot") + mailbox = client.mailboxes.create(agent.id, local_part="support") + print(f"mailbox: {mailbox.address}") + + # 2. 발송 (멱등 키로 중복 방지). + try: + msg = client.messages.send( + mailbox_id=mailbox.id, + to=["customer@example.com"], + subject="안녕하세요", + body_text="LoftBox 에서 보냅니다.", + idempotency_key="welcome-customer-42", + ) + print(f"sent: {msg.id} status={msg.status}") + except RateLimitError as e: + print(f"rate limited, retry after {e.retry_after_secs}s") + + # 3. 예약 발송 (1시간 뒤). + from datetime import datetime, timedelta, timezone + + client.messages.send( + mailbox_id=mailbox.id, + to=["customer@example.com"], + subject="리마인더", + body_text="예약 발송 메시지", + send_at=(datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), + ) + + # 4. 수신 폴링 → 처리 → ack. + inbox = client.mailboxes.list_inbox(mailbox.id, limit=20) + for incoming in inbox.data: + print(f"received: {incoming.subject} (extracted: {incoming.extracted_text!r})") + if inbox.data: + client.mailboxes.ack_inbox(mailbox.id, [m.id for m in inbox.data]) + + # 5. 라벨링 + 전문검색. + if inbox.data: + client.messages.add_labels(inbox.data[0].id, ["needs-reply", "vip"]) + results = client.messages.list(q="invoice", label="vip", limit=10) + print(f"search hits: {len(results.data)}") + + +if __name__ == "__main__": + main() diff --git a/loftbox/__init__.py b/loftbox/__init__.py index 846919f..16a12be 100644 --- a/loftbox/__init__.py +++ b/loftbox/__init__.py @@ -1,7 +1,48 @@ -"""LoftBox Python SDK""" +"""LoftBox Python SDK — AI 에이전트를 위한 이메일 인프라.""" from .client import LoftBox -from .models import Message, Agent, Mailbox +from .errors import ( + AuthenticationError, + ConflictError, + LoftBoxError, + NotFoundError, + PermissionError, + RateLimitError, + ValidationError, +) +from .models import ( + Agent, + Attachment, + Domain, + DomainStatus, + Mailbox, + Message, + Page, + Suppression, + Thread, + Webhook, +) __version__ = "0.1.0" -__all__ = ["LoftBox", "Message", "Agent", "Mailbox"] +__all__ = [ + "LoftBox", + # models + "Agent", + "Attachment", + "Domain", + "DomainStatus", + "Mailbox", + "Message", + "Page", + "Suppression", + "Thread", + "Webhook", + # errors + "LoftBoxError", + "AuthenticationError", + "PermissionError", + "NotFoundError", + "ConflictError", + "RateLimitError", + "ValidationError", +] diff --git a/loftbox/client.py b/loftbox/client.py index 4088865..6f65081 100644 --- a/loftbox/client.py +++ b/loftbox/client.py @@ -1,17 +1,449 @@ -"""LoftBox API Client""" +"""LoftBox API 클라이언트 (동기, httpx 기반). -from typing import Optional +AI 에이전트를 위한 이메일 인프라. 핵심 플로우: 회원가입 → 에이전트/메일박스 +생성 → 발송 → 수신 폴링/ack → 스레드 → 웹훅 → 승인. + +사용 예: + from loftbox import LoftBox + + client = LoftBox(api_key="lb_live_xxx") + msg = client.messages.send( + mailbox_id="mb_xxx", + to=["recipient@example.com"], + subject="Hello", + body_text="World", + ) +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Type +from urllib.parse import quote + +import httpx +from pydantic import BaseModel + +from .errors import LoftBoxError, error_for_status +from .models import ( + Agent, + Attachment, + Domain, + DomainStatus, + Mailbox, + Message, + Page, + Suppression, + Thread, + Webhook, +) + +DEFAULT_BASE_URL = "https://api.loftbox.net" +DEFAULT_TIMEOUT = 30.0 +USER_AGENT = "loftbox-python/0.1.0" class LoftBox: - """LoftBox API 클라이언트""" + """LoftBox API 클라이언트. + + Args: + api_key: API 키 (`Authorization: Bearer` 로 전송). + base_url: API 베이스 URL (기본 https://api.loftbox.net). + timeout: 요청 타임아웃(초). + http_client: 직접 구성한 httpx.Client (테스트/프록시용). 주면 timeout 은 + 그 클라이언트 설정을 따른다. + """ - def __init__(self, api_key: str, base_url: Optional[str] = None): + def __init__( + self, + api_key: str, + base_url: Optional[str] = None, + timeout: float = DEFAULT_TIMEOUT, + http_client: Optional[httpx.Client] = None, + ) -> None: + if not api_key: + raise ValueError("api_key 는 필수입니다") self.api_key = api_key - self.base_url = base_url or "https://api.loftbox.net" + self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/") + self._owns_client = http_client is None + self._http = http_client or httpx.Client(timeout=timeout) - def _headers(self) -> dict: - return { + # 리소스 네임스페이스 + self.auth = _Auth(self) + self.agents = _Agents(self) + self.mailboxes = _Mailboxes(self) + self.messages = _Messages(self) + self.threads = _Threads(self) + self.webhooks = _Webhooks(self) + self.domains = _Domains(self) + self.suppressions = _Suppressions(self) + self.attachments = _Attachments(self) + + # -- transport ---------------------------------------------------------- + + def _request( + self, + method: str, + path: str, + *, + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Any: + url = f"{self.base_url}{path}" + hdrs = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": USER_AGENT, + } + if headers: + hdrs.update(headers) + # None 값 query param 제거. + clean_params = {k: v for k, v in (params or {}).items() if v is not None} + try: + resp = self._http.request( + method, url, json=json, params=clean_params or None, headers=hdrs + ) + except httpx.HTTPError as e: # 네트워크/타임아웃 등 + raise LoftBoxError(f"요청 실패: {e}") from e + + request_id = resp.headers.get("x-request-id") + if resp.status_code >= 400: + body: Any = None + message = f"HTTP {resp.status_code}" + retry_after_secs: Optional[int] = None + try: + body = resp.json() + except Exception: + body = resp.text or None + if body: + message = str(body) + if isinstance(body, dict): + # LoftBox 오류 wire shape: {"error": {message, code, retry_after, ...}}. + # top-level message/detail 도 방어적으로 허용. + err = body.get("error") + if isinstance(err, dict): + message = err.get("message") or message + ra = err.get("retry_after") + if isinstance(ra, int): + retry_after_secs = ra + else: + message = ( + body.get("message") + or (err if isinstance(err, str) else None) + or body.get("detail") + or message + ) + # Retry-After 헤더가 있으면 우선(표준). + header_ra = resp.headers.get("retry-after") + if header_ra and header_ra.isdigit(): + retry_after_secs = int(header_ra) + raise error_for_status( + resp.status_code, + message, + body=body, + request_id=request_id, + retry_after_secs=retry_after_secs, + ) + + if resp.status_code == 204 or not resp.content: + return None + return resp.json() + + # -- lifecycle ---------------------------------------------------------- + + def close(self) -> None: + """소유한 httpx 클라이언트를 닫는다(외부 주입 클라이언트는 닫지 않음).""" + if self._owns_client: + self._http.close() + + def __enter__(self) -> "LoftBox": + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + +class _Resource: + def __init__(self, client: LoftBox) -> None: + self._c = client + + +class _Auth(_Resource): + def signup( + self, + email: str, + organization_name: str, + slug: Optional[str] = None, + ) -> Dict[str, Any]: + """조직 가입 요청 — 이메일 검증 링크 발송. 반환은 서버 안내 페이로드.""" + return self._c._request( + "POST", + "/v1/auth/signup", + json={"email": email, "organization_name": organization_name, "slug": slug}, + ) + + def verify_signup(self, email: str, verification_token: str) -> Dict[str, Any]: + """이메일 + 검증 토큰으로 가입 확정.""" + return self._c._request( + "POST", + "/v1/auth/signup/verify", + json={"email": email, "verification_token": verification_token}, + ) + + +class _Agents(_Resource): + def create( + self, + name: str, + slug: str, + *, + description: Optional[str] = None, + purpose: Optional[str] = None, + external_id: Optional[str] = None, + owner_label: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> Agent: + body = { + "name": name, + "slug": slug, + "description": description, + "purpose": purpose, + "external_id": external_id, + "owner_label": owner_label, + "metadata": metadata, + } + return Agent.model_validate(self._c._request("POST", "/v1/agents", json=body)) + + def get(self, agent_id: str) -> Agent: + return Agent.model_validate(self._c._request("GET", f"/v1/agents/{agent_id}")) + + def list(self, *, limit: Optional[int] = None, cursor: Optional[str] = None) -> Page[Agent]: + raw = self._c._request("GET", "/v1/agents", params={"limit": limit, "cursor": cursor}) + return _page(raw, Agent) + + +class _Mailboxes(_Resource): + def create( + self, + agent_id: str, + local_part: str, + *, + domain_id: Optional[str] = None, + display_name: Optional[str] = None, + webhook_url: Optional[str] = None, + retention_days: Optional[int] = None, + ) -> Mailbox: + body = { + "local_part": local_part, + "domain_id": domain_id, + "display_name": display_name, + "webhook_url": webhook_url, + "retention_days": retention_days, + } + return Mailbox.model_validate( + self._c._request("POST", f"/v1/agents/{agent_id}/mailboxes", json=body) + ) + + def list_by_agent(self, agent_id: str) -> Page[Mailbox]: + raw = self._c._request("GET", f"/v1/agents/{agent_id}/mailboxes") + return _page(raw, Mailbox) + + def list_inbox( + self, + mailbox_id: str, + *, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Page[Message]: + """미확인(unacked) 수신 메시지 폴링.""" + raw = self._c._request( + "GET", + f"/v1/mailboxes/{mailbox_id}/inbox", + params={"limit": limit, "cursor": cursor}, + ) + return _page(raw, Message) + + def ack_inbox(self, mailbox_id: str, message_ids: List[str]) -> Any: + """수신 메시지 확인 처리 — 다음 폴링에서 제외.""" + return self._c._request( + "POST", + f"/v1/mailboxes/{mailbox_id}/inbox/ack", + json={"message_ids": message_ids}, + ) + + +class _Messages(_Resource): + def send( + self, + mailbox_id: str, + to: List[str], + subject: str, + *, + body_text: Optional[str] = None, + body_html: Optional[str] = None, + body_markdown: Optional[str] = None, + cc: Optional[List[str]] = None, + in_reply_to: Optional[str] = None, + references: Optional[List[str]] = None, + metadata: Optional[Dict[str, Any]] = None, + attachments: Optional[List[Dict[str, Any]]] = None, + send_at: Optional[str] = None, + idempotency_key: Optional[str] = None, + ) -> Message: + """발송 큐에 메시지 진입. + + send_at(RFC3339 미래 시각)을 주면 예약발송. idempotency_key 를 주면 + 같은 키+같은 내용 재요청은 원본 메시지를 그대로 반환(중복 발송 방지). + """ + body: Dict[str, Any] = { + "mailbox_id": mailbox_id, + "to": to, + "subject": subject, + "body_text": body_text, + "body_html": body_html, + "body_markdown": body_markdown, + "cc": cc or [], + "in_reply_to": in_reply_to, + "references": references or [], + "metadata": metadata, + "attachments": attachments or [], + "send_at": send_at, } + headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None + return Message.model_validate( + self._c._request("POST", "/v1/messages", json=body, headers=headers) + ) + + def get(self, message_id: str) -> Message: + return Message.model_validate(self._c._request("GET", f"/v1/messages/{message_id}")) + + def list( + self, + *, + mailbox_id: Optional[str] = None, + direction: Optional[str] = None, + status: Optional[str] = None, + label: Optional[str] = None, + q: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Page[Message]: + """메시지 목록 — mailbox/direction/status/label 필터, q 전문검색.""" + raw = self._c._request( + "GET", + "/v1/messages", + params={ + "mailbox_id": mailbox_id, + "direction": direction, + "status": status, + "label": label, + "q": q, + "limit": limit, + "cursor": cursor, + }, + ) + return _page(raw, Message) + + def add_labels(self, message_id: str, labels: List[str]) -> Message: + return Message.model_validate( + self._c._request("POST", f"/v1/messages/{message_id}/labels", json={"labels": labels}) + ) + + def remove_label(self, message_id: str, label: str) -> Message: + # 라벨을 경로 세그먼트로 안전 인코딩(공백/슬래시 등). safe="" 로 '/' 도 인코딩. + seg = quote(label, safe="") + return Message.model_validate( + self._c._request("DELETE", f"/v1/messages/{message_id}/labels/{seg}") + ) + + def approve(self, message_id: str, reason: str) -> Message: + return Message.model_validate( + self._c._request("POST", f"/v1/messages/{message_id}/approve", json={"reason": reason}) + ) + + def reject(self, message_id: str, reason: str) -> Message: + return Message.model_validate( + self._c._request("POST", f"/v1/messages/{message_id}/reject", json={"reason": reason}) + ) + + +class _Threads(_Resource): + def list( + self, + *, + mailbox_id: Optional[str] = None, + q: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Page[Thread]: + raw = self._c._request( + "GET", + "/v1/threads", + params={"mailbox_id": mailbox_id, "q": q, "limit": limit, "cursor": cursor}, + ) + return _page(raw, Thread) + + def list_messages(self, thread_id: str) -> Page[Message]: + raw = self._c._request("GET", f"/v1/threads/{thread_id}/messages") + return _page(raw, Message) + + +class _Webhooks(_Resource): + def create(self, agent_id: str, url: str, event_types: List[str]) -> Webhook: + return Webhook.model_validate( + self._c._request( + "POST", + f"/v1/agents/{agent_id}/webhooks", + json={"url": url, "event_types": event_types}, + ) + ) + + +class _Domains(_Resource): + def create(self, domain: str) -> Domain: + return Domain.model_validate( + self._c._request("POST", "/v1/domains", json={"domain": domain}) + ) + + def list(self) -> Page[Domain]: + return _page(self._c._request("GET", "/v1/domains"), Domain) + + def status(self, domain_id: str) -> DomainStatus: + return DomainStatus.model_validate( + self._c._request("GET", f"/v1/domains/{domain_id}/status") + ) + + +class _Suppressions(_Resource): + def list( + self, *, limit: Optional[int] = None, before: Optional[str] = None + ) -> Page[Suppression]: + raw = self._c._request("GET", "/v1/suppressions", params={"limit": limit, "before": before}) + return _page(raw, Suppression) + + def create(self, address: str) -> Suppression: + return Suppression.model_validate( + self._c._request("POST", "/v1/suppressions", json={"address": address}) + ) + + def remove(self, suppression_id: str) -> None: + self._c._request("DELETE", f"/v1/suppressions/{suppression_id}") + + +class _Attachments(_Resource): + def list_for_message(self, message_id: str) -> Page[Attachment]: + raw = self._c._request("GET", f"/v1/messages/{message_id}/attachments") + return _page(raw, Attachment) + + def presigned_url(self, attachment_id: str) -> Dict[str, Any]: + return self._c._request("GET", f"/v1/attachments/{attachment_id}/url") + + +def _page(raw: Any, model: Type[BaseModel]) -> Page: + """{data:[...], next_cursor} 또는 단순 배열 응답을 Page 로 정규화.""" + if isinstance(raw, list): + return Page(data=[model.model_validate(x) for x in raw], next_cursor=None) + src = raw or {} + data = [model.model_validate(x) for x in src.get("data", [])] + return Page(data=data, next_cursor=src.get("next_cursor")) diff --git a/loftbox/errors.py b/loftbox/errors.py new file mode 100644 index 0000000..a6cdf14 --- /dev/null +++ b/loftbox/errors.py @@ -0,0 +1,88 @@ +"""LoftBox SDK 예외.""" + +from __future__ import annotations + +from typing import Optional + + +class LoftBoxError(Exception): + """LoftBox API 호출 실패의 기본 예외. + + Attributes: + status_code: HTTP 상태 코드 (네트워크 오류 시 None). + message: 서버가 준 오류 메시지(가능하면) 또는 예외 메시지. + body: 파싱된 응답 본문(dict) 또는 원문(str), 없으면 None. + request_id: 서버 X-Request-Id 헤더(있으면) — 지원 문의용. + """ + + def __init__( + self, + message: str, + *, + status_code: Optional[int] = None, + body: object = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.message = message + self.body = body + self.request_id = request_id + + +class AuthenticationError(LoftBoxError): + """401 — API 키가 없거나 유효하지 않음.""" + + +class PermissionError(LoftBoxError): + """403 — API 키에 필요한 scope 가 없음.""" + + +class NotFoundError(LoftBoxError): + """404 — 리소스를 찾을 수 없음.""" + + +class ConflictError(LoftBoxError): + """409 — 멱등 키 충돌 / 상태 충돌(예: suppression 차단).""" + + +class RateLimitError(LoftBoxError): + """429 — 발송 rate limit 초과. + + `retry_after_secs` 가 있으면 그만큼 기다린 뒤 재시도. + """ + + def __init__( + self, *args: object, retry_after_secs: Optional[int] = None, **kwargs: object + ) -> None: + super().__init__(*args, **kwargs) # type: ignore[arg-type] + self.retry_after_secs = retry_after_secs + + +class ValidationError(LoftBoxError): + """400 — 요청 검증 실패.""" + + +def error_for_status( + status_code: int, + message: str, + *, + body: object = None, + request_id: Optional[str] = None, + retry_after_secs: Optional[int] = None, +) -> LoftBoxError: + """HTTP 상태 코드를 구체 예외 타입으로 매핑.""" + common = {"status_code": status_code, "body": body, "request_id": request_id} + if status_code in (400, 422): + return ValidationError(message, **common) # type: ignore[arg-type] + if status_code == 401: + return AuthenticationError(message, **common) # type: ignore[arg-type] + if status_code == 403: + return PermissionError(message, **common) # type: ignore[arg-type] + if status_code == 404: + return NotFoundError(message, **common) # type: ignore[arg-type] + if status_code == 409: + return ConflictError(message, **common) # type: ignore[arg-type] + if status_code == 429: + return RateLimitError(message, retry_after_secs=retry_after_secs, **common) # type: ignore[arg-type] + return LoftBoxError(message, **common) # type: ignore[arg-type] diff --git a/loftbox/models.py b/loftbox/models.py index f24c572..2b7510c 100644 --- a/loftbox/models.py +++ b/loftbox/models.py @@ -1,33 +1,113 @@ -"""LoftBox 데이터 모델""" +"""LoftBox 데이터 모델 (pydantic v2). + +API 응답을 그대로 받되, 서버가 필드를 추가해도 깨지지 않도록 `extra="allow"`. +알 수 없는 필드는 보존되어 `.model_extra` 로 접근 가능. +""" + +from __future__ import annotations -from dataclasses import dataclass -from typing import Optional from datetime import datetime +from typing import Generic, List, Optional, TypeVar + +from pydantic import BaseModel, ConfigDict, Field + +T = TypeVar("T") + +class _Base(BaseModel): + model_config = ConfigDict(extra="allow") -@dataclass -class Agent: + +class Agent(_Base): id: str + slug: Optional[str] = None name: str description: Optional[str] = None created_at: Optional[datetime] = None -@dataclass -class Mailbox: +class Mailbox(_Base): id: str - agent_id: str + agent_id: Optional[str] = None address: str display_name: Optional[str] = None active: bool = True + created_at: Optional[datetime] = None -@dataclass -class Message: +class Attachment(_Base): id: str - mailbox_id: str - direction: str + filename: Optional[str] = None + content_type: Optional[str] = None + size_bytes: Optional[int] = None + + +class Message(_Base): + id: str + public_id: Optional[str] = None + mailbox_id: Optional[str] = None + thread_id: Optional[str] = None + direction: Optional[str] = None + status: Optional[str] = None subject: Optional[str] = None body_text: Optional[str] = None body_html: Optional[str] = None - status: str = "queued" + body_markdown: Optional[str] = None + # #229 수신 답장 본문(인용 제거) + extracted_text: Optional[str] = None + # #236 라벨 + labels: List[str] = Field(default_factory=list) + # #241 예약발송 시각 + scheduled_at: Optional[datetime] = None + sent_at: Optional[datetime] = None + received_at: Optional[datetime] = None + created_at: Optional[datetime] = None + + +class Thread(_Base): + id: str + mailbox_id: Optional[str] = None + subject: Optional[str] = None + last_message_at: Optional[datetime] = None + + +class Webhook(_Base): + id: str + url: str + event_types: List[str] = Field(default_factory=list) + # 생성 응답에서 1회만 반환되는 서명 시크릿. 이후 조회에서는 None. + # 받은 즉시 안전한 곳에 저장할 것 — 로그에 남기지 말 것. + secret: Optional[str] = None + + +class Domain(_Base): + id: str + domain: Optional[str] = None + status: Optional[str] = None + + +class DomainStatus(_Base): + """`domains.status()` 응답 — id 없이 도메인 검증 상태.""" + + domain: Optional[str] = None + status: Optional[str] = None + inbound: Optional[object] = None + outbound: Optional[object] = None + next_actions: Optional[object] = None + + +class Suppression(_Base): + id: str + address: str + reason: Optional[str] = None + created_at: Optional[datetime] = None + + +class Page(_Base, Generic[T]): + """cursor 페이지네이션 응답 래퍼. + + `data` 는 항목 리스트, `next_cursor` 가 있으면 다음 페이지 요청에 전달. + """ + + data: List[T] = Field(default_factory=list) + next_cursor: Optional[str] = None diff --git a/pyproject.toml b/pyproject.toml index 787b818..85f18c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,17 +2,33 @@ name = "loftbox" version = "0.1.0" description = "LoftBox Python SDK - Email infrastructure for AI agents" +readme = "README.md" requires-python = ">=3.9" +license = { text = "MIT" } +authors = [{ name = "LoftBox" }] +keywords = ["email", "ai-agents", "smtp", "inbox", "loftbox"] dependencies = [ "httpx>=0.25", "pydantic>=2", ] +[project.urls] +Homepage = "https://loftbox.net" +Repository = "https://github.com/TheMagicTower/loftbox-sdk-python" + [project.optional-dependencies] -langchain = ["langchain-core>=0.1"] -crewai = ["crewai>=0.1"] -dev = ["pytest>=7", "pytest-asyncio>=0.21"] +# 프레임워크 통합(LangChain/CrewAI)은 구현 후 추가 — 미구현 광고 금지. +dev = ["pytest>=7", "respx>=0.20", "mypy>=1.8", "ruff>=0.4"] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["loftbox"] + +[tool.ruff] +line-length = 100 + +[tool.mypy] +ignore_missing_imports = true diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..ea07c40 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,193 @@ +"""LoftBox SDK 단위 테스트 — httpx.MockTransport 로 네트워크 없이 검증.""" + +from __future__ import annotations + +import json +from typing import Callable, List, Tuple + +import httpx +import pytest + +from loftbox import ( + ConflictError, + LoftBox, + NotFoundError, + RateLimitError, + ValidationError, +) + +Captured = List[httpx.Request] + + +def make_client(handler: Callable[[httpx.Request], httpx.Response]) -> Tuple[LoftBox, Captured]: + captured: Captured = [] + + def wrapper(request: httpx.Request) -> httpx.Response: + captured.append(request) + return handler(request) + + transport = httpx.MockTransport(wrapper) + http = httpx.Client(transport=transport, base_url="https://api.test") + client = LoftBox(api_key="lb_test_key", base_url="https://api.test", http_client=http) + return client, captured + + +def test_requires_api_key() -> None: + with pytest.raises(ValueError): + LoftBox(api_key="") + + +def test_send_shapes_request_and_parses_message() -> None: + def handler(req: httpx.Request) -> httpx.Response: + assert req.method == "POST" + assert req.url.path == "/v1/messages" + assert req.headers["authorization"] == "Bearer lb_test_key" + assert req.headers["idempotency-key"] == "key-1" + body = json.loads(req.content) + assert body["mailbox_id"] == "mb_1" + assert body["to"] == ["a@example.com"] + assert body["send_at"] == "2030-01-01T00:00:00+00:00" + return httpx.Response(201, json={"id": "msg_1", "status": "queued", "labels": []}) + + client, _ = make_client(handler) + msg = client.messages.send( + mailbox_id="mb_1", + to=["a@example.com"], + subject="hi", + body_text="b", + send_at="2030-01-01T00:00:00+00:00", + idempotency_key="key-1", + ) + assert msg.id == "msg_1" + assert msg.status == "queued" + + +def test_list_messages_filters_and_pagination() -> None: + def handler(req: httpx.Request) -> httpx.Response: + assert req.url.path == "/v1/messages" + params = dict(req.url.params) + assert params["label"] == "vip" + assert params["q"] == "invoice" + # None 값은 빠져야 함. + assert "status" not in params + return httpx.Response( + 200, + json={"data": [{"id": "m1", "labels": ["vip"]}], "next_cursor": "c2"}, + ) + + client, _ = make_client(handler) + page = client.messages.list(label="vip", q="invoice", limit=10) + assert len(page.data) == 1 + assert page.data[0].id == "m1" + assert page.next_cursor == "c2" + + +def test_list_handles_bare_array_response() -> None: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=[{"id": "d1", "domain": "x.com"}]) + + client, _ = make_client(handler) + page = client.domains.list() + assert len(page.data) == 1 + assert page.data[0].id == "d1" + assert page.next_cursor is None + + +def test_ack_inbox_posts_ids() -> None: + def handler(req: httpx.Request) -> httpx.Response: + assert req.url.path == "/v1/mailboxes/mb_1/inbox/ack" + assert json.loads(req.content)["message_ids"] == ["m1", "m2"] + return httpx.Response(200, json={"acked": 2}) + + client, _ = make_client(handler) + client.mailboxes.ack_inbox("mb_1", ["m1", "m2"]) + + +def test_remove_label_uses_path_segment() -> None: + def handler(req: httpx.Request) -> httpx.Response: + assert req.method == "DELETE" + assert req.url.path == "/v1/messages/msg_1/labels/vip" + return httpx.Response(200, json={"id": "msg_1", "labels": []}) + + client, _ = make_client(handler) + msg = client.messages.remove_label("msg_1", "vip") + assert msg.labels == [] + + +def test_error_mapping() -> None: + cases = [ + (400, ValidationError), + (404, NotFoundError), + (409, ConflictError), + ] + for code, exc in cases: + client, _ = make_client(lambda req, c=code: httpx.Response(c, json={"message": f"err {c}"})) + with pytest.raises(exc) as ei: + client.messages.get("msg_x") + assert ei.value.status_code == code + assert "err" in ei.value.message + + +def test_rate_limit_retry_after() -> None: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(429, headers={"Retry-After": "12"}, json={"message": "slow down"}) + + client, _ = make_client(handler) + with pytest.raises(RateLimitError) as ei: + client.messages.send(mailbox_id="mb_1", to=["a@b.com"], subject="s", body_text="b") + assert ei.value.retry_after_secs == 12 + + +def test_nested_error_shape_and_retry_after_body() -> None: + # LoftBox 실제 오류 wire shape: {"error": {message, retry_after, ...}}. + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 429, + json={"error": {"message": "rate limited", "code": 429, "retry_after": 7}}, + ) + + client, _ = make_client(handler) + with pytest.raises(RateLimitError) as ei: + client.messages.send(mailbox_id="mb_1", to=["a@b.com"], subject="s", body_text="b") + assert ei.value.message == "rate limited" + assert ei.value.retry_after_secs == 7 + + +def test_verify_signup_sends_email_and_token() -> None: + def handler(req: httpx.Request) -> httpx.Response: + assert req.url.path == "/v1/auth/signup/verify" + body = json.loads(req.content) + assert body == {"email": "a@b.com", "verification_token": "tok-1"} + return httpx.Response(200, json={"ok": True}) + + client, _ = make_client(handler) + client.auth.verify_signup("a@b.com", "tok-1") + + +def test_remove_label_encodes_special_chars() -> None: + def handler(req: httpx.Request) -> httpx.Response: + # 'needs review/urgent' → 슬래시·공백 인코딩되어 단일 세그먼트로(raw_path). + assert req.url.raw_path.decode() == "/v1/messages/msg_1/labels/needs%20review%2Furgent" + return httpx.Response(200, json={"id": "msg_1", "labels": []}) + + client, _ = make_client(handler) + client.messages.remove_label("msg_1", "needs review/urgent") + + +def test_domain_status_parses_without_id() -> None: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"domain": "x.com", "status": "verified", "inbound": {"mx": True}}, + ) + + client, _ = make_client(handler) + st = client.domains.status("dom_1") + assert st.domain == "x.com" + assert st.status == "verified" + + +def test_context_manager_closes() -> None: + client, _ = make_client(lambda req: httpx.Response(200, json={"data": []})) + with client as c: + c.agents.list()