diff --git a/.env.example b/.env.example index 03b0fb45..41a40ca8 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,9 @@ BACKEND_PORT=18420 ORCHESTRATOR_BASE_URL= ORCHESTRATOR_API_KEY= VISION_MODEL= + +# Optional. Empty = naruon mailbox port is unavailable (NaruonNotAvailable, +# never an invented thread). Point at a running naruon /api/emails host +# to list published inbox subjects (ADR 0020). +NARUON_BASE_URL= +NARUON_BEARER= diff --git a/AGENTS.md b/AGENTS.md index c790995c..4e2cef60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,9 @@ a statistic, never a title, name, or id. This repo depends on real ContextualWisdomLab-org packages rather than reimplementing them: +- [naruon](https://github.com/ContextualWisdomLab/naruon) for the + mailbox inbox (`naruon_client.py`, published `GET /api/emails`) -- + never invent a thread or read naruon tables. - [ThreadWeave](https://github.com/ContextualWisdomLab/ThreadWeave) for tree assembly (`reconstruct.py`'s `_walk`/`thread_messages` calls). - [RankWeave](https://github.com/ContextualWisdomLab/RankWeave) for @@ -53,7 +56,9 @@ does it (`gh repo list ContextualWisdomLab`). must set `available = False` and make their channel dropped + renormalized (`reconstruct.active_weights`), never silently return a placeholder score, invented Keyman, guessed relationship, fabricated -summary/chat, or invented commitment. A missing signal and a +summary/chat, or invented commitment. `NaruonClient` fails closed with +`NaruonNotAvailable` and an empty thread list -- never an invented +mailbox subject. A missing signal and a confidently-negative signal are different things. Keyman extraction, entity-relationship classification, post summary, in-popup chat, and commitment derivation go through contextual-orchestrator the same way diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f8a83ceb..01fec468 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -123,6 +123,11 @@ flowchart LR `RankWeaveNotAvailable`. `GET /api/rankings` then returns `rankweave_not_available` and an empty ranking list. Hidden posts are omitted from every channel. See ADR 0024. +- **naruon is a mailbox wire contract, not a table read.** + `naruon_client.py`'s default transport raises `NaruonNotAvailable`. + `GET /api/mailbox` then returns `naruon_not_available` and an empty + thread list. Message bodies are never projected. See ADR 0020. + ## Standards and citations diff --git a/CHANGELOG.d/0.80.0-naruon-mailbox-fail-closed.md b/CHANGELOG.d/0.80.0-naruon-mailbox-fail-closed.md new file mode 100644 index 00000000..086bae6b --- /dev/null +++ b/CHANGELOG.d/0.80.0-naruon-mailbox-fail-closed.md @@ -0,0 +1,8 @@ +# 0.80.0 — Fail-closed naruon mailbox + +## Added + +- Home Mailbox panel lists naruon inbox threads through `NaruonClient`. + After login with naruon HTTP down, Demo Analyst sees + **Mailbox · naruon not available**. An accepted transport lists the + published thread subject. Seed never invents a message. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfcaa28..70e0773f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.80.0] - 2026-08-17 + +### Added + +- Home Mailbox panel lists naruon inbox threads through `NaruonClient`. + After login with naruon HTTP down, Demo Analyst sees + **Mailbox · naruon not available**. An accepted transport lists the + published thread subject. Seed never invents a message. + ## [0.75.0] - 2026-08-17 ### Added diff --git a/README.md b/README.md index a3626d9f..ea32d939 100644 --- a/README.md +++ b/README.md @@ -146,10 +146,13 @@ curl http://localhost:18420/healthz `GET /api/posts/{post_id}/keymen`, `GET /api/keymen/{person_id}/related`, `GET /api/posts/{post_id}/affiliate-tree`, `GET /api/posts/{post_id}/voc-evidence`, +`GET /api/mailbox`, and `POST /api/posts/{post_id}/extract-keymen` require a real bearer token (RBAC: the account's role must grant `post_read`; ABAC: a private post is only visible to accounts affiliated -with its owning corporate entity -- `backend/app/main.py`). A Keyman who +with its owning corporate entity -- `backend/app/main.py`). `GET /api/mailbox` +returns `naruon_not_available` when naruon HTTP is down and never invents +a thread (ADR 0020). A Keyman who is only mentioned on a post the account cannot see is 403, same deny path. `backend/tests/test_api.py` proves both the allow and the deny path against a live Keycloak + throwaway Postgres database, including diff --git a/backend/app/config.py b/backend/app/config.py index 68cad343..6969022a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -52,6 +52,11 @@ class Settings: # RankWeaveNotAvailable -- never invent a fused score. Default false # uses the in-process library already required by reconstruct.py. rankweave_disabled: bool + # naruon mailbox control plane (ADR 0020). Empty = fail-closed + # NaruonNotAvailable -- never invent a thread. + naruon_base_url: str + naruon_bearer: str + @property def keycloak_jwks_uri(self) -> str: @@ -88,4 +93,7 @@ def load_settings() -> Settings: .strip() .lower() in {"1", "true", "yes", "on"}, + naruon_base_url=os.environ.get("NARUON_BASE_URL", ""), + naruon_bearer=os.environ.get("NARUON_BEARER", ""), + ) diff --git a/backend/app/main.py b/backend/app/main.py index 27f67911..01950c7f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -41,6 +41,7 @@ ContextualOrchestratorKeymanExtractionClient, NullKeymanExtractionClient, ) +from lineageweave.naruon_client import build_naruon_client from lineageweave.post_chat import ( ContextualOrchestratorPostChatClient, NullPostChatClient, @@ -242,6 +243,16 @@ def _rankweave_client(): return build_rankweave_client(disabled=load_settings().rankweave_disabled) +def _naruon_client(): + """Live naruon inbox client when configured; otherwise fail-closed.""" + settings = load_settings() + return build_naruon_client( + base_url=settings.naruon_base_url, + bearer=settings.naruon_bearer, + ) + + + def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool: """ABAC: public rows are visible; private rows require same-corp affiliation.""" if post["visibility_code"] == "public": @@ -1150,3 +1161,17 @@ async def read_rankings( return _rankweave_client().as_api_payload( posts, can_see_post=lambda _row: True ) + + +@app.get("/api/mailbox") +async def read_mailbox( + account: CurrentAccount = Depends(get_current_account), +) -> dict[str, Any]: + """naruon inbox projection. Fail-closed when the port is down (ADR 0020). + + Never invents a thread or a message body. Click-through to a + ``source_post`` is a later mapping slice. + """ + _require_post_read(account) + return _naruon_client().as_api_payload() + diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 1db483ee..18cbd4ed 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -320,6 +320,18 @@ def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> No assert "post_read" in body["permission_codes"] +def test_mailbox_fails_closed_when_naruon_is_unconfigured(client, demo_analyst_token) -> None: + os.environ.pop("NARUON_BASE_URL", None) + os.environ.pop("NARUON_BEARER", None) + response = client.get("/api/mailbox", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert response.status_code == 200 + body = response.json() + assert body["port"] == "naruon" + assert body["status"] == "unavailable" + assert body["status_reason"] == "naruon_not_available" + assert body["threads"] == [] + + def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client, demo_analyst_token, seeded_db) -> None: response = client.get("/api/posts", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 diff --git a/docker-compose.yml b/docker-compose.yml index 5087366b..f0a1d531 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -105,6 +105,9 @@ services: ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-} VISION_MODEL: ${VISION_MODEL:-} SEARXNG_BASE_URL: http://searxng:8080 + # Empty by default: mailbox port stays fail-closed (ADR 0020). + NARUON_BASE_URL: ${NARUON_BASE_URL:-} + NARUON_BEARER: ${NARUON_BEARER:-} ports: - "${BACKEND_PORT:-18420}:8000" depends_on: diff --git a/docs/adr/0020-naruon-mailbox-fail-closed.md b/docs/adr/0020-naruon-mailbox-fail-closed.md new file mode 100644 index 00000000..4f691fda --- /dev/null +++ b/docs/adr/0020-naruon-mailbox-fail-closed.md @@ -0,0 +1,48 @@ +# ADR 0020 — Fail-closed naruon mailbox port + +**Decision status:** Accepted +**Date:** 2026-08-17 + +## Context + +LineageWeave reconstructs lineage from records that already exist +somewhere else. The org mailbox control plane is +[naruon](https://github.com/ContextualWisdomLab/naruon). naruon is not +an SMTP/IMAP host; it exposes a signed inbox envelope at +`GET /api/emails`. Until this slice, Demo Analyst had no mailbox +surface: a down naruon port was silent, and nothing stopped a later +writer from inventing a "Quarterly plan" thread. + +TEPP already owns the naruon→TEPP analysis-run interchange +(`docs/connectors/naruon-artifact-consumer.md` in TEPP). This ADR does +not reimplement that interchange, invent a second analysis-run +registry, or bind the demo IdP to production Keyverse. + +## Decision + +1. Consume naruon only through `NaruonClient` and the published + `GET /api/emails` envelope. Never read naruon tables. +2. The default transport raises `NaruonNotAvailable`. HTTP 4xx/5xx, + timeout, network, non-https, and an unknown envelope fail closed. +3. Project `thread_id`, `subject`, and optional `reply_count` only. + Message bodies, raw ids, and provider credentials are not copied. +4. `GET /api/mailbox` (post_read) returns `unavailable` + + `naruon_not_available` + empty `threads` when the port is down. + Seed probes the same client and never inserts an invented email. +5. After login, the home Mailbox panel names that status. An accepted + thread lists its subject; click does not invent a `source_post`. + +## Consequences + +Demo Analyst sees **Mailbox · naruon not available** after `make seed` +when `NARUON_BASE_URL` is empty. A live naruon transport can list the +published Quarterly plan fixture without fabricating a post. Later +mapping of mailbox threads onto lineage posts is a separate slice. + +## References + +Contextual Wisdom Lab. (2026). *Naruon AI email workspace* [Software +documentation]. https://github.com/ContextualWisdomLab/naruon + +Contextual Wisdom Lab. (2026). *naruon artifact consumer* [Connector +contract]. https://github.com/ContextualWisdomLab/TEPP/blob/main/docs/connectors/naruon-artifact-consumer.md diff --git a/frontend/package.json b/frontend/package.json index 575b7c58..ca3a1810 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.75.0", + "version": "0.80.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a32a2640..9b7a8d90 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -65,6 +65,11 @@ describe("App, authenticated", () => { fused_rank: number; }[]; }; + mailbox?: { + status: "accepted" | "unavailable"; + status_reason: string | null; + threads: { thread_id: string; subject: string; reply_count?: number }[]; + }; chatUnavailable?: boolean; searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; @@ -178,6 +183,22 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", has_commitment: true, ticket }), ); } + if (url.endsWith("/api/mailbox")) { + const mailbox = options?.mailbox ?? { + port: "naruon", + status: "unavailable" as const, + status_reason: "naruon_not_available", + threads: [], + }; + return Promise.resolve( + jsonResponse({ + port: "naruon", + status: mailbox.status, + status_reason: mailbox.status_reason, + threads: mailbox.threads, + }), + ); + } if (url.endsWith("/api/calendar")) { return Promise.resolve( jsonResponse({ @@ -1307,7 +1328,37 @@ describe("App, authenticated", () => { await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); }); - it("shows upcoming commitments on the home page calendar and opens the post on click", async () => { + it("names naruon unavailability on the home mailbox instead of inventing a thread", async () => { + stubBackend(); + render(); + + expect(await screen.findByText("Mailbox · naruon not available")).toBeInTheDocument(); + expect(screen.queryByText("Quarterly plan")).not.toBeInTheDocument(); + }); + + it("lists an accepted naruon thread subject without inventing a post", async () => { + stubBackend({ + mailbox: { + status: "accepted", + status_reason: null, + threads: [ + { + thread_id: "thread-root@example.com", + subject: "Quarterly plan", + reply_count: 3, + }, + ], + }, + }); + render(); + + expect(await screen.findByText("Quarterly plan")).toBeInTheDocument(); + expect(screen.getByText("Mailbox · naruon")).toBeInTheDocument(); + expect(screen.getByText("3 replies")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /quarterly plan/i })).not.toBeInTheDocument(); + }); + +it("shows upcoming commitments on the home page calendar and opens the post on click", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6056e5eb..49516b86 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { fetchCalendar, fetchLineageGraph, fetchMe, + fetchMailbox, fetchPost, fetchPostActivity, fetchPostChat, @@ -41,6 +42,7 @@ import { type EvaluationResponse, type IssueTicket, type LineageGraph, + type MailboxInbox, type Keyman, type LinkedPostRef, type PostAiSummary, @@ -1371,6 +1373,52 @@ function RankingsPanel({ ); } +function MailboxPanel({ accessToken }: { accessToken: string }) { + const [inbox, setInbox] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + setError(null); + fetchMailbox(accessToken) + .then(setInbox) + .catch((err) => setError(String(err))); + }, [accessToken]); + + return ( +
+
+

Mailbox

+ {inbox && ( + + {inbox.status === "accepted" ? "naruon" : `naruon · ${inbox.status_reason ?? "unavailable"}`} + + )} +
+ {error &&

{error}

} + {inbox === null && !error &&

Loading mailbox...

} + {inbox && inbox.status === "unavailable" && ( +

Mailbox · naruon not available

+ )} + {inbox && inbox.status === "accepted" && inbox.threads.length === 0 && ( +

No mailbox threads from naruon.

+ )} + {inbox && inbox.threads.length > 0 && ( +
    + {inbox.threads.map((thread) => ( +
  • + {thread.subject} + Mailbox · naruon + {thread.reply_count != null && ( + {thread.reply_count} replies + )} +
  • + ))} +
+ )} +
+ ); +} + function CalendarPanel({ accessToken, onSelectPost, @@ -1690,6 +1738,7 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> +
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e6dfcbad..cc0c64ac 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -515,3 +515,20 @@ export interface RankingList { export function fetchRankings(accessToken: string): Promise { return backendFetch("/api/rankings", accessToken); } + +export interface MailboxThread { + thread_id: string; + subject: string; + reply_count?: number; +} + +export interface MailboxInbox { + port: string; + status: "accepted" | "unavailable"; + status_reason: string | null; + threads: MailboxThread[]; +} + +export function fetchMailbox(accessToken: string): Promise { + return backendFetch("/api/mailbox", accessToken); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1710c009..c2db00df 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.75.0" +__version__ = "0.80.0" diff --git a/lineageweave/naruon_client.py b/lineageweave/naruon_client.py new file mode 100644 index 00000000..6f0b77c0 --- /dev/null +++ b/lineageweave/naruon_client.py @@ -0,0 +1,158 @@ +"""Adapter for naruon's published mailbox inbox contract. + +`naruon `_ is a web +client/control plane over customer-owned mail, not a mailbox host. +LineageWeave consumes only the published inbox envelope +(``GET /api/emails``) and never invents a thread, a subject, or a +message body. + +The default transport raises :class:`NaruonNotAvailable` so a missing +naruon port is fail-closed, the same discipline as +:class:`lineageweave.tepp_client.TeppNotAvailable`. Wiring a live +HTTPS base URL is additive (``HttpNaruonTransport``), not a redesign. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from lineageweave.http_client import HttpClientError, get_json + + +class NaruonNotAvailable(RuntimeError): + """Raised when the naruon mailbox port is down or unconfigured.""" + + reason = "naruon_not_available" + + +def _no_transport() -> dict[str, Any]: + raise NaruonNotAvailable( + "naruon_not_available: naruon mailbox HTTP is not configured. " + "Pass NARUON_BASE_URL or a transport= callable. Never invent a thread." + ) + + +@dataclass(frozen=True) +class MailboxThread: + """Projected naruon inbox row. No message body.""" + + thread_id: str + subject: str + reply_count: int | None = None + + def to_json(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "thread_id": self.thread_id, + "subject": self.subject, + } + if self.reply_count is not None: + payload["reply_count"] = self.reply_count + return payload + + +@dataclass(frozen=True) +class MailboxInbox: + """Accepted inbox projection. Empty when naruon returned no usable rows.""" + + threads: tuple[MailboxThread, ...] + + def to_json(self) -> list[dict[str, Any]]: + return [thread.to_json() for thread in self.threads] + + +def parse_inbox(payload: object) -> MailboxInbox: + """Project naruon's published ``{emails: [...]}`` envelope. + + Unknown envelopes fail closed. Malformed rows are skipped rather than + repaired. Message bodies are never copied. + """ + if not isinstance(payload, dict) or not isinstance(payload.get("emails"), list): + raise NaruonNotAvailable( + "naruon_not_available: inbox envelope is not the published {emails: []} shape" + ) + threads: list[MailboxThread] = [] + for row in payload["emails"]: + if not isinstance(row, dict): + continue + subject = row.get("subject") + thread_id = row.get("thread_id") + if not isinstance(subject, str) or not subject.strip(): + continue + if not isinstance(thread_id, str) or not thread_id.strip(): + continue + reply_count = row.get("reply_count") + if reply_count is not None and not isinstance(reply_count, int): + reply_count = None + threads.append( + MailboxThread( + thread_id=thread_id.strip(), + subject=subject.strip(), + reply_count=reply_count, + ) + ) + return MailboxInbox(threads=tuple(threads)) + + +class HttpNaruonTransport: + """GET ``{base_url}/api/emails`` through the http(s)-only client.""" + + def __init__(self, base_url: str, bearer: str = "", timeout: float = 5.0) -> None: + self.base_url = base_url.rstrip("/") + self.bearer = bearer + self.timeout = timeout + + def __call__(self) -> dict[str, Any]: + headers: dict[str, str] = {} + if self.bearer: + headers["Authorization"] = f"Bearer {self.bearer}" + try: + payload = get_json( + f"{self.base_url}/api/emails", + headers=headers or None, + timeout=self.timeout, + ) + except (HttpClientError, OSError, TimeoutError, ValueError) as exc: + raise NaruonNotAvailable( + f"naruon_not_available: mailbox HTTP failed ({exc})" + ) from exc + if not isinstance(payload, dict): + raise NaruonNotAvailable( + "naruon_not_available: inbox HTTP did not return a JSON object" + ) + return payload + + +def build_naruon_client(base_url: str = "", bearer: str = "") -> "NaruonClient": + """Empty base URL keeps the default fail-closed transport.""" + if not base_url.strip(): + return NaruonClient() + return NaruonClient(transport=HttpNaruonTransport(base_url=base_url, bearer=bearer)) + + +class NaruonClient: + """Lists naruon inbox threads through a pluggable transport.""" + + def __init__(self, transport: Callable[[], dict[str, Any]] = _no_transport) -> None: + self._transport = transport + + def list_inbox(self) -> MailboxInbox: + return parse_inbox(self._transport()) + + def as_api_payload(self) -> dict[str, Any]: + """Buyer-visible mailbox status. Never invents a thread.""" + try: + inbox = self.list_inbox() + except NaruonNotAvailable: + return { + "port": "naruon", + "status": "unavailable", + "status_reason": NaruonNotAvailable.reason, + "threads": [], + } + return { + "port": "naruon", + "status": "accepted", + "status_reason": None, + "threads": inbox.to_json(), + } diff --git a/pyproject.toml b/pyproject.toml index 764ebad7..34f9238a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.75.0" +version = "0.80.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index d4c24d84..f31e8c48 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -29,6 +29,7 @@ import psycopg2 from lineageweave.http_client import get_json_list, post_form +from lineageweave.naruon_client import build_naruon_client REALM = "lineageweave-demo" DEFAULT_POSTGRES_DSN = "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave" @@ -1182,6 +1183,11 @@ def main() -> None: subjects = _fetch_demo_user_subjects(args.keycloak_base_url, args.keycloak_admin_user, args.keycloak_admin_password) seed(args.postgres_dsn, subjects, args.valkey_url) print(f"Seeded synthetic demo data for accounts: {subjects}") + mailbox = build_naruon_client().as_api_payload() + if mailbox["status"] == "accepted": + print(f"naruon mailbox accepted: {len(mailbox['threads'])} threads") + else: + print(f"naruon mailbox: {mailbox['status_reason']} (no invented messages)") if __name__ == "__main__": diff --git a/tests/test_naruon_client.py b/tests/test_naruon_client.py new file mode 100644 index 00000000..ffd0a088 --- /dev/null +++ b/tests/test_naruon_client.py @@ -0,0 +1,146 @@ +"""Fail-closed naruon mailbox port. + +Naruon is the mailbox control plane. LineageWeave consumes only the +published inbox envelope (GET /api/emails) and never invents a thread +or a message body. +""" + +from __future__ import annotations + +import pytest + +from lineageweave.http_client import HttpClientError +from lineageweave.naruon_client import ( + HttpNaruonTransport, + NaruonClient, + NaruonNotAvailable, + parse_inbox, +) + + +def test_default_transport_fails_closed() -> None: + client = NaruonClient() + with pytest.raises(NaruonNotAvailable, match="naruon_not_available"): + client.list_inbox() + + +def test_default_payload_never_invents_a_thread() -> None: + payload = NaruonClient().as_api_payload() + + assert payload == { + "port": "naruon", + "status": "unavailable", + "status_reason": "naruon_not_available", + "threads": [], + } + + +def test_parse_inbox_projects_published_fields_only() -> None: + inbox = parse_inbox( + { + "emails": [ + { + "subject": "Quarterly plan", + "thread_id": "thread-root@example.com", + "reply_count": 3, + "body": "must not be copied", + } + ] + } + ) + + assert len(inbox.threads) == 1 + thread = inbox.threads[0] + assert thread.subject == "Quarterly plan" + assert thread.thread_id == "thread-root@example.com" + assert thread.reply_count == 3 + assert not hasattr(thread, "body") + + +def test_parse_inbox_rejects_unknown_envelope() -> None: + with pytest.raises(NaruonNotAvailable, match="naruon_not_available"): + parse_inbox({"messages": [{"subject": "spoofed"}]}) + + +def test_parse_inbox_skips_malformed_rows_without_inventing() -> None: + inbox = parse_inbox( + { + "emails": [ + {"subject": "", "thread_id": "blank-subject"}, + {"subject": "No thread id"}, + {"subject": "Quarterly plan", "thread_id": "thread-root@example.com"}, + ] + } + ) + + assert [thread.subject for thread in inbox.threads] == ["Quarterly plan"] + + +def test_injected_transport_returns_accepted_threads() -> None: + def fake_transport() -> dict: + return { + "emails": [ + { + "subject": "Quarterly plan", + "thread_id": "thread-root@example.com", + "reply_count": 2, + } + ] + } + + payload = NaruonClient(transport=fake_transport).as_api_payload() + + assert payload["status"] == "accepted" + assert payload["status_reason"] is None + assert payload["threads"] == [ + { + "thread_id": "thread-root@example.com", + "subject": "Quarterly plan", + "reply_count": 2, + } + ] + + +def test_http_transport_posts_published_inbox_path(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + def fake_get_json(url: str, *, headers: dict[str, str] | None = None, timeout: float) -> dict: + captured["url"] = url + captured["headers"] = headers + captured["timeout"] = timeout + return { + "emails": [ + {"subject": "Quarterly plan", "thread_id": "thread-root@example.com", "reply_count": 1} + ] + } + + monkeypatch.setattr("lineageweave.naruon_client.get_json", fake_get_json) + transport = HttpNaruonTransport(base_url="https://naruon.example", bearer="demo-bearer") + inbox = NaruonClient(transport=transport).list_inbox() + + assert captured["url"] == "https://naruon.example/api/emails" + assert captured["headers"] == {"Authorization": "Bearer demo-bearer"} + assert inbox.threads[0].subject == "Quarterly plan" + + +@pytest.mark.parametrize( + "error", + [ + HttpClientError("HTTP 503 from naruon.example"), + HttpClientError("HTTP 404 from naruon.example"), + TimeoutError("timed out"), + OSError("network down"), + ValueError("refusing non-http(s) URL scheme: 'file'"), + ], +) +def test_http_transport_fail_closed_on_transport_errors( + monkeypatch: pytest.MonkeyPatch, error: Exception +) -> None: + def boom(*_args: object, **_kwargs: object) -> dict: + raise error + + monkeypatch.setattr("lineageweave.naruon_client.get_json", boom) + client = NaruonClient(transport=HttpNaruonTransport(base_url="https://naruon.example")) + with pytest.raises(NaruonNotAvailable, match="naruon_not_available"): + client.list_inbox() + assert client.as_api_payload()["threads"] == [] diff --git a/uv.lock b/uv.lock index 08eab776..7060e3be 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.75.0" +version = "0.80.0" source = { virtual = "." } dependencies = [ { name = "certifi" },