Repository navigation
feat(service-mailbox): store an ingested message's attachments, and each forwarded message as an .eml, through file-attachments - #23484
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
席位核对ACCEPT · No contract face, so no review is owed beyond this one: no published schema, no governed text, and 1. Shape
2. Premises measured
3. Gates
4. Open question, answered A: keep
5. Deviations accepted
6. Out of scope, and landing
Generated by Claude Code |
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'ssys_mail_messagerow. It goes through the server-side verbIFileAttachmentService.attachFile(the kernel servicefile-attachments,FILE_ATTACHMENT_SERVICE, landed in #23386). Each attached file is one part. Each forwardedmessage/rfc822ormessage/globalpart is stored as the.emlfile it travels as.parentObject: 'sys_mail_message',parentId= the new row's id).sys_mailbox.organization_id), nevernull. 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.attachFileper part, in message order. Then the association links andassociated, as before. A message therefore leaves the restricted pool with its attachments already in place.attachFile. A re-delivery adds nothing, andrematchnever reads a part.The landing package is the producer, as the claim's file surface says.
@objectstack/service-storageandpackages/spec/**are not touched.H1: how the writer is resolved (measured)
MailIngestServicea 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 optionalattachments: () => IFileAttachmentService | undefined, andMailboxServicePluginsuppliesctx.getService(FILE_ATTACHMENT_SERVICE), answeringundefinedwhen it is not registered. The kernel'sgetServicethrows 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.FILE_ATTACHMENT_SERVICEandIFileAttachmentServicecome from@objectstack/spec/contracts, which the package already depends on.@objectstack/service-storageis added as a devDependency only, for the real-verb pins (see "Surface beyond the claim's file list").file-attachmentsservice is composed: the message is stored without its parts, and awarnsays 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 anif (!service)composition branch atwarn.storageis onPLATFORM_ALWAYS_ON_CAPABILITIES, so this branch is reached only on a kernel whose slate leaves it out.kernel:bootstrapped, the verb answers503 SERVICE_UNAVAILABLE. That is handled as a refusal (next section), not as absence. The IMAP sync's interval job first fires one interval afterkernel: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.parsewith the package's own options (forceRfc822Attachments: true,attachmentEncoding: 'arraybuffer') on one message carrying each kind of part:email.attachmentswithfilename(ornull),mimeType,disposition(attachment,inlineornull), andcontentas anArrayBuffer.message/rfc822part arrives as an attachment whose bytes are byte-identical to the forwarded message. Amessage/globalpart 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, becauseforceRfc822Attachmentskeeps the part whole. They travel inside its.eml.Content-Disposition: inlinewith aContent-ID, insidemultipart/related) arrives as an attachment withdisposition: 'inline'andrelated: true.Content-Disposition: inlinearrives withdisposition: 'inline'and norelated.text/plainpart 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 thecid:images the storedbody_htmlreferences, which are bytes the message carries and which nothing else keeps. The line is postal-mime's own split between bodies and attachments.Naming:
attachment-N, where N is its position among the message's parts.forwarded. The name always ends in.eml._. A name oversys_attachment.file_name's 255 is cut in its stem, keeping the extension.message/globalpart staysmessage/global, notmessage/rfc822. A type overmime_type's 128 is stored asapplication/octet-stream.H3: order and failure (pinned)
error, once, naming the part (number, file name, type, size), the message and the mailbox, and quoting the refusal'sCODE (status): sentence. The verb's sentences name their own remedy, for example themax_upload_mbsetting for413.errorand notwarn: 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 aterrorfor the same reason.MailIngestLoggerto gainerror. See "For the seat".attachFile. This is pinned on the double (call count) and on the real verb (sys_attachmentandsys_filecounts unchanged).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 recordsorganizationIdper call.Pins and their controls
src/mailbox-attachments-kernel.test.tsboots a realObjectKernelcomposingObjectQLPlugin,StorageServicePlugin(local adapter in a temp directory, removed after the run, with a settings service carryingmax_upload_mb: 0.001= 1048 bytes) andMailboxServicePlugin, over sqlite:memory:. The seam is reached as themailboxservice and finds the verb on its own.sys_attachmentrows on it (quote.pdf,question.eml). Each points at a committedsys_file, both in the mailbox's organization, and the stored bytes equal the file's and the forwarded message's.duplicate, and thesys_attachmentandsys_filecounts are unchanged.PAYLOAD_TOO_LARGE (413)and leaves nosys_file, committed or pending. The message and its other two parts are stored, and oneerrornamesbig.bin.warnis said.src/ingest/mail-ingest-service.test.ts(real engine, a recording double for the verb) pins what the seam hands the verb and when:Buffer;organizationIdis nevernull;FILES_DISABLED (403)refusal and an envelope-less throw are each said aterror, and the other parts are still sent;undefinedand for no resolver at all, says onewarn.src/ingest/parse-message.test.tspins the H2 readings on the parser: every part listed in order with its bytes, a forwarded part byte-identical, parts pastMAX_FORWARDED_PARTSstill 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
attachFilecall)Run at
3a2884edbwithnode scripts/ablation-replace.mjs. The anchorawait verb.attachFile({was replaced byvoid ({, so the input is built and never sent. The tool's own log: anchor 1 → 0, replacement 0 → 1, blobf8fc21733a6c→b86bc69395ad. The subject resolves tosrc/through relative imports, so nodist/is involved.Tests 12 failed | 33 passed (45)over the two attachment suites.warnpins on both suites, andarms.f8fc21733a6c), andgit diff HEADis empty. The tool proved both.Tests and gates
Local, at
2f293b262, after mergingorigin/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'stsconfig.jsonincludessrcwith no test exclusion, so the new tests are type-checked.vitest run: 10 files, 152 tests passed.@objectstack/service-storagesrc/file-attachment-service.test.ts: 17 passed. Unchanged by this PR.node scripts/pm/dispatch-gates.mjs --commands: 79 derived, 77 exit 0. Two are NOT MEASURED locally, because they exited 3 withPREREQUISITE NOT MET(they read built output of the whole workspace):check:dual-build-cjs-loadsandcheck:i18n. Lint & Repo Gates runs both in CI.--ranreconciliation: 79 accounted, 77 run, 2 NOT-MEASURED (derived from the recorded exit 3), 0 unrun. Also run, though not derived:check:startup-registry-verdictexit 0,check:durability-log-levelexit 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.jsonandpnpm-lock.yaml(+3 lines, importer entry only): the@objectstack/service-storagedevDependency for the real-verb pins, as the dispatch's suggested route asked.packages/services/service-mailbox/vitest.config.ts: source aliases for@objectstack/service-storageand for what it reads (@objectstack/observability,@objectstack/platform-objects/auditand/system,@objectstack/spec/api,/contracts,/kerneland/system). With them, a verdict here is about the source, not a staledist/(check:test-source-alias).src/sync/mailbox-sync-service.test.ts: one line, so the logger it handsMailIngestServicehaserror.For the seat
MailIngestLogger(exported) gains a requirederror(message), andMailIngestService's options gain the optionalattachments. No export is added or removed.MailIngestServicefirst shipped in18.0.0-next.1, a prerelease, and no stable release carries it. The plugin passesctx.logger, which haserror. A host that constructs the seam itself with a warn-only logger now fails to type-check. Whether this moves the claim'sClause-②: nois the seat's call. The line above is copied from the claim unchanged.sys_mail_message, associated at ingest through the seam — phase 1 of #8998 inbound mail #22871: this is the attachment item of connector-imap + minimal service-mailbox: shared-mailbox (forward/BCC) ingest intosys_mail_message, associated at ingest through the seam — phase 1 of #8998 inbound mail #22871, which is in stateclosed(completed). Nothing else keeps it open, and this PR carries no keyword for it.Acceptance notes
Content-IDis not recorded, becausesys_attachmenthas no column for it and a metadata-only column is ruled out here. A renderer cannot yet resolve acid:reference inbody_htmlto its stored file. That is a later capability, not a defect of this change. Carrier: none..eml, as the seam reads the forward the user made and not the forwards inside it.Generated by Claude Code