diff --git a/backend/api/emails.py b/backend/api/emails.py index 2b0a9dbd6..048cf448b 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 db.models import Attachment, Email +from api.data import _opaque_asset_key from pydantic import BaseModel, EmailStr, Field, field_validator import datetime import time @@ -165,6 +167,32 @@ def _email_list_item( ) +def _mail_attachment_load_option(): + """Load only metadata required to render mail attachment references.""" + + return selectinload(Email.attachments).load_only( + Attachment.filename, + Attachment.parser_key, + raiseload=True, + ) + + +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 +206,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 +226,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 +248,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 +683,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(_mail_attachment_load_option()) + .where( Email.id == email_id, *Email.owner_filters(auth_context.user_id, auth_context.organization_id), ) @@ -668,6 +708,7 @@ async def get_email_thread( lookup_values = thread_lookup_values(thread_id) result = await db.execute( select(Email) + .options(_mail_attachment_load_option()) .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..ea9c1fa45 --- /dev/null +++ b/backend/tests/test_email_attachment_preview.py @@ -0,0 +1,386 @@ +"""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 without loading attachment bodies.""" + + import uuid + + import asyncpg + from sqlalchemy import event, 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 + attachment_selects: list[str] = [] + + def capture_attachment_select( + _conn, _cursor, statement: str, _parameters, _context, _executemany + ) -> None: + if "from email_attachments" in statement.lower().replace('"', ""): + attachment_selects.append(statement) + + event.listen(engine.sync_engine, "before_cursor_execute", capture_attachment_select) + 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}") + detail_attachment_selects = tuple(attachment_selects) + attachment_selects.clear() + thread = await real_client.get( + "/api/emails/thread/thread-mail-preview-hwpx" + ) + thread_attachment_selects = tuple(attachment_selects) + attachment_selects.clear() + 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: + event.remove( + engine.sync_engine, "before_cursor_execute", capture_attachment_select + ) + if previous_override is None: + app.dependency_overrides.pop(get_db, None) + else: + app.dependency_overrides[get_db] = previous_override + async with engine.begin() as cleanup_conn: + await cleanup_conn.execute( + text("DELETE FROM email_attachments WHERE email_id = :email_id"), + {"email_id": email_id}, + ) + await cleanup_conn.execute( + text("DELETE FROM email_records WHERE id = :email_id"), + {"email_id": email_id}, + ) + 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 thread.status_code == 200, thread.text + assert len(thread.json()["thread"]) == 1 + assert detail_attachment_selects + assert thread_attachment_selects + for statement in (*detail_attachment_selects, *thread_attachment_selects): + normalized_statement = statement.lower().replace('"', "") + assert "email_attachments.content" not in normalized_statement + 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/backend/tests/test_email_attachment_preview_auth.py b/backend/tests/test_email_attachment_preview_auth.py new file mode 100644 index 000000000..c2560649a --- /dev/null +++ b/backend/tests/test_email_attachment_preview_auth.py @@ -0,0 +1,145 @@ +"""Signed-session coverage for mail attachment metadata endpoints.""" + +from __future__ import annotations + +import datetime +import time + +import jwt +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from api.auth import SESSION_AUDIENCE, SESSION_ISSUER +from core.config import settings +from db.models import Attachment, Email +from db.session import get_db +from main import app + + +class _SignedMailMockSession: + """Return one tenant-scoped email while authentication stays production-real.""" + + def __init__(self, email: Email): + self.email = email + + async def execute(self, query, params=None): + class _MockResult: + def __init__(self, email: Email): + self.email = email + + def scalar_one_or_none(self): + return self.email + + def scalars(self): + return self + + def all(self): + return [self.email] + + return _MockResult(self.email) + + +def _signed_mail_email() -> Email: + email = Email( + id=51, + user_id="signed-mail-user", + organization_id="org-signed-mail", + message_id="", + thread_id="thread-signed-mail-preview", + sender="partner@example.com", + recipients="owner@example.com", + subject="Signed HWPX preview", + date=datetime.datetime.now(datetime.timezone.utc), + body="Open the recognized attachment.", + ) + email.attachments = [ + Attachment( + id=151, + email_id=51, + filename="decision.hwpx", + parser_key="hwpx", + ) + ] + return email + + +def _signed_session_token() -> str: + configured_secret = settings.AUTH_SESSION_HMAC_SECRET + assert configured_secret is not None + now = int(time.time()) + return jwt.encode( + { + "ver": 1, + "iss": SESSION_ISSUER, + "aud": SESSION_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "signed-mail-user", + "role": "member", + "org": "org-signed-mail", + "groups": [], + "workspace": "workspace-org-signed-mail", + }, + configured_secret.get_secret_value(), + algorithm="HS256", + ) + + +@pytest.fixture(autouse=True) +def override_mail_db(): + email = _signed_mail_email() + app.dependency_overrides[get_db] = lambda: _SignedMailMockSession(email) + yield + app.dependency_overrides.pop(get_db, None) + + +@pytest_asyncio.fixture +async def client(): + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as async_client: + yield async_client + + +@pytest.mark.asyncio +async def test_email_attachment_detail_uses_real_signed_bearer_session( + client: AsyncClient, +) -> None: + """A valid HMAC bearer session authorizes attachment metadata without identity headers.""" + + response = await client.get( + "/api/emails/51", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + ) + + assert response.status_code == 200, response.text + payload = response.json() + assert payload["id"] == 51 + assert payload["attachments"] == [ + { + "asset_key": payload["attachments"][0]["asset_key"], + "file_name": "decision.hwpx", + "parser_family": "hwpx", + } + ] + assert payload["attachments"][0]["asset_key"].startswith("asset_") + + +@pytest.mark.asyncio +async def test_email_attachment_detail_rejects_public_identity_headers_without_session( + client: AsyncClient, +) -> None: + """Public identity headers cannot substitute for the signed bearer session.""" + + response = await client.get( + "/api/emails/51", + headers={ + "X-User-Id": "signed-mail-user", + "X-Organization-Id": "org-signed-mail", + }, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "Authentication required" diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md index 7a64d71e0..3f8b04b27 100644 --- a/docs/doctoring/hwp-hwpx-attachment-recognition.md +++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md @@ -188,6 +188,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 | @@ -219,6 +220,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 =
+ +