Skip to content

feat(service-mailbox): store an ingested message's attachments, and each forwarded message as an .eml, through file-attachments - #23484

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23307-mail-attachments
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23307-mail-attachments

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23307

Clause-②: no

What this does

@objectstack/service-mailbox's ingest now stores every part of a stored message besides its text and HTML bodies as a file of that message's sys_mail_message row. It goes through the server-side verb IFileAttachmentService.attachFile (the kernel service file-attachments, FILE_ATTACHMENT_SERVICE, landed in #23386). Each attached file is one part. Each forwarded message/rfc822 or message/global part is stored as the .eml file it travels as.

  • Parent: the row the message wrote (parentObject: 'sys_mail_message', parentId = the new row's id).
  • Organization: the mailbox's own organization (sys_mailbox.organization_id), never null. This follows the seat pointer from feat(service-storage): the file-attachments service, a server-side verb that stores bytes and attaches them to a record #23386's contract review: the verb applies no per-user check, so this caller states its parent and its organization, and both are pinned below.
  • Order: the row first, because it is the parent. Then one attachFile per part, in message order. Then the association links and associated, as before. A message therefore leaves the restricted pool with its attachments already in place.
  • Only on the stored path: a duplicate (stage 1's dedupe, or the unique key settling a concurrent pair) and a suppressed message return before any attachFile. A re-delivery adds nothing, and rematch never reads a part.
  • ⛔ There is no second representation: no column of the row names a part, and no byte is stored in a column.

The landing package is the producer, as the claim's file surface says. @objectstack/service-storage and packages/spec/** are not touched.

H1: how the writer is resolved (measured)

  • How ingest reaches a kernel service today. The plugin hands MailIngestService a resolver closure for the engine (engine: () => ctx.getService('objectql')), which is read on each call. The attach verb follows the same shape. The constructor gains the optional attachments: () => IFileAttachmentService | undefined, and MailboxServicePlugin supplies ctx.getService(FILE_ATTACHMENT_SERVICE), answering undefined when it is not registered. The kernel's getService throws for a name nothing registered (core/src/kernel.ts, the context accessor), so the plugin catches that. The resolver runs per message that carries a part, and nothing is cached, so plugin order does not matter.
  • No new runtime dependency. FILE_ATTACHMENT_SERVICE and IFileAttachmentService come from @objectstack/spec/contracts, which the package already depends on. @objectstack/service-storage is added as a devDependency only, for the real-verb pins (see "Surface beyond the claim's file list").
  • Choice when no file-attachments service is composed: the message is stored without its parts, and a warn says so once per instance, naming the remedy (compose the storage capability). This is a composition gap, not a write that failed: AGENTS.md's degradation rule puts an if (!service) composition branch at warn. storage is on PLATFORM_ALWAYS_ON_CAPABILITIES, so this branch is reached only on a kernel whose slate leaves it out.
  • Before kernel:bootstrapped, the verb answers 503 SERVICE_UNAVAILABLE. That is handled as a refusal (next section), not as absence. The IMAP sync's interval job first fires one interval after kernel:ready (interval-job-adapter.ts, setInterval), so the sync does not reach that window in practice.

H2: what postal-mime 4.0.5 hands over (measured)

I probed PostalMime.parse with the package's own options (forceRfc822Attachments: true, attachmentEncoding: 'arraybuffer') on one message carrying each kind of part:

  • Every non-body leaf part arrives in email.attachments with filename (or null), mimeType, disposition (attachment, inline or null), and content as an ArrayBuffer.
  • A forwarded message/rfc822 part arrives as an attachment whose bytes are byte-identical to the forwarded message. A message/global part arrives with its transfer encoding decoded, and its UTF-8 bytes are identical. The parts inside a forwarded message are not listed on their own, because forceRfc822Attachments keeps the part whole. They travel inside its .eml.
  • An inline image (Content-Disposition: inline with a Content-ID, inside multipart/related) arrives as an attachment with disposition: 'inline' and related: true.
  • An Apple-Mail-style attached file marked Content-Disposition: inline arrives with disposition: 'inline' and no related.
  • A second text/plain part with no disposition is folded into the text body. It is not an attachment.

Choice: inline parts count. Every part postal-mime lists is stored, whatever its disposition. A disposition filter would drop the files Apple Mail attaches, because it marks them inline. It would also drop the cid: images the stored body_html references, which are bytes the message carries and which nothing else keeps. The line is postal-mime's own split between bodies and attachments.

Naming:

  • A file keeps the name the message gives it. A part with no name is attachment-N, where N is its position among the message's parts.
  • A forwarded part is named by its own file name, else its Subject, else forwarded. The name always ends in .eml.
  • Control characters and path separators become _. A name over sys_attachment.file_name's 255 is cut in its stem, keeping the extension.
  • The part's own media type is kept: a forwarded message/global part stays message/global, not message/rfc822. A type over mime_type's 128 is stored as application/octet-stream.

H3: order and failure (pinned)

  • A refused part loses neither the message nor its other parts. It is caught per part and logged at error, once, naming the part (number, file name, type, size), the message and the mailbox, and quoting the refusal's CODE (status): sentence. The verb's sentences name their own remedy, for example the max_upload_mb setting for 413.
    • Why error and not warn: the message looks complete while a part it carried is gone, and nothing else will report it, because a re-delivery is a duplicate. That is the durability class in AGENTS.md's degradation rule. The package's IMAP sync already logs a message it skips at error for the same reason.
    • This required MailIngestLogger to gain error. See "For the seat".
  • A re-delivery stops at stage 1's dedupe before any attachFile. This is pinned on the double (call count) and on the real verb (sys_attachment and sys_file counts unchanged).
  • A re-match never re-attaches, and a suppressed message attaches nothing (pinned).

H4: organization (pinned)

A mailbox in organization A stores both rows (sys_attachment, sys_file) in A. A mailbox in organization B stores them in B. Each pin is the other's control. Both are pinned on the real verb, and the double records organizationId per call.

Pins and their controls

src/mailbox-attachments-kernel.test.ts boots a real ObjectKernel composing ObjectQLPlugin, StorageServicePlugin (local adapter in a temp directory, removed after the run, with a settings service carrying max_upload_mb: 0.001 = 1048 bytes) and MailboxServicePlugin, over sqlite :memory:. The seam is reached as the mailbox service and finds the verb on its own.

Pin Control
One forwarded message with one file gives 1 row and 2 sys_attachment rows on it (quote.pdf, question.eml). Each points at a committed sys_file, both in the mailbox's organization, and the stored bytes equal the file's and the forwarded message's. A message without parts has no attachment.
A re-delivered copy answers duplicate, and the sys_attachment and sys_file counts are unchanged. The same parts under another Message-ID are another message's: +2.
A mailbox in ORG_B stores both rows in ORG_B. The first pin: ORG_A.
A 1,304-byte part is refused PAYLOAD_TOO_LARGE (413) and leaves no sys_file, committed or pending. The message and its other two parts are stored, and one error names big.bin. The first pin: parts within the limit are stored.
On a kernel without storage, two messages are stored without parts, and one warn is said. The first pin: with storage, no such line.

src/ingest/mail-ingest-service.test.ts (real engine, a recording double for the verb) pins what the seam hands the verb and when:

  • the parent row exists, in the mailbox's organization, at each call;
  • the calls follow message order, carrying the bytes and a Buffer;
  • organizationId is never null;
  • the dedupe, re-match and suppressed paths make no call;
  • the naming and type rules;
  • a FILES_DISABLED (403) refusal and an envelope-less throw are each said at error, and the other parts are still sent;
  • the no-verb path, for a resolver answering undefined and for no resolver at all, says one warn.

src/ingest/parse-message.test.ts pins the H2 readings on the parser: every part listed in order with its bytes, a forwarded part byte-identical, parts past MAX_FORWARDED_PARTS still listed (their addresses are not read), and as controls, a plain message lists none and a second text part joins the body.

Ablation (one, on the attachFile call)

Run at 3a2884edb with node scripts/ablation-replace.mjs. The anchor await verb.attachFile({ was replaced by void ({, so the input is built and never sent. The tool's own log: anchor 1 → 0, replacement 0 → 1, blob f8fc21733a6c → b86bc69395ad. The subject resolves to src/ through relative imports, so no dist/ is involved.

  • Result: Tests 12 failed | 33 passed (45) over the two attachment suites.
    • Failed: every unit pin that expects a call (8), and the 4 real-verb pins (acceptance, re-delivery, ORG_B, 413).
    • Stayed green: the controls (no parts gives no call or no attachment, no refusal says nothing), the no-verb warn pins on both suites, and arms.
  • Restore: blob equals HEAD (f8fc21733a6c), and git diff HEAD is empty. The tool proved both.

Tests and gates

Local, at 2f293b262, after merging origin/main (cb9841aa2) and rebuilding the closure:

  • turbo run build --filter='@objectstack/service-mailbox...': 19/19 tasks.
  • pnpm --filter @objectstack/service-mailbox typecheck: exit 0. The package's tsconfig.json includes src with no test exclusion, so the new tests are type-checked.
  • vitest run: 10 files, 152 tests passed.
  • Reading, @objectstack/service-storage src/file-attachment-service.test.ts: 17 passed. Unchanged by this PR.
  • Gate families derived by node scripts/pm/dispatch-gates.mjs --commands: 79 derived, 77 exit 0. Two are NOT MEASURED locally, because they exited 3 with PREREQUISITE NOT MET (they read built output of the whole workspace): check:dual-build-cjs-loads and check:i18n. Lint & Repo Gates runs both in CI. --ran reconciliation: 79 accounted, 77 run, 2 NOT-MEASURED (derived from the recorded exit 3), 0 unrun. Also run, though not derived: check:startup-registry-verdict exit 0, check:durability-log-level exit 0.

The families CI runs are read from this PR's check-runs, not re-run locally.

Surface beyond the claim's file list (declared)

  • packages/services/service-mailbox/package.json and pnpm-lock.yaml (+3 lines, importer entry only): the @objectstack/service-storage devDependency for the real-verb pins, as the dispatch's suggested route asked.
  • packages/services/service-mailbox/vitest.config.ts: source aliases for @objectstack/service-storage and for what it reads (@objectstack/observability, @objectstack/platform-objects/audit and /system, @objectstack/spec/api, /contracts, /kernel and /system). With them, a verdict here is about the source, not a stale dist/ (check:test-source-alias).
  • src/sync/mailbox-sync-service.test.ts: one line, so the logger it hands MailIngestService has error.

For the seat

Acceptance notes

  • An inline image is stored by its file name. Its Content-ID is not recorded, because sys_attachment has no column for it and a metadata-only column is ruled out here. A renderer cannot yet resolve a cid: reference in body_html to its stored file. That is a later capability, not a defect of this change. Carrier: none.
  • A forwarded part's own attachments are not stored as separate files. They travel inside its .eml, as the seam reads the forward the user made and not the forwards inside it.

Generated by Claude Code

…warded parts through file-attachments

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
…hangeset for mail attachments

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-mailbox, touching 27 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/services/service-mailbox/README.md, packages/services/service-mailbox/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/plugin-endpoints.mdx (via mimeType (symbol, a field of interface MailAttachmentPart))
  • content/docs/automation/email-templates.mdx (via II18nService (symbol, a top-level type))
  • content/docs/automation/jobs.mdx (via IJobService (symbol, a top-level type))
  • content/docs/kernel/contracts/index.mdx (via IJobService (symbol, a top-level type))
  • content/docs/kernel/contracts/storage-service.mdx (via contentType (symbol, a field of interface FixturePart))
  • content/docs/kernel/services-checklist.mdx (via II18nService (symbol, a top-level type))
  • content/docs/permissions/attachments-access.mdx (via mimeType (symbol, a field of interface MailAttachmentPart))
  • content/docs/protocol/kernel/i18n-standard.mdx (via II18nService (symbol, a top-level type))
  • content/docs/protocol/kernel/index.mdx (via II18nService (symbol, a top-level type))
  • content/docs/protocol/objectql/types.mdx (via mimeType (symbol, a field of interface MailAttachmentPart))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via II18nService (symbol, a top-level type))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/services/service-mailbox/README.md, packages/services/service-mailbox/vitest.config.ts) — pages documenting those are invisible to this run
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d54e2393bf5344bbb88ced1580d59542de583f39 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e9495d1456e990478879f179bb4240f48b868f9e — the merge of head 2f293b262815b59c8ecca0e41a70e21c7a26010d into base d54e2393bf5344bbb88ced1580d59542de583f39, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e9495d1456e990478879f179bb4240f48b868f9e && git checkout e9495d1456e990478879f179bb4240f48b868f9e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d54e2393bf5344bbb88ced1580d59542de583f39 2f293b262815b59c8ecca0e41a70e21c7a26010d && git checkout -B drift-repro d54e2393bf5344bbb88ced1580d59542de583f39 && git merge --no-ff 2f293b262815b59c8ecca0e41a70e21c7a26010d

node scripts/docs-audit/affected-docs.mjs --json d54e2393bf5344bbb88ced1580d59542de583f39

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d54e2393bf5344bbb88ced1580d59542de583f39 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy · head 2f293b262 · 2026-10-14T12:48Z

No contract face, so no review is owed beyond this one: no published schema, no governed text, and Clause-②: no. CI plus this seat's own read clear it. Card #23307; claim 6167149551; dev report on #23307.

1. Shape

2. Premises measured

  • H1: the plugin hands the seam a per-call resolver (ctx.getService(FILE_ATTACHMENT_SERVICE)). No new runtime dependency is added; the contract is @objectstack/spec/contracts.
  • H2: on postal-mime 4.0.5, every non-body leaf part arrives with its bytes. message/rfc822 arrives byte-identical. Inline parts count: Apple Mail marks attached files inline, and cid images are bytes that body_html references.
  • H3: a refusal is caught per part and the message survives. Pinned on the real verb: a PAYLOAD_TOO_LARGE 413 part leaves no sys_file, and the other parts are stored.
  • H4: organizationId is the mailbox's own, pinned both ways (ORG_A, ORG_B).

3. Gates

  • Check-runs on 2f293b262 at this act: 23 success, 5 skipped, 10 in progress, 0 failures. The flip waits for green.
  • Local: --ran accounts for 79 families. 77 ran; 2 are NOT MEASURED (exit 3: they need whole-workspace dist/, which CI's Lint & Repo Gates has).
  • Package suite: 10 files, 152 tests. That includes 7 real-kernel pins (ObjectQL, StorageServicePlugin local, MailboxServicePlugin).
  • Ablation on the attachFile call: 12 red, controls green, restore verified.

4. Open question, answered A: keep Clause-②: no.

  • The exported MailIngestLogger gains a required error. The type first shipped in 18.0.0-next.1, a prerelease of this major, and no stable release carries it, so nothing narrows against the last stable line.
  • The plugin passes ctx.logger, which has error. MailSyncLogger already requires it. The changeset names the change for a host that constructs the seam itself.
  • C (an optional error falling back to warn) would report a lost attachment at warn, which the degradation rule refuses.

5. Deviations accepted

  • The surface widens to:
    • package.json: a devDependency on @objectstack/service-storage, for the real-verb pins;
    • pnpm-lock.yaml: +3 lines, the importer's link: entry only. A re-resolving install churned unrelated esbuild entries, and pnpm install --frozen-lockfile accepts this entry; Validate Package Dependencies judges it in CI;
    • vitest.config.ts: source aliases, per check:test-source-alias;
    • a one-line logger change in mailbox-sync-service.test.ts.
  • Forwarded parts keep their own media type and their own file name when they have one; SUBJECT.eml and forwarded.eml are fallbacks. The attach calls sit after the row insert and before the association links.
  • main was merged once, before the PR existed.
  • Commit trailers are model-free, per AGENTS.md.

6. Out of scope, and landing

  • One Acceptance note (承接者:无): an inline image is stored without its Content-ID, so a cid: reference in body_html cannot yet resolve to its stored file. That is a later capability, not a defect class.
  • Landing: Tier S through the ordinary queue, once the head is all green.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 13:17
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 13:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 05e3b8e Oct 14, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23307-mail-attachments branch October 14, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants