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
2 changes: 1 addition & 1 deletion inkbox_codex/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Inkbox bridge for Codex — email, SMS, iMessage, and voice."""

__version__ = "0.1.0"
__version__ = "0.2.5"
21 changes: 20 additions & 1 deletion inkbox_codex/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

from __future__ import annotations

import importlib.metadata
import os
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, List

Expand All @@ -16,6 +18,9 @@
# Empty means "do not override"; the Inkbox SDK owns its API default.
INKBOX_BASE_URL_DEFAULT = ""
INKBOX_WS_PATH = "/phone/media/ws"

USER_AGENT_NAME = "inkbox-codex"
DISTRIBUTION_NAME = "codex-plugin"
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8767
DEFAULT_WEBHOOK_PATH = "/webhook"
Expand Down Expand Up @@ -99,8 +104,22 @@ def inkbox_base_url_kwargs(base_url: str | None = None) -> Dict[str, str]:
return {"base_url": normalized} if normalized else {}


@lru_cache(maxsize=1)
def plugin_user_agent() -> str:
"""Identifies this plugin ahead of the SDK's own ``User-Agent`` token."""
try:
version = importlib.metadata.version(DISTRIBUTION_NAME)
except importlib.metadata.PackageNotFoundError:
version = "unknown"
return f"{USER_AGENT_NAME}/{version}"


def inkbox_client_kwargs(api_key: str, base_url: str | None = None) -> Dict[str, str]:
return {"api_key": api_key, **inkbox_base_url_kwargs(base_url)}
return {
"api_key": api_key,
"user_agent_prefix": plugin_user_agent(),
**inkbox_base_url_kwargs(base_url),
}


def _read_realtime_config() -> RealtimeConfig:
Expand Down
31 changes: 28 additions & 3 deletions inkbox_codex/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,27 @@
logger = logging.getLogger(__name__)


def _webhook_mail_body(message: Dict[str, Any]) -> str:
"""Body text carried by the webhook, with a notice when it is a prefix.

Falls back to the 200-char snippet for payloads that carry no body.
"""
body = str(message.get("body") or "")
if not body.strip():
return str(message.get("snippet") or "")
if str(message.get("body_state") or "") != "truncated":
return body
total = message.get("body_total_chars")
included = message.get("body_included_chars")
msg_id = str(message.get("id") or "")
counts = f"{included} of {total} characters" if total and included else "part"
return (
f"{body}\n\n[inkbox: this email was too long to deliver in full. "
f"You are seeing {counts}."
+ (f" Fetch email {msg_id} to read the rest.]" if msg_id else "]")
)


def _format_transcript(transcript: Any, limit: int = 30) -> str:
"""Render the last ``limit`` (role, text) turns as plain lines."""
rows = list(transcript or [])[-limit:]
Expand Down Expand Up @@ -1418,16 +1439,20 @@ async def _fetch_mail_attachments(self, message: Dict[str, Any]) -> List[Dict[st
return await download_media(items, prefix=f"mail-{msg_id}")

def _fetch_mail_body(self, message: Dict[str, Any]) -> str:
# The webhook only carries a snippet; pull the full body when we can.
# message.received carries the body, so the common case needs no
# round-trip; only a truncated or absent body is worth fetching.
body = str(message.get("body") or "")
if body.strip() and str(message.get("body_state") or "") != "truncated":
return body
try:
detail = self._identity.get_message(str(message.get("id")))
for attr in ("body_text", "text_body", "body"):
value = getattr(detail, attr, None)
if value:
return str(value)
except Exception:
logger.debug("[bridge] full-body fetch failed; using snippet", exc_info=True)
return str(message.get("snippet") or "")
logger.debug("[bridge] full-body fetch failed; using the webhook body", exc_info=True)
return _webhook_mail_body(message)

async def _lookup_text_conversation_summary(self, conversation_id: str) -> Any:
if not conversation_id:
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 = "codex-plugin"
version = "0.1.4"
version = "0.2.5"
description = "Inkbox bridge for Codex — talk to your coding agent over email, SMS, iMessage, and voice"
requires-python = ">=3.11"
dependencies = [
Expand Down
185 changes: 185 additions & 0 deletions tests/test_gateway_inbound_mail_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Inbound email turns carry the full body, not the 200-char snippet.

``message.received`` ships the body alongside the snippet, self-describing
when it had to abbreviate. A whole body needs no round-trip; a truncated or
absent one falls back to a fetch, and the snippet is the last resort.
"""

import asyncio
import json
import types

import pytest

from inkbox_codex import gateway
from inkbox_codex.config import BridgeConfig


LONG_BODY = "Pricing details follow. " * 40
SNIPPET = LONG_BODY[:200]


@pytest.fixture(autouse=True)
def fake_web(monkeypatch):
def json_response(payload):
return types.SimpleNamespace(text=json.dumps(payload), payload=payload)

monkeypatch.setattr(gateway, "web", types.SimpleNamespace(json_response=json_response))


class _FakeSession:
def __init__(self):
self.inbound = []

async def handle_inbound(self, text, mode, meta):
self.inbound.append((text, mode, meta))


class _FakeSessions:
def __init__(self):
self.by_id = {}

def get(self, chat_id):
return self.by_id.setdefault(chat_id, _FakeSession())


class _RecordingIdentity:
"""Stands in for the SDK identity; records full-body fetches."""

def __init__(self, detail=None, fails=False):
self.calls = []
self._detail = detail
self._fails = fails

def get_message(self, message_id):
self.calls.append(message_id)
if self._fails:
raise RuntimeError("unreachable")
return self._detail


def _gw(identity=None):
gw = gateway.InkboxGateway(BridgeConfig(require_signature=False, allow_all_users=True))
gw.sessions = _FakeSessions()
gw._identity = identity or _RecordingIdentity()
return gw


def _envelope(**message_overrides):
message = {
"id": "mail-in-1",
"thread_id": "thread-1",
"from_address": "atlas@inkboxmail.com",
"subject": "Coordinating",
"snippet": SNIPPET,
"direction": "inbound",
}
message.update(message_overrides)
return {"data": {"message": message}}


# ── helper ───────────────────────────────────────────────────────────────


def test_complete_body_is_used_verbatim():
message = {"body": LONG_BODY, "body_state": "complete", "snippet": SNIPPET}

assert gateway._webhook_mail_body(message) == LONG_BODY


def test_truncated_body_appends_a_notice_with_the_message_id():
message = {
"id": "mail-in-9",
"body": LONG_BODY,
"body_state": "truncated",
"body_truncated": True,
"body_total_chars": 40_000,
"body_included_chars": len(LONG_BODY),
"snippet": SNIPPET,
}

result = gateway._webhook_mail_body(message)

assert result.startswith(LONG_BODY)
assert "too long to deliver in full" in result
assert f"{len(LONG_BODY)} of 40000 characters" in result
assert "mail-in-9" in result


def test_missing_body_falls_back_to_the_snippet():
assert gateway._webhook_mail_body({"snippet": SNIPPET}) == SNIPPET


def test_unavailable_body_falls_back_to_the_snippet():
message = {"body": "", "body_state": "unavailable", "snippet": SNIPPET}

assert gateway._webhook_mail_body(message) == SNIPPET


# ── fetch policy ─────────────────────────────────────────────────────────


def test_complete_body_skips_the_fetch():
identity = _RecordingIdentity()
gw = _gw(identity)

asyncio.run(gw._on_mail_received(_envelope(body=LONG_BODY, body_state="complete")))

assert identity.calls == []
body, mode, _ = gw.sessions.by_id[next(iter(gw.sessions.by_id))].inbound[0]
assert mode == "email"
assert LONG_BODY in body


def test_truncated_body_fetches_the_remainder():
full = LONG_BODY + "and the rest of it."
identity = _RecordingIdentity(detail=types.SimpleNamespace(body_text=full))
gw = _gw(identity)

asyncio.run(
gw._on_mail_received(
_envelope(
body=LONG_BODY,
body_state="truncated",
body_truncated=True,
body_total_chars=len(full),
body_included_chars=len(LONG_BODY),
)
)
)

assert identity.calls == ["mail-in-1"]
body, _, _ = gw.sessions.by_id[next(iter(gw.sessions.by_id))].inbound[0]
assert body == full


def test_failed_fetch_keeps_the_truncated_body_and_notes_it():
identity = _RecordingIdentity(fails=True)
gw = _gw(identity)

asyncio.run(
gw._on_mail_received(
_envelope(
body=LONG_BODY,
body_state="truncated",
body_truncated=True,
body_total_chars=40_000,
body_included_chars=len(LONG_BODY),
)
)
)

body, _, _ = gw.sessions.by_id[next(iter(gw.sessions.by_id))].inbound[0]
assert LONG_BODY in body
assert "too long to deliver in full" in body


def test_absent_body_falls_back_to_the_fetch_then_the_snippet():
identity = _RecordingIdentity(fails=True)
gw = _gw(identity)

asyncio.run(gw._on_mail_received(_envelope()))

assert identity.calls == ["mail-in-1"]
body, _, _ = gw.sessions.by_id[next(iter(gw.sessions.by_id))].inbound[0]
assert body == SNIPPET
33 changes: 33 additions & 0 deletions tests/test_user_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""The plugin identifies itself and its version in the SDK User-Agent."""

from inkbox_codex import config as config_mod


def test_user_agent_names_the_plugin_and_its_version():
config_mod.plugin_user_agent.cache_clear()

ua = config_mod.plugin_user_agent()

assert ua.startswith("inkbox-codex/")
assert ua.split("/", 1)[1]


def test_client_kwargs_carry_the_prefix():
kwargs = config_mod.inkbox_client_kwargs("ak_test")

assert kwargs["api_key"] == "ak_test"
assert kwargs["user_agent_prefix"] == config_mod.plugin_user_agent()


def test_unknown_distribution_still_yields_a_token(monkeypatch):
import importlib.metadata

def _missing(_name):
raise importlib.metadata.PackageNotFoundError

monkeypatch.setattr(importlib.metadata, "version", _missing)
config_mod.plugin_user_agent.cache_clear()

assert config_mod.plugin_user_agent() == "inkbox-codex/unknown"

config_mod.plugin_user_agent.cache_clear()