From b60ab7df223b0b56453f862c04e21aefc197ff4f Mon Sep 17 00:00:00 2001 From: alex-w-99 Date: Mon, 27 Jul 2026 02:19:21 +0000 Subject: [PATCH 1/2] Prefer the webhook body over refetching inbound email message.received carries the plain-text body, so the inbound mail path no longer needs an API round-trip to read an email. It fetched on every inbound and fell back to the 200-char snippet whenever that call failed, leaving the agent with an email cut off mid-sentence and no indication anything was missing. Use the body from the payload when it is whole. Only a truncated or absent body is worth a fetch, and when that fetch fails the truncated body is delivered with a notice giving the character counts and the message id, rather than silently collapsing to the snippet. --- inkbox_codex/gateway.py | 31 +++- tests/test_gateway_inbound_mail_body.py | 185 ++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 tests/test_gateway_inbound_mail_body.py diff --git a/inkbox_codex/gateway.py b/inkbox_codex/gateway.py index f099421..0ef786b 100644 --- a/inkbox_codex/gateway.py +++ b/inkbox_codex/gateway.py @@ -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:] @@ -1418,7 +1439,11 @@ 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"): @@ -1426,8 +1451,8 @@ def _fetch_mail_body(self, message: Dict[str, Any]) -> str: 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: diff --git a/tests/test_gateway_inbound_mail_body.py b/tests/test_gateway_inbound_mail_body.py new file mode 100644 index 0000000..59c35ae --- /dev/null +++ b/tests/test_gateway_inbound_mail_body.py @@ -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 From 7e6d8f47867abbce721dabd8776ab823fbdef8a8 Mon Sep 17 00:00:00 2001 From: alex-w-99 Date: Mon, 27 Jul 2026 02:30:05 +0000 Subject: [PATCH 2/2] Identify the plugin and its version to the API The SDK announces itself in the User-Agent and accepts a caller token ahead of its own, but the plugin never set one, so an inbound request was indistinguishable from any other Python SDK caller. Without that there is no way to tell which plugin, or which version of it, a given agent is running. Pass user_agent_prefix from inkbox_client_kwargs, the single place client kwargs are built, sourced from installed package metadata so it cannot drift from the declared version. __version__ had already drifted to 0.1.0 against a 0.1.4 pyproject, which is exactly the failure the metadata lookup avoids. Version is realigned to 0.2.5, the shared number across the plugin fleet, so the token identifies a fleet release rather than a per-repo count. --- inkbox_codex/__init__.py | 2 +- inkbox_codex/config.py | 21 ++++++++++++++++++++- pyproject.toml | 2 +- tests/test_user_agent.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 tests/test_user_agent.py diff --git a/inkbox_codex/__init__.py b/inkbox_codex/__init__.py index d37641c..02b4e89 100644 --- a/inkbox_codex/__init__.py +++ b/inkbox_codex/__init__.py @@ -1,3 +1,3 @@ """Inkbox bridge for Codex — email, SMS, iMessage, and voice.""" -__version__ = "0.1.0" +__version__ = "0.2.5" diff --git a/inkbox_codex/config.py b/inkbox_codex/config.py index 9de7c9b..2a257ad 100644 --- a/inkbox_codex/config.py +++ b/inkbox_codex/config.py @@ -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 @@ -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" @@ -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: diff --git a/pyproject.toml b/pyproject.toml index bb2b7a1..ac34321 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/test_user_agent.py b/tests/test_user_agent.py new file mode 100644 index 0000000..67ad48d --- /dev/null +++ b/tests/test_user_agent.py @@ -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()