Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,32 @@ with LoftBox(api_key="lb_live_xxx") as client:
- **승인 워크플로**: `messages.approve(id, reason=...)`, `reject(...)`
- **웹훅**: `webhooks.create(agent_id, url, event_types)`
- **도메인 / suppression**: `domains.*`, `suppressions.*`
- **인바운드 안전 (#369/#370)**: `message.injection_score`/`injection_categories` (프롬프트-인젝션 휴리스틱 신호, 차단 아님) + `inbound_rules.*` (발신자 allow/block)

## 인바운드 안전 (프롬프트-인젝션 신호 + 발신자 통제)

수신 메일은 임의 외부 발신자가 보낸 untrusted 입력입니다. LoftBox 는 두 가지 통제를 제공합니다.

```python
# #369: 수신 메시지마다 프롬프트-인젝션 휴리스틱 점수(0~1) + 발화 카테고리.
# 신호 전용 — LoftBox 는 차단하지 않으며, 에이전트가 판단합니다.
for msg in client.mailboxes.list_inbox(mailbox_id).data:
if (msg.injection_score or 0) >= 0.7:
# 예: 사람 승인 후에만 메일 내 지시를 따른다.
require_human_review(msg)

# #370: 발신자 allow/block 리스트로 *수신 자체*를 통제(SMTP 550 거부).
client.inbound_rules.create(rule_type="block", pattern_type="domain", pattern="evil.com")
client.inbound_rules.create(
rule_type="allow", pattern_type="address",
pattern="partner@trusted.com", mailbox_id="mb_xxx", # 미지정 시 org 전체
)
rules = client.inbound_rules.list(mailbox_id="mb_xxx")
client.inbound_rules.remove("rule_id")
```

allow 리스트가 하나라도 있으면 미매치 발신자는 거부됩니다(화이트리스트). 평가는 위조 가능한
`From` 헤더가 아니라 SMTP envelope sender 로 합니다.

## 오류 처리

Expand Down
4 changes: 3 additions & 1 deletion loftbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Attachment,
Domain,
DomainStatus,
InboundSenderRule,
Mailbox,
Message,
Page,
Expand All @@ -23,14 +24,15 @@
Webhook,
)

__version__ = "0.2.0"
__version__ = "0.3.0"
__all__ = [
"LoftBox",
# models
"Agent",
"Attachment",
"Domain",
"DomainStatus",
"InboundSenderRule",
"Mailbox",
"Message",
"Page",
Expand Down
50 changes: 49 additions & 1 deletion loftbox/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
Attachment,
Domain,
DomainStatus,
InboundSenderRule,
Mailbox,
Message,
Page,
Expand All @@ -39,7 +40,7 @@

DEFAULT_BASE_URL = "https://api.loftbox.net"
DEFAULT_TIMEOUT = 30.0
USER_AGENT = "loftbox-python/0.2.0"
USER_AGENT = "loftbox-python/0.3.0"


class LoftBox:
Expand Down Expand Up @@ -76,6 +77,7 @@ def __init__(
self.webhooks = _Webhooks(self)
self.domains = _Domains(self)
self.suppressions = _Suppressions(self)
self.inbound_rules = _InboundRules(self)
self.attachments = _Attachments(self)

# -- transport ----------------------------------------------------------
Expand Down Expand Up @@ -431,6 +433,52 @@ def remove(self, suppression_id: str) -> None:
self._c._request("DELETE", f"/v1/suppressions/{suppression_id}")


class _InboundRules(_Resource):
"""#370 인바운드 발신자 allow/block 리스트 — 수신 통제."""

def list(
self,
*,
mailbox_id: Optional[str] = None,
limit: Optional[int] = None,
before: Optional[str] = None,
) -> Page[InboundSenderRule]:
raw = self._c._request(
"GET",
"/v1/inbound-rules",
params={"mailbox_id": mailbox_id, "limit": limit, "before": before},
)
return _page(raw, InboundSenderRule)
Comment on lines +439 to +451

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect all list() signatures and pagination param usage across resources.
rg -nP '\bdef list\s*\(' loftbox/client.py -A 8
rg -nP '\b(before|after|cursor|next_cursor)\b' loftbox/client.py loftbox/models.py

Repository: TheMagicTower/loftbox-sdk-python

Length of output: 3767


Inconsistent pagination parameter naming

The InboundSenderRules.list and _Suppressions.list methods use before as the pagination cursor parameter, whereas Agent, Folder, and Thread list methods use cursor. Additionally, the Page model explicitly exposes next_cursor, creating a naming mismatch for callers attempting to chain pages (e.g., next_cursor vs before). Align the input parameter name to cursor across all list methods for consistency.

    def list(
        self,
        *,
        mailbox_id: Optional[str] = None,
        limit: Optional[int] = None,
-       before: Optional[str] = None,
+       cursor: Optional[str] = None,
    ) -> Page[InboundSenderRule]:
        raw = self._c._request(
            "GET",
            "/v1/inbound-rules",
-           params={"mailbox_id": mailbox_id, "limit": limit, "before": before},
+           params={"mailbox_id": mailbox_id, "limit": limit, "cursor": cursor},
        )
        return _page(raw, InboundSenderRule)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def list(
self,
*,
mailbox_id: Optional[str] = None,
limit: Optional[int] = None,
before: Optional[str] = None,
) -> Page[InboundSenderRule]:
raw = self._c._request(
"GET",
"/v1/inbound-rules",
params={"mailbox_id": mailbox_id, "limit": limit, "before": before},
)
return _page(raw, InboundSenderRule)
def list(
self,
*,
mailbox_id: Optional[str] = None,
limit: Optional[int] = None,
cursor: Optional[str] = None,
) -> Page[InboundSenderRule]:
raw = self._c._request(
"GET",
"/v1/inbound-rules",
params={"mailbox_id": mailbox_id, "limit": limit, "cursor": cursor},
)
return _page(raw, InboundSenderRule)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@loftbox/client.py` around lines 439 - 451, The pagination cursor name in
InboundSenderRule list methods is inconsistent with the rest of the client API.
Update InboundSenderRules.list to use cursor instead of before, and make the
same change in _Suppressions.list so callers can pass the Page.next_cursor value
through consistently. Keep the request parameter mapping aligned in the shared
list method implementations and preserve the existing Page model behavior.


def create(
self,
*,
rule_type: str,
pattern_type: str,
pattern: str,
mailbox_id: Optional[str] = None,
) -> InboundSenderRule:
"""규칙 생성. rule_type=allow|block, pattern_type=address|domain.

mailbox_id 미지정 = org 전체. block 매치 또는 allow 리스트 미매치 발신자는 수신 거부.
"""
return InboundSenderRule.model_validate(
self._c._request(
"POST",
"/v1/inbound-rules",
json={
"rule_type": rule_type,
"pattern_type": pattern_type,
"pattern": pattern,
"mailbox_id": mailbox_id,
},
)
)

def remove(self, rule_id: str) -> None:
self._c._request("DELETE", f"/v1/inbound-rules/{rule_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")
Expand Down
18 changes: 18 additions & 0 deletions loftbox/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ class Message(_Base):
body_markdown: Optional[str] = None
# #229 수신 답장 본문(인용 제거)
extracted_text: Optional[str] = None
# #369 인바운드 프롬프트-인젝션 휴리스틱 신호(신호 전용 — 차단 아님).
# score 0~1(높을수록 의심), categories 는 발화 카테고리. 아웃바운드/미스캔은 None.
injection_score: Optional[float] = None
injection_categories: Optional[List[str]] = None
# #236 라벨
labels: List[str] = Field(default_factory=list)
# #241 예약발송 시각
Expand Down Expand Up @@ -103,6 +107,20 @@ class Suppression(_Base):
created_at: Optional[datetime] = None


class InboundSenderRule(_Base):
"""#370 인바운드 발신자 allow/block 규칙."""

id: str
# None = org 전체 메일박스. 값이 있으면 그 메일박스에만 적용.
mailbox_id: Optional[str] = None
# "allow" 또는 "block".
rule_type: Optional[str] = None
# "address"(정확 주소) 또는 "domain".
pattern_type: Optional[str] = None
pattern: Optional[str] = None
created_at: Optional[datetime] = None


class Page(_Base, Generic[T]):
"""cursor 페이지네이션 응답 래퍼.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "loftbox"
version = "0.2.0"
version = "0.3.0"
description = "LoftBox Python SDK - Email infrastructure for AI agents"
readme = "README.md"
requires-python = ">=3.9"
Expand Down
69 changes: 69 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,72 @@ def test_context_manager_closes() -> None:
client, _ = make_client(lambda req: httpx.Response(200, json={"data": []}))
with client as c:
c.agents.list()


def test_message_parses_injection_signal() -> None:
"""#369 인바운드 인젝션 신호 필드 파싱."""
client, _ = make_client(
lambda req: httpx.Response(
200,
json={
"id": "msg_1",
"direction": "incoming",
"injection_score": 0.78,
"injection_categories": ["instruction_override", "data_exfiltration"],
},
)
)
msg = client.messages.get("msg_1")
assert msg.injection_score == 0.78
assert msg.injection_categories == ["instruction_override", "data_exfiltration"]


def test_inbound_rule_create_shapes_request() -> None:
"""#370 규칙 생성 — body 구성 + 응답 파싱."""

def handler(req: httpx.Request) -> httpx.Response:
assert req.method == "POST"
assert req.url.path == "/v1/inbound-rules"
body = json.loads(req.content)
assert body["rule_type"] == "block"
assert body["pattern_type"] == "domain"
assert body["pattern"] == "evil.com"
assert body["mailbox_id"] is None
return httpx.Response(
201,
json={
"id": "rule_1",
"rule_type": "block",
"pattern_type": "domain",
"pattern": "evil.com",
},
)

client, _ = make_client(handler)
rule = client.inbound_rules.create(rule_type="block", pattern_type="domain", pattern="evil.com")
assert rule.id == "rule_1"
assert rule.rule_type == "block"


def test_inbound_rule_list_filters_mailbox() -> None:
def handler(req: httpx.Request) -> httpx.Response:
assert req.url.path == "/v1/inbound-rules"
assert req.url.params.get("mailbox_id") == "mb_9"
return httpx.Response(200, json={"data": [{"id": "r1"}], "next_cursor": None})

client, _ = make_client(handler)
page = client.inbound_rules.list(mailbox_id="mb_9")
assert page.data[0].id == "r1"


def test_inbound_rule_remove_uses_path() -> None:
captured: Captured = []

def handler(req: httpx.Request) -> httpx.Response:
captured.append(req)
return httpx.Response(204)

client, _ = make_client(handler)
client.inbound_rules.remove("rule_42")
assert captured[0].method == "DELETE"
assert captured[0].url.path == "/v1/inbound-rules/rule_42"
Loading