Skip to content
Draft
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
fdd6da7
feat(attachments): defer HWP and HWPX recognition safely
seonghobae Aug 15, 2026
f8698c2
test(attachments): cover HWP and HWPX deferred recognition
seonghobae Aug 15, 2026
a730d74
docs(attachments): record HWP and HWPX recognition boundary
seonghobae Aug 15, 2026
26b5f29
docs(attachments): add HWP and HWPX implementation plan
seonghobae Aug 15, 2026
d24fe48
docs(attachments): add HWP and HWPX merge checklist
seonghobae Aug 15, 2026
d6ecf95
docs(attachments): add HWP HWPX shipped-state note
seonghobae Aug 15, 2026
4b51240
test(attachments): bound HWPX package metadata
seonghobae Aug 15, 2026
b737ae8
fix(attachments): validate bounded HWPX package identity
seonghobae Aug 15, 2026
d818273
docs(attachments): record bounded HWPX admission
seonghobae Aug 15, 2026
d97281c
test(attachments): require HWP FileHeader identity
seonghobae Aug 15, 2026
07bd3b3
fix(attachments): require HWP FileHeader signature
seonghobae Aug 15, 2026
c8837fb
test(attachments): align valid HWP fixture with FileHeader
seonghobae Aug 15, 2026
1313fd8
docs(attachments): distinguish OLE from HWP identity
seonghobae Aug 15, 2026
8beb01c
Merge protected develop into HWP/HWPX recognition slice
seonghobae Aug 15, 2026
ad78111
Merge branch 'develop' into feat/hwp-hwpx-attachment-recognition
seonghobae Aug 17, 2026
c858519
Merge branch 'develop' into feat/hwp-hwpx-attachment-recognition
seonghobae Aug 17, 2026
7068326
Merge branch 'develop' into feat/hwp-hwpx-attachment-recognition
cursoragent Aug 17, 2026
8a4b895
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 20, 2026
52bbc4f
test: close attachment parser coverage gaps
seonghobae Aug 20, 2026
c966167
feat: bound deferred attachment recognition at 64 MiB
seonghobae Aug 20, 2026
e3e3fb0
fix(attachments): reject unknown deferred parser types
seonghobae Aug 20, 2026
315ea05
Merge remote-tracking branch 'origin/develop' into restack-1353
seonghobae Aug 21, 2026
9be0992
style(attachments): apply parser formatting
seonghobae Aug 21, 2026
4f3e95d
docs(adr): record attachment design traceability
seonghobae Aug 21, 2026
dd501da
fix(attachments): process deferred HWPX packages
seonghobae Aug 24, 2026
2a0d2c0
Merge branch 'develop' into feat/hwp-hwpx-attachment-recognition
seonghobae Aug 26, 2026
74f6e35
merge(develop): restack HWP parser boundary
seonghobae Sep 5, 2026
3ab3f6e
merge(stack): inherit TestClient dependency foundation
seonghobae Sep 5, 2026
44a268b
test(attachments): reject sectionless HWPX admission
seonghobae Sep 6, 2026
4281904
fix(attachments): align HWPX admission with recognizer
seonghobae Sep 6, 2026
50a4102
fix(tests): use one HWPX recognition import style
seonghobae Sep 6, 2026
5cf9577
docs(attachments): align HWPX admission evidence
seonghobae Sep 6, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## [Unreleased]
- HWP/HWPX 첨부파일을 명시적인 지연 인식 경계로 분류하고, HWP OLE+FileHeader 및 HWPX ZIP/mimetype 서명을 bounded preflight 합니다. 유효한 원본은 64MiB까지 base64 deferred payload로 보존하고, 손상·위조·과대 입력은 안정적인 rejection status로 fail-closed 합니다. ADR-0006과 APA 7th 근거를 함께 기록했으며 inline 변환·의미 추출은 아직 제공하지 않습니다.
- 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다.
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.

Expand Down
218 changes: 197 additions & 21 deletions backend/services/attachment_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import base64
import binascii
import io
import struct
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
Expand All @@ -14,8 +17,30 @@
"binary/octet-stream",
"application/x-binary",
}
_HWPX_CONTENT_TYPES = (
"application/hwp+zip",
"application/x-hwp+zip",
"application/vnd.hancom.hwpx",
)
_HWP_CONTENT_TYPES = (
"application/x-hwp",
"application/vnd.hancom.hwp",
"application/haansofthwp",
)
_HWP_OLE_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
_HWP_DOCUMENT_SIGNATURE = b"HWP Document File"
_HWPX_MIMETYPE = b"application/hwp+zip"
_ZIP_END_RECORD_SIGNATURE = b"PK\x05\x06"
_ZIP_END_RECORD_SIZE = 22
_ZIP_MAX_COMMENT_BYTES = 65_535
_ZIP_END_RECORD = struct.Struct("<4s4H2LH")
MAX_ATTACHMENT_PARSE_SOURCE_CHARS = 1_000_000
MAX_ATTACHMENT_PARSE_SOURCE_BYTES = 20 * 1024 * 1024
# Keep deferred attachment retention aligned with the email import transport.
MAX_ATTACHMENT_PARSE_SOURCE_BYTES = 64 * 1024 * 1024
Comment thread
seonghobae marked this conversation as resolved.
MAX_HWPX_ZIP_ENTRIES = 4_096
MAX_HWPX_CENTRAL_DIRECTORY_BYTES = 4 * 1024 * 1024
MAX_HWPX_ZIP_NAME_BYTES = 1 * 1024 * 1024
MAX_HWPX_MIMETYPE_BYTES = 128


@dataclass(frozen=True)
Expand Down Expand Up @@ -86,6 +111,20 @@ class AttachmentParserDescriptor:
extensions=(".pdf",),
parse_status="pdf_dom_recognition_pending",
),
AttachmentParserDescriptor(
parser_key="hwpx",
display_name="HWPX documents (OWPML XML package recognition)",
content_types=_HWPX_CONTENT_TYPES,
extensions=(".hwpx", ".owpml"),
parse_status="hwpx_xml_package_pending",
),
Comment thread
seonghobae marked this conversation as resolved.
AttachmentParserDescriptor(
parser_key="hwp",
display_name="HWP binary documents (sandboxed conversion)",
content_types=_HWP_CONTENT_TYPES,
extensions=(".hwp",),
parse_status="hwp_conversion_pending",
),
AttachmentParserDescriptor(
parser_key="unsupported_binary",
display_name="Unsupported binary attachments",
Expand All @@ -96,8 +135,14 @@ class AttachmentParserDescriptor:
)
# Statuses whose recognition is too heavy to run inline during import. The
# attachment is stored with the pending status and a background worker later
# calls the NewsDOM sidecar to fill in parse_content + the content graph.
_DEFERRED_PARSE_STATUSES = frozenset({"pdf_dom_recognition_pending"})
# calls a sandboxed recognizer/converter to fill parse_content and content graph.
_DEFERRED_PARSE_STATUSES = frozenset(
{
"pdf_dom_recognition_pending",
"hwpx_xml_package_pending",
"hwp_conversion_pending",
}
)
Comment thread
seonghobae marked this conversation as resolved.
_SUPPORTED_CONTENT_TYPES = {
content_type
for descriptor in _PARSER_MANIFEST
Expand All @@ -117,6 +162,11 @@ class AttachmentParserDescriptor:
or descriptor.parse_status in _DEFERRED_PARSE_STATUSES
for extension in descriptor.extensions
}
_DEFERRED_PAYLOAD_ERROR_MESSAGES = {
"invalid_pdf_payload": "Pending attachment payload is not a PDF",
"invalid_hwpx_payload": "Pending attachment payload is not a HWPX package",
"invalid_hwp_payload": "Pending attachment payload is not a HWP binary document",
}


@dataclass(frozen=True)
Expand Down Expand Up @@ -154,13 +204,13 @@ def parse_email_attachment(

deferred_descriptor = _DEFERRED_DESCRIPTORS_BY_CONTENT_TYPE.get(parse_content_type)
if deferred_descriptor is not None:
# Heavy recognition (OCR/MinerU via the NewsDOM sidecar) must not run
# inline during import. Retain the raw bytes as a base64 payload in
# ``content`` (mirroring the document-upload path's document_content) so
# the worker can decode and recognize them later; mark the attachment
# pending. The pending status gates display, and the worker overwrites
# ``content`` with the recognized text on success. Without this the
# source bytes were discarded and recognition was impossible.
# Heavy recognition (OCR/MinerU, HWPX XML section extraction, or HWP
# binary conversion) must not run inline during import. Retain the raw
# bytes as a base64 payload in ``content`` so the worker can recognize
# them later; mark the attachment pending. The pending status gates
# display, and the worker overwrites ``content`` with recognized text on
# success. Without this the source bytes are discarded and recognition
# is impossible.
deferred_payload = _coerce_deferred_payload_bytes(raw_content)
if len(deferred_payload) > MAX_ATTACHMENT_PARSE_SOURCE_BYTES:
return AttachmentParseResult(
Expand All @@ -173,18 +223,20 @@ def parse_email_attachment(
parse_status="parse_size_limit_exceeded",
parse_error_code="parse_size_limit_exceeded",
)
if parse_content_type == "application/pdf" and not deferred_payload.startswith(
b"%PDF-"
):
payload_error_code = _deferred_payload_error_code(
parse_content_type,
deferred_payload,
)
if payload_error_code is not None:
return AttachmentParseResult(
filename=safe_filename,
content="",
content_type=normalized_content_type,
parse_content="",
parse_content_type=parse_content_type,
parser_key=deferred_descriptor.parser_key,
parse_status="invalid_pdf_payload",
parse_error_code="invalid_pdf_payload",
parse_status=payload_error_code,
parse_error_code=payload_error_code,
)
return AttachmentParseResult(
filename=safe_filename,
Expand Down Expand Up @@ -289,23 +341,147 @@ def _encode_deferred_payload(payload: bytes) -> str:
return base64.b64encode(payload).decode("ascii")


def decode_deferred_attachment_payload(content: str | None) -> bytes:
def decode_deferred_attachment_payload(
content: str | None,
expected_content_type: str = "application/pdf",
) -> bytes:
"""Decode the base64 payload retained on a pending attachment's content.

Raises ``ValueError`` when the stored payload is not valid base64, so the
recognition worker can record an error status instead of crashing.
Raises ``ValueError`` when the stored payload is not valid base64 or no
longer matches the expected deferred parser family, so the recognition
worker can record an error status instead of crashing.
"""
parse_content_type = _normalize_content_type(expected_content_type)
if parse_content_type not in _DEFERRED_DESCRIPTORS_BY_CONTENT_TYPE:
raise ValueError(
"Pending attachment content type is not a deferred parser type"
)
try:
payload = base64.b64decode((content or "").encode("ascii"), validate=True)
except (binascii.Error, UnicodeEncodeError, ValueError) as exc:
raise ValueError("Pending attachment payload is not valid base64") from exc
if len(payload) > MAX_ATTACHMENT_PARSE_SOURCE_BYTES:
raise ValueError("Pending attachment PDF exceeds the parse size limit")
if not payload.startswith(b"%PDF-"):
raise ValueError("Pending attachment payload is not a PDF")
raise ValueError("Pending attachment payload exceeds the parse size limit")
payload_error_code = _deferred_payload_error_code(parse_content_type, payload)
if payload_error_code is not None:
raise ValueError(_DEFERRED_PAYLOAD_ERROR_MESSAGES[payload_error_code])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return payload


def _deferred_payload_error_code(
parse_content_type: str,
payload: bytes,
) -> str | None:
"""Return an error code when deferred parser bytes fail a cheap signature."""
if parse_content_type == "application/pdf" and not payload.startswith(b"%PDF-"):
return "invalid_pdf_payload"
if parse_content_type in _HWPX_CONTENT_TYPES and not _is_hwpx_payload(payload):
return "invalid_hwpx_payload"
if parse_content_type in _HWP_CONTENT_TYPES and not _is_hwp_payload(payload):
return "invalid_hwp_payload"
return None


def _bounded_zip_directory_metadata(payload: bytes) -> tuple[int, int] | None:
"""Return bounded ZIP directory counts without materializing member metadata."""
search_start = max(
0,
len(payload) - (_ZIP_END_RECORD_SIZE + _ZIP_MAX_COMMENT_BYTES),
)
record_offset = payload.rfind(_ZIP_END_RECORD_SIGNATURE, search_start)
if record_offset < 0 or record_offset + _ZIP_END_RECORD_SIZE > len(payload):
return None

(
signature,
disk_number,
directory_disk_number,
disk_entry_count,
total_entry_count,
directory_size,
directory_offset,
comment_size,
) = _ZIP_END_RECORD.unpack_from(payload, record_offset)
if (
signature != _ZIP_END_RECORD_SIGNATURE
or disk_number != 0
or directory_disk_number != 0
or disk_entry_count != total_entry_count
or not 0 < total_entry_count <= MAX_HWPX_ZIP_ENTRIES
or directory_size > MAX_HWPX_CENTRAL_DIRECTORY_BYTES
or record_offset + _ZIP_END_RECORD_SIZE + comment_size != len(payload)
or directory_offset + directory_size > record_offset
):
return None
return total_entry_count, directory_size
Comment thread
seonghobae marked this conversation as resolved.


def _is_hwpx_payload(payload: bytes) -> bool:
"""Return whether bytes look like a bounded HWPX/OWPML ZIP package.

Recognition checks ZIP directory budgets and the exact HWPX ``mimetype``
signature before inspecting only member names. It does not parse section XML,
execute active content, extract files, or fetch external resources.
"""
directory_metadata = _bounded_zip_directory_metadata(payload)
if not payload.startswith(b"PK") or directory_metadata is None:
return False
expected_entry_count, _ = directory_metadata

try:
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
entries = archive.infolist()
aggregate_name_bytes = sum(
len(entry.filename.encode("utf-8", errors="surrogatepass"))
for entry in entries
)
if (
len(entries) != expected_entry_count
or aggregate_name_bytes > MAX_HWPX_ZIP_NAME_BYTES
):
return False

mimetype_entries = [
entry for entry in entries if entry.filename == "mimetype"
]
if len(mimetype_entries) != 1:
return False
mimetype_entry = mimetype_entries[0]
if (
mimetype_entry.flag_bits & 0x1
or mimetype_entry.file_size > MAX_HWPX_MIMETYPE_BYTES
):
return False
mimetype = archive.read(mimetype_entry)
names = {entry.filename for entry in entries}
except (
NotImplementedError,
OSError,
RuntimeError,
ValueError,
zipfile.BadZipFile,
):
return False

has_manifest = "Contents/content.hpf" in names or "META-INF/manifest.xml" in names
has_section = any(
name.startswith("Contents/section") and name.endswith(".xml")
for name in names
)
return (
mimetype == _HWPX_MIMETYPE
and "version.xml" in names
and (has_manifest or has_section)
)


def _is_hwp_payload(payload: bytes) -> bool:
"""Require both the OLE container magic and HWP FileHeader identity."""
if not payload.startswith(_HWP_OLE_MAGIC):
return False
return payload.find(_HWP_DOCUMENT_SIGNATURE, len(_HWP_OLE_MAGIC)) >= 0


def _coerce_text(raw_content: Any) -> str:
"""Coerce arbitrary attachment content to NUL-free text."""
if raw_content is None:
Expand Down
Loading
Loading