From 304d5afa605c9121f23b9c68884d39ec3508f81e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 23:17:32 +0000 Subject: [PATCH 01/15] feat(mail): open recognized HWPX text from email attachments List the current email's attachments with the same opaque asset_key as the Data preview contract so a buyer can read ordered HWPX paragraphs from mail without treating missing text as empty content. Co-authored-by: Seongho Bae --- AGENTS.md | 6 + backend/api/emails.py | 33 +- .../tests/test_email_attachment_preview.py | 351 ++++++++++++++++++ .../hwp-hwpx-attachment-recognition.md | 14 + frontend/src/components/EmailDetail.test.tsx | 68 ++++ frontend/src/components/EmailDetail.tsx | 5 + .../components/MailAttachmentPreview.test.tsx | 208 +++++++++++ .../src/components/MailAttachmentPreview.tsx | 96 +++++ frontend/src/lib/email-threading.ts | 8 + frontend/tests/e2e/dashboard-flows.spec.ts | 15 + frontend/tests/e2e/helpers.ts | 29 +- 11 files changed, 830 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_email_attachment_preview.py create mode 100644 frontend/src/components/MailAttachmentPreview.test.tsx create mode 100644 frontend/src/components/MailAttachmentPreview.tsx diff --git a/AGENTS.md b/AGENTS.md index 2a45040bd..1a14c29bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -446,6 +446,12 @@ in this repo. topic components, or label evidence by a bare document, model, topic, rank, label, or display value. - When reviews find public/private identifier leaks, stale API fixture shapes, or recurring bug patterns, update tests, frontend mocks, E2E mocks, README examples, architecture docs, and explicitly record the anti-pattern in `AGENTS.md` so the same bug pattern does not reappear in copied examples. +- Mail attachment surfaces must list the current email's files with opaque + `asset_key` values and open the signed + `/api/data/repository-assets/{asset_key}/preview` contract. Do not expose + sequential attachment ids, render missing preview text as empty document + content, or send buyers only to the Data repository list as a substitute for + opening the HWPX file on the selected mail. - Memoized id-to-record Maps must be first-wins (`if (!map.has(key)) map.set(...)`). `new Map(items.map((item) => [String(item.id), item]))` is last-wins and desynchronizes first-wins label maps from the selected node or edge when diff --git a/backend/api/emails.py b/backend/api/emails.py index 2b0a9dbd6..e1ddef19b 100644 --- a/backend/api/emails.py +++ b/backend/api/emails.py @@ -3,8 +3,10 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import func, or_, select +from sqlalchemy.orm import selectinload from db.session import get_db from db.models import Email +from api.data import _opaque_asset_key from pydantic import BaseModel, EmailStr, Field, field_validator import datetime import time @@ -165,6 +167,22 @@ def _email_list_item( ) +def _mail_attachment_refs(email: Email) -> list["EmailAttachmentRef"]: + """Return opaque, display-safe attachment refs for one scoped email.""" + + refs: list[EmailAttachmentRef] = [] + for attachment in getattr(email, "attachments", None) or []: + file_name = _safe_email_display_text(getattr(attachment, "filename", None)) + refs.append( + EmailAttachmentRef( + asset_key=_opaque_asset_key(email, attachment), + file_name=file_name or "email attachment", + parser_family=str(getattr(attachment, "parser_key", "") or "") or None, + ) + ) + return refs + + def _email_detail_response(email: Email) -> "EmailDetailResponse": return EmailDetailResponse( id=email.id, @@ -178,6 +196,7 @@ def _email_detail_response(email: Email) -> "EmailDetailResponse": thread_id=canonical_thread_key(email), in_reply_to=email.in_reply_to, references=email.references, + attachments=_mail_attachment_refs(email), ) @@ -197,6 +216,14 @@ class EmailListItem(BaseModel): schedule_conflict: bool = False +class EmailAttachmentRef(BaseModel): + """Opaque mail-attachment handle that opens the read-only preview contract.""" + + asset_key: str + file_name: str + parser_family: str | None = None + + class EmailDetailResponse(BaseModel): id: int message_id: str @@ -211,6 +238,7 @@ class EmailDetailResponse(BaseModel): references: str | None = None requires_reply: bool = False schedule_conflict: bool = False + attachments: list[EmailAttachmentRef] = Field(default_factory=list) class UniqueThreadCandidateRequest(BaseModel): @@ -645,7 +673,9 @@ async def get_email( ): # Ensure auth context validates the request payload and scopes access result = await db.execute( - select(Email).where( + select(Email) + .options(selectinload(Email.attachments)) + .where( Email.id == email_id, *Email.owner_filters(auth_context.user_id, auth_context.organization_id), ) @@ -668,6 +698,7 @@ async def get_email_thread( lookup_values = thread_lookup_values(thread_id) result = await db.execute( select(Email) + .options(selectinload(Email.attachments)) .where( *Email.owner_filters(auth_context.user_id, auth_context.organization_id), or_( diff --git a/backend/tests/test_email_attachment_preview.py b/backend/tests/test_email_attachment_preview.py new file mode 100644 index 000000000..0a64a967d --- /dev/null +++ b/backend/tests/test_email_attachment_preview.py @@ -0,0 +1,351 @@ +"""Mail-detail attachments must open the existing read-only HWPX preview. + +#1404 already exposes recognized ordered paragraphs at +``/api/data/repository-assets/{asset_key}/preview``. These tests require the +core mail attachment experience to list the current email's files with that +same opaque ``asset_key`` so a buyer can open HWPX text without going through +the Data repository list. Preview recognition semantics stay unchanged. +""" + +from __future__ import annotations + +import datetime + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from db.models import Attachment, Email +from main import app + + +pytestmark = pytest.mark.usefixtures("dev_auth_dependency_overrides") + + +class _EmailDetailMockSession: + """Return one email row for mail-detail attachment listing tests.""" + + def __init__(self, items: list[Email]): + self.items = items + + async def execute(self, query, params=None): + class _MockResult: + def __init__(self, rows): + self.rows = rows + + def scalars(self): + return self + + def all(self): + return self.rows + + def scalar_one_or_none(self): + return self.rows[0] if self.rows else None + + return _MockResult(self.items) + + +def _hwpx_mail_email() -> Email: + """Build one scoped email that already has a recognized HWPX attachment.""" + + email = Email( + id=41, + user_id="testuser", + organization_id="org-acme", + message_id="", + thread_id="thread-mail-hwpx", + sender="partner@example.com", + reply_to="partner@example.com", + recipients="user@example.com", + subject="HWPX decision record", + date=datetime.datetime.now(datetime.timezone.utc), + body="Please review the attached HWPX decision record.", + ) + email.attachments = [ + Attachment( + id=99, + email_id=41, + filename="decision.hwpx", + content="Quarterly decision record\n\nApprove the next action.", + content_type="application/hwp+zip", + parse_status="hwpx_xml_package_parsed", + parse_content_type="application/hwp+zip", + parser_key="hwpx", + ) + ] + return email + + +@pytest.fixture +def mail_hwpx_email() -> Email: + """Provide the recognized HWPX mail fixture to route tests.""" + + return _hwpx_mail_email() + + +@pytest.fixture +def db_session(mail_hwpx_email: Email) -> _EmailDetailMockSession: + """Bind the mail-detail mock session to the recognized HWPX email.""" + + return _EmailDetailMockSession([mail_hwpx_email]) + + +@pytest.fixture(autouse=True) +def override_get_db(db_session: _EmailDetailMockSession): + """Install the mail-detail mock database for this module.""" + + from db.session import get_db + + app.dependency_overrides[get_db] = lambda: db_session + yield + app.dependency_overrides.pop(get_db, None) + + +@pytest_asyncio.fixture +async def client(): + """Return a signed-session test client scoped to the fixture owner.""" + + async with AsyncClient( + transport=ASGITransport(app=app), + headers={ + "X-User-Id": "testuser", + "X-Organization-Id": "org-acme", + }, + base_url="http://test", + ) as async_client: + yield async_client + + +@pytest.mark.asyncio +async def test_email_detail_lists_opaque_hwpx_attachment_refs( + client: AsyncClient, + mail_hwpx_email: Email, +) -> None: + """Mail detail exposes the current email's HWPX file without sequential ids.""" + + import api.data as data_api + + response = await client.get(f"/api/emails/{mail_hwpx_email.id}") + + assert response.status_code == 200, response.text + payload = response.json() + attachments = payload["attachments"] + assert len(attachments) == 1 + attachment = attachments[0] + expected_key = data_api._opaque_asset_key( + mail_hwpx_email, + mail_hwpx_email.attachments[0], + ) + assert attachment["asset_key"] == expected_key + assert attachment["asset_key"].startswith("asset_") + assert attachment["file_name"] == "decision.hwpx" + assert attachment["parser_family"] == "hwpx" + assert "id" not in attachment + assert "email_id" not in attachment + assert "content" not in attachment + assert " None: + """Thread messages keep the same opaque attachment keys as mail detail.""" + + import api.data as data_api + + response = await client.get( + f"/api/emails/thread/{mail_hwpx_email.thread_id}" + ) + + assert response.status_code == 200, response.text + items = response.json()["thread"] + assert len(items) == 1 + attachment = items[0]["attachments"][0] + assert attachment["asset_key"] == data_api._opaque_asset_key( + mail_hwpx_email, + mail_hwpx_email.attachments[0], + ) + assert attachment["file_name"] == "decision.hwpx" + assert "id" not in attachment + + +@pytest.mark.asyncio +async def test_email_detail_without_attachments_returns_empty_list( + client: AsyncClient, +) -> None: + """Missing attachments stay an empty list, never implied empty document text.""" + + from db.session import get_db + + bare_email = Email( + id=42, + user_id="testuser", + organization_id="org-acme", + message_id="", + thread_id="thread-mail-empty", + sender="partner@example.com", + recipients="user@example.com", + subject="No attachments", + date=datetime.datetime.now(datetime.timezone.utc), + body="There is no attached file.", + ) + app.dependency_overrides[get_db] = lambda: _EmailDetailMockSession([bare_email]) + + response = await client.get(f"/api/emails/{bare_email.id}") + + assert response.status_code == 200, response.text + assert response.json()["attachments"] == [] + + +@pytest.mark.asyncio +@pytest.mark.postgres +async def test_email_detail_preview_postgres_smoke_lists_opaque_hwpx_key() -> None: + """Mail detail returns the same opaque key the scoped preview can open.""" + + import uuid + + import asyncpg + from sqlalchemy import text + from sqlalchemy.exc import OperationalError + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from core.config import settings + from db.models import Base + from db.session import get_db + + database_url = getattr(settings, "DATABASE_URL", None) + if not database_url: + pytest.skip("PostgreSQL smoke path unavailable: DATABASE_URL is not set") + + user_id = f"mail_preview_user_{uuid.uuid4().hex[:12]}" + organization_id = f"mail_preview_org_{uuid.uuid4().hex[:12]}" + message_id = f"" + engine = create_async_engine(database_url, echo=False) + try: + async with engine.begin() as conn: + await conn.execute(text("SELECT 1")) + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + await conn.run_sync(Base.metadata.create_all) + inserted = await conn.execute( + text( + """ + INSERT INTO email_records ( + user_id, organization_id, message_id, thread_id, + fingerprint, sender, recipients, subject, "date", body + ) + VALUES ( + :user_id, :organization_id, :message_id, :thread_id, + :fingerprint, :sender, :recipients, :subject, now(), :body + ) + RETURNING id + """ + ), + { + "user_id": user_id, + "organization_id": organization_id, + "message_id": message_id, + "thread_id": "thread-mail-preview-hwpx", + "fingerprint": f"sha256:{message_id}", + "sender": "partner@example.com", + "recipients": "owner@example.com", + "subject": "Mail HWPX preview smoke", + "body": "source email body", + }, + ) + email_id = inserted.scalar_one() + await conn.execute( + text( + """ + INSERT INTO email_attachments ( + email_id, filename, content, + content_type, parse_status, parse_content_type, + parser_key, parse_error_code + ) + VALUES ( + CAST(:email_id AS INTEGER), 'decision.hwpx', + :content, 'application/hwp+zip', + 'hwpx_xml_package_parsed', 'application/hwp+zip', + 'hwpx', NULL + ) + """ + ), + { + "email_id": email_id, + "content": ( + "Quarterly decision record\n\nApprove the next action." + ), + }, + ) + except ( + ConnectionRefusedError, + OSError, + OperationalError, + asyncpg.CannotConnectNowError, + asyncpg.InvalidAuthorizationSpecificationError, + asyncpg.InvalidCatalogNameError, + asyncpg.InvalidPasswordError, + ): + await engine.dispose() + pytest.skip("PostgreSQL smoke path unavailable") + except Exception: + await engine.dispose() + raise + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + async def override_real_db(): + async with session_factory() as session: + yield session + + import api.data as data_api + + expected_key = data_api._opaque_asset_key( + Email( + user_id=user_id, + organization_id=organization_id, + message_id=message_id, + ), + Attachment(filename="decision.hwpx"), + ) + previous_override = app.dependency_overrides.get(get_db) + app.dependency_overrides[get_db] = override_real_db + try: + async with AsyncClient( + transport=ASGITransport(app=app), + headers={ + "X-User-Id": user_id, + "X-Organization-Id": organization_id, + }, + base_url="http://test", + ) as real_client: + detail = await real_client.get(f"/api/emails/{email_id}") + preview = await real_client.get( + f"/api/data/repository-assets/{expected_key}/preview" + ) + hidden = await real_client.get( + "/api/data/repository-assets/asset_missing_mail_preview/preview" + ) + finally: + if previous_override is None: + app.dependency_overrides.pop(get_db, None) + else: + app.dependency_overrides[get_db] = previous_override + await engine.dispose() + + assert detail.status_code == 200, detail.text + attachments = detail.json()["attachments"] + assert len(attachments) == 1 + assert attachments[0]["asset_key"] == expected_key + assert attachments[0]["file_name"] == "decision.hwpx" + assert "id" not in attachments[0] + assert preview.status_code == 200, preview.text + assert preview.json()["preview_state"] == "recognized" + assert preview.json()["paragraph_texts"] == [ + "Quarterly decision record", + "Approve the next action.", + ] + assert hidden.status_code == 404 + assert hidden.json()["detail"]["error_code"] == "repository_asset_not_found" diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md index 32adf2c8d..30cf39a2e 100644 --- a/docs/doctoring/hwp-hwpx-attachment-recognition.md +++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md @@ -176,6 +176,7 @@ traceability, not a substitute for current-head gates. | Deferred HWPX worker selection and local execution | `backend/services/newsdom_worker.py` | `test_hwpx_worker.py` | Active stacked PR #1373 | | Parsed attachment text + graph provenance | shared content-graph landing path | HWPX worker + recognizer tests | Active stacked PR #1373 | | Buyer-visible recognized HWPX paragraph preview | `backend/services/repository_asset_preview.py`, Data attachment view | `test_repository_asset_preview.py`, `RepositoryAssetPreviewPanel.test.tsx` | Active stacked preview PR | +| Mail-detail HWPX preview via the same read-only contract | `backend/api/emails.py`, `MailAttachmentPreview` | `test_email_attachment_preview.py`, `MailAttachmentPreview.test.tsx` | Active stacked mail preview PR | | Binary HWP conversion | future sandboxed converter | none yet | Planned / out of this slice | | HWPX tables, images, layout fidelity | future bounded recognizers | none yet | Planned / out of this slice | | Protected-`develop` shipped HWP/HWPX recognition | protected branch | integrated release gates | Not yet shipped | @@ -207,6 +208,19 @@ recognizer: KS X 6101/OWPML section and paragraph order is already persisted, and the UI only reads that order (Korean Agency for Technology and Standards, 2024; Hancom Tech, 2025b, 2025c). +## Buyer-visible mail attachment preview — stacked on PR #1404 + +Recognized HWPX text that is only reachable from Data is not the core mail +attachment experience. This slice lists the current email's attachments with +the same opaque `asset_key` and opens the existing read-only preview contract. +It does not reconstruct tables or images, convert binary HWP, call a model or +NewsDOM, or change #1353/#1373/#1404 recognition or preview semantics. + +Pending HWPX still means wait or choose another file. Failed or unavailable +preview still means choose another file. Missing text is never presented as +empty document content. Unknown and cross-workspace resources remain one +indistinguishable 404. + ## Out of scope This slice does not reconstruct HWPX tables, images, charts, layout, styles, or diff --git a/frontend/src/components/EmailDetail.test.tsx b/frontend/src/components/EmailDetail.test.tsx index db2b617b6..77dcc9cb6 100644 --- a/frontend/src/components/EmailDetail.test.tsx +++ b/frontend/src/components/EmailDetail.test.tsx @@ -1278,6 +1278,74 @@ describe("EmailDetail", () => { )).toBe(true); }); + it("lets a buyer open recognized HWPX paragraph text from a mail attachment", async () => { + const email = { + id: 44, + message_id: "", + thread_id: null, + sender: "partner@example.com", + recipients: "user@example.com", + subject: "HWPX from inbox", + date: "2026-08-17T09:00:00Z", + body: "Please read the attached decision record.", + attachments: [ + { + asset_key: "asset_mail_hwpx_recognized", + file_name: "decision.hwpx", + parser_family: "hwpx", + }, + ], + }; + + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/emails/44")) return Promise.resolve(jsonResponse(email)); + if (url.endsWith("/api/llm/summarize")) { + return Promise.resolve(jsonResponse({ summary: "첨부 결정문을 확인해야 합니다.", action_items: [] })); + } + if (url.endsWith("/api/data/repository-assets/asset_mail_hwpx_recognized/preview")) { + return Promise.resolve(jsonResponse({ + asset_key: "asset_mail_hwpx_recognized", + asset_type: "email_attachment", + preview_state: "recognized", + parser_family: "hwpx", + paragraph_texts: ["Quarterly decision record", "Approve the next action."], + preview_text: "Quarterly decision record\n\nApprove the next action.", + next_action: "read_recognized_text", + error_code: null, + provider_write_executed: false, + })); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + + const openButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("decision.hwpx"), + ); + expect(openButton).not.toBeUndefined(); + + await act(async () => { + openButton?.click(); + }); + await flushAsyncWork(); + + expect(container.textContent).toContain("Quarterly decision record"); + expect(container.textContent).toContain("Approve the next action."); + expect(container.textContent).toContain("인식된 본문"); + expect(container.textContent).not.toContain("본문이 없습니다"); + expect(container.textContent).not.toContain("asset_mail_hwpx_recognized"); + }); + it("handles send message failure", async () => { const email: TestEmail = { id: 22, diff --git a/frontend/src/components/EmailDetail.tsx b/frontend/src/components/EmailDetail.tsx index e634a896c..92b563b6e 100644 --- a/frontend/src/components/EmailDetail.tsx +++ b/frontend/src/components/EmailDetail.tsx @@ -16,8 +16,10 @@ import { buildReplyPayload, formatEmailDate, getConversationMessages, + type MailAttachmentRef, type ThreadEmailData, } from "@/lib/email-threading"; +import { MailAttachmentPreview } from "@/components/MailAttachmentPreview"; import { toMailBodyText, toMailDisplayText } from "@/lib/mail-text"; import { toConfidencePercent } from "@/lib/confidence"; import { @@ -29,6 +31,7 @@ import { type EmailData = ThreadEmailData & { requires_reply?: boolean; schedule_conflict?: boolean; + attachments?: MailAttachmentRef[]; }; interface LlmData { summary: string; @@ -652,6 +655,8 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand =
+ +