Repository navigation
feat(connector-imap, service-mailbox): the IMAP mail source — a read-only IMAP connector and the mailbox sync that feeds the ingest seam - #23303
Conversation
…atcher reads it
The row was planned + authorWarn until the mailbox service's matcher existed.
The matcher (service-mailbox src/ingest/match-records.ts, MailRecordMatcher)
reads every email field declaring mailAssociation: true at ingest and at
re-match, so the author hint ("no mailbox service reads this yet") is false.
Flip to live, cite the matcher, drop authorWarn and authorHint, follow the
authorWarnedProperties('field') pin and regenerate the field state counts
(94 live / 4 planned -> 95 / 3).
Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ
Co-authored-by: Claude <noreply@anthropic.com>
…shaped account and credential abstraction (WIP) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…sor on sys_mailbox, message/global forwards (WIP) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…ields, changeset; connector-imap README (WIP) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…IMAP sync and the new package Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…-connector-imap # Conflicts: # content/docs/permissions/tenant-audit-census.mdx # docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…P sync's cursor write) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 38 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 142 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 23e3692414c9c1063cf6584435803aa9da3732a1 && git checkout 23e3692414c9c1063cf6584435803aa9da3732a1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 430e5f5ad128792a5f0e4acfbbd14ab79cd317e8 671693ad630aadf748ee8645a596b59aaa812afa && git checkout -B drift-repro 430e5f5ad128792a5f0e4acfbbd14ab79cd317e8 && git merge --no-ff 671693ad630aadf748ee8645a596b59aaa812afa
node scripts/docs-audit/affected-docs.mjs --json 430e5f5ad128792a5f0e4acfbbd14ab79cd317e8
|
Contract reviewServed-tier: Read: card #22871 (body, all 13 comments), ruling ① Derived judgments(a) Scope against the ruled direction — holds.
(b) Security — the stated guarantees hold; three hardening gaps are named in ③.
(c) The sync state — correct on every axis asked.
(d) The four
(e) The
(f) Semver and changesets — see ②. (g) The acceptance tests — each clause pinned against the double, with its control; the double is a fair stand-in.
(h) The surface beyond the claim — each forced by a named gate, and correct.
(i) LICENSE: byte-identical to (j) CI on the head, read three times during this review: 42 check-runs; 36 success, 5 skipped, 1 still in progress at the last read ( ② Semver level
③ Boundary flagsThe dev's declared deviations, each read:
The open question, as ruled B:
Findings of this review, none failing the contract:
Out-of-scope findings carried from the report, each with its carrier as the seat stated:
Implemented-by: VERDICT: PASS |
…r can make the session hold, and skip a lying server's oversized message - the session refuses, as `oversized`, a response of more than 32 literals and more than maxCommandBytes of responses to one command (each response charged 256 bytes beyond its own), beside the line and literal bounds; - the sync opens the session with maxMessageBytes plus a 64 KiB framing envelope as its literal bound; a server that omits or understates RFC822.SIZE gets the batch re-read one message at a time on a fresh session, and the offending message is skipped as too_large instead of failing every run; - a fetch batch is bounded by listed bytes (maxBatchBytes) as well as by count; - the double's and the README's \\Seen sentence says what the code does; - the changeset leaves the spec entry to the liveness-flip PR. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…-connector-imap # Conflicts: # content/docs/permissions/tenant-audit-census.mdx # docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…es with the IMAP sync's cursor write) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta review. Baseline: the at-tier PASS ① Derived judgments(1) Bounded accumulation — every path is bounded and every refusal closes the socket; one bound is loose, one ceiling is new.
(2)
(3) The batch byte budget — right. (4) The (5) The changeset and the split-off flip — the release is right in either order; one sentence of the flip differs; the spec line's obligation rides on #23314.
(6) The published-API change inside the PR — sound. (7) The merge and the census regeneration — clean, exact, nothing stale. (8) Nothing the prior PASS relied on is undone. The hardening diff on (9) CI on the head: no non-green check. 43 check-runs, 38 ② Semver level
③ Boundary flagsThe dev's deviations, each read:
Findings of this review:
Out-of-scope, carried as the seat stated: Implemented-by: VERDICT: FAIL |
… in its batch too All three too_large paths (listed above the bound, refused by the session when fetched alone, above the bound once read) skipped past the cursor advance, so a skipped message that was the last judged in its batch was listed, skipped and logged again on every poll until a later UID arrived. skip() now advances the cursor itself. One pin per path places the offender last: the first run skips it, the second fetches nothing, skips nothing, and imap_last_uid is past it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ
…nd bound at each literal announcement The per-command byte bound was applied only when a response completed, so one response could hold up to 32 literals of maxLiteralBytes each before it fired. The reader now asks the session at each literal's announcement, before any octet is read, and the session refuses `oversized` when the command's kept bytes plus the running response would pass maxCommandBytes. The double gains a `withheld-literal` hostile mode (the second literal is announced, never sent); the pin asserts the refusal kind, its message and the closed connection. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ
…uncement charge, and the listing ceiling Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ
#23314 landed the mailAssociation liveness flip as 777eed2 with the trimmed note; the one conflicting line, that row's note in packages/spec/liveness/field.json, is resolved to main's side, so this branch's flip commit is a no-op against main. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ
Contract reviewServed-tier: Delta review, round 3. Baseline: the at-tier PASS ① Derived judgments(1) The failing defect — fixed on every skip path, pinned offender-last on every
(2) The per-announcement charge — one hostile response is bounded by
(3) Changeset and READMEs — every changed sentence true; one formula is a simplification in the safe direction.
(4) The merge
(5) Nothing the two prior records relied on is undone. The fix round touches nine files, and none of (6) CI on the head — no non-green check. 43 check-runs, all on ② Semver level
③ Boundary flagsThe dev's declared deviations, each read:
Findings of this review, none failing:
Carried as the seat and the prior records stated: Landing shape, unchanged: 47 files, +4105 / −63 = 4168 changed lines against Implemented-by: VERDICT: PASS |
维护者速读 · PR #23303(
|
Fixes #22871
Clause-②: yes (a new published package,
@objectstack/connector-imap, and its exports; themailAssociationliveness row goeslive)Stage 2 of #22871:
connector-imapfeeds the stage-1 ingest seamStage 1 (PR #23250,
82581819b) shipped the ingest seam:MailIngestService.ingestturns one raw RFC 822 message into onesys_mail_messagerow, with dedupe, threading, association, the pool and suppression. Nothing fed it. This PR adds the mail source: a new package,@objectstack/connector-imap, and an IMAP sync in@objectstack/service-mailboxthat polls each mailbox's folder above a stored UID cursor and hands every message to the seam. Stage 1's dedupe, threading, matching, re-match and suppression are reused unchanged (ruling 6116489638: Q1 A, Q3 C phase 1, Q4 A and Q5 A are not touched).Attachment extraction, the card's last scope item, moves to #23307 (
Blocked-by: #23306, the server-sidesys_attachmentwriter), under the seat's ruling B (6151023438).@objectstack/connector-imap(new): protocol and auth onlyopenImapSession({ account, credentials, timeoutMs?, maxLiteralBytes? })opens a read-only IMAP4rev1 session:examine(folder)answersuidValidity,uidNextandexists. It usesEXAMINEand neverSELECT. A folder opened without a UIDVALIDITY is refused.listMessages(fromUid)andfetchMessages(uids)fetch withBODY.PEEK[], so no message is ever marked\Seen.logout()andclose()end the session.MailAccountis{ id, username, imap: { host, port?, security? } }, one identity per account, never a process-wide setting.MailCredentialProvider.credentialFor(account)is resolved each time a session opens and never cached. A per-user provider can therefore read a user's stored credential and refresh an OAuth token without changing the connector.imap, so the same account can later name a calendar endpoint. The design record shares one account between the mail and calendar engines.staticCredentials(credential)is the shared mailbox's implementor: a fixed credential the host hands over.passwordusesAUTHENTICATE PLAIN(withSASL-IRwhen offered), otherwiseLOGIN, with a synchronizing literal when quoting cannot carry the password;oauth2token usesAUTHENTICATE XOAUTH2, including its error-continuation flow;LOGINDISABLEDand offers noPLAINis refused before anything is sent.security: 'none'is refused to any host but loopback, before a socket opens.LICENSE,tsconfig.json,vitest.config.tsand co-located tests. It has no runtime dependencies. It registers noconnector_actionhandler and ships no plugin, because nothing in a flow calls IMAP; the mailbox service does.@objectstack/connector-imap/testingexportsImapServerDouble, the in-process server the tests run against. It is a separate entry with its owntsup.config.ts.@objectstack/service-mailbox: the sync halfnew MailboxServicePlugin({ imap: { accountFor, pollIntervalMs?, batchSize?, maxMessagesPerRun?, maxMessageBytes? } })schedules the interval jobmailbox:imap-syncon the composed job service atkernel:ready.destroy()cancels the job and closes any open session.imapbut no job service, the boot warns.plugin.syncService.syncAll({ isSystem: true })is the manual path.{ outcome: 'degraded' }for the job service (theJobRunOutcomecontract).MailboxSyncService, per mailbox:accountFor(mailbox)for the account and credential (nullmeans not synced);batchSizeat a time and hands each toingestunder the system context, in UID order.sys_mailboxin four new readonly fields, written by the sync only:imap_folder,imap_uid_validity,imap_last_uidandimap_synced_at. That is a widening of a published object, declared in the changeset.duplicate, so nothing doubles.maxMessageBytes(32 MiB by default), or one the seam refuses as unreadable, is skipped so it cannot stall the mailbox. It is logged aterroronce, naming its UID, with the consequence and the fix.accountFor, the way every open connector takes its credential (measured below).system-context.mdxgains row 47c for the sync's two system-only entry points.Stage 1's four carried items (
6145459294)mailAssociation→live(packages/spec/liveness/field.json).authorWarnandauthorHintare dropped. TheauthorWarnedProperties('field')pin becomes['conditionalRequired'], and thefieldstate counts go from 94 live / 4 planned to 95 / 3. The evidence anchor ismatch-records.ts#MailRecordMatcherrather than the dispatch's#MailRecordMatcher.match, becausecheck:livenessrefuses a dotted anchor ("an anchor must be one identifier"). The prose namesmatch, throughflaggedFieldsOf. This flip is its own first commit (610e112bd). Under the seat answer6151023438it lands first on its own PR, fix(spec): the mailAssociation liveness row goes live — the mailbox matcher reads it, so the stale author warning is dropped #23314. This PR keeps the commit, which becomes a no-op once fix(spec): the mailAssociation liveness row goes live — the mailbox matcher reads it, so the stale author warning is dropped #23314 is onmainandmainis merged here..emlattachment, through the server-side writer: the attachment item of #22871, chained #23307 under ruling B (6151023438). See Measurements, H5.message/global(RFC 6532): it is now read as anattachmentsource, likemessage/rfc822, including a base64-encoded part. It is pinned inparse-message.test.ts.MAX_FORWARDED_PARTS = 20is named in the service README, with what happens past it.Measurements (Zone 2), at the merge base
175e8d414imapflow@2.4.0(MIT, ESM and CJS, 1.75 MB unpacked) pins 8 exact-version dependencies:pinowith its worker-thread transport,socks,iconv-lite,libmime,libqp,libbase64,encoding-japaneseand@zone-eu/mailsplit. That is a lot for the five commands a read-only mirror sends.imap@0.8.19(node-imap) is unmaintained.CAPABILITY,LOGIN/AUTHENTICATE,EXAMINE,UID FETCHandLOGOUT, plus literal framing. That is about 1,000 lines with the wire reader and the account types, and MIME stays with stage 1'spostal-mime.hoodiecrow-imapwas dormant from 2.1.0 (2016) until 3.x on 2026-10-06/07, and was deprecated hours later as "Renamed to imapkit".imapkitwas created 2026-10-07 and had 11 releases in two days; 5.0.1 is five days old.wildduckneeds MongoDB and Redis.ImapServerDoubleshares no parser or constant with the client.imapkit@5.0.1andhoodiecrow-imap@3.3.1. Both are strict RFC 3501 servers by the postal-mime maintainers. 18 checks, all PASS on both:LOGIN,AUTHENTICATE PLAINwith and withoutSASL-IR, a UTF-8 password, and a password carrying a double quote and a backslash;XOAUTH2success and its error-continuation refusal, a wrong password asauthentication, andLOGINDISABLEDrefused before sending;EXAMINEwith UIDVALIDITY and UIDNEXT,UID FETCHsizes, the last-UID edge case of a range ending in the star, byte-exact 7-bit and 8-bit bodies, andINTERNALDATEwith an offset;LOGOUT, and no\Seenleft after a fetch. That last check is read through imapkit's control API; hoodiecrow has none, so it is NOT MEASURED there.connector-slacktoken,connector-restauthentication,connector-mcpenv/headers, and the showcase'sprocess.env.SLACK_BOT_TOKEN. "OAuth2 install/refresh, credential vaulting, and multi-tenant connection lifecycle are the enterprise tier" is the slack connector's own header.accountForplus a credential provider). No secret store is added, and nosecretfield either. The engine's encryptedsecretchannel (resolveSecretField) is the natural store for the per-user phase.sys_mailboxheld no cursor. Its own header said the account, credential and cursor "are added by the ingest half". Four fields are added (the widening above). The account and credential are not stored, by H2.IJobService.schedule(name, { type: 'interval' }, handler)resolved atkernel:ready, pluscancelindestroy(). That is plugin-approvals' SLA clock pattern. No timer of its own;check:plugin-teardown-shapeis green.sys_attachment(platform-objects) has 0 non-testinsertcall sites in the tree.sys_fileis written only by service-storage'sStorageMetadataStore.createFile, reached from the upload routes.storageservice (IStorageService) is a blob store with no file-row or attachment-link verb.sys_filerow, and thesys_attachmentlink, with quota, organization and parent-access semantics. The dispatch forbids building that here. The seat ruled B (6151023438): the writer is service-storage: a server-side verb that stores bytes and attaches them to a record (sys_file+sys_attachment), under the parent's organization, quota and access hooks: the prerequisite for inbound-mail attachments #23306, and the mail half is service-mailbox: store an ingested message's attachments, and each forwarded message part as an.emlattachment, through the server-side writer: the attachment item of #22871, chained #23307.Hardening round (seat note
6151344448), commit4488d1171ImapErrorkindoversized, and closes the connection when:IMAP_MAX_RESPONSE_LITERALS(32) literals;maxCommandBytes(default 128 MiB; each response is charged 256 bytes beyond its own size).The existing line and literal refusals moved from
protocoltooversized. The package is unreleased, and the changeset lists the kinds.maxLiteralBytesfollowsmaxMessageBytes. The sync opens the session withmaxLiteralBytes = maxMessageBytes + SYNC_LITERAL_ENVELOPE_BYTES(64 KiB), andmaxCommandBytes = max(128 MiB, batch budget + maxMessageBytes + envelope).oversizedrefusal during a batch fetch reopens the session (checking UIDVALIDITY), re-reads the batch one UID at a time, and skips the offender astoo_large.maxBatchBytes(default 64 MiB, never below one message's bound) as well as by count. A message the listing says is oversized costs 0, because it is never fetched; an unsized one costs the whole bound.\Seensentence intesting.ts's header and in the connector README now says what the code does: onlyBODY[]underSELECTmarks a message seen, andEXAMINEnever changes state.'@objectstack/spec': patchand the spec bullet, because fix(spec): the mailAssociation liveness row goes live — the mailbox matcher reads it, so the stale author warning is dropped #23314 carries them. It also drops the liveness clause from its ownClause-②: yesline. Thellms.txtpackage count (70 to 71) rides the fixed-group release.mainwas merged in viaos-regen-merge.sh(c33c6dc67), because GitHub reported a census conflict.main's side was taken for the two census files, which were then regenerated.Fix round after the delta review FAIL (
6154709411), commitsa11f7c57e,2725aacca,5e075ec2fskip()now advancesresult.lastUiditself, so all threetoo_largepaths pass the cursor, including a message that is the last of its batch and run. Four offender-last pins cover: listed above the bound; refused alone with the size understated; refused alone with the size omitted; above the bound once read. The second run fetches nothing and logs nothing new.{n}announcement, before any octet is read, the reader charges the running response againstmaxCommandBytes. One hostile response now holds at mostmaxCommandBytesplus one line. A pin uses the double's newwithheld-literalmode.openImapSessionline gainsmaxCommandBytes?.maxBatchBytes; paging is the per-user phase's.Carried, not fixed: a run's time is unbounded under a server that trickles a literal. Memory stays bounded. Carrier: the per-user mailbox phase of #22871.
mainwas merged in again (671693ad6) after PR #23314 landed as777eed2bc. The one conflict, themailAssociationrow'snote, resolved tomain's side. This PR's liveness and lint-test files now equalmain's, so610e112bdis a no-op.Size and landing
47 files, +4105 / -63 = 4168 changed lines against the merge base (
LICENSEis 202 of them). That is over the 3,000-line threshold, so under the seat answer6151023438this PR takes the Tier H route: the seat's ACCEPT and an at-tier contract review, then it waits for a human merge. The seat does not arm auto-merge. The diff touches no governed surface.Verification at
671693ad6(merged withorigin/mainat430e5f5ad)The figures at
671693ad6come first, then those at44aabc877, then the reading at27aa15166, on which the first contract review PASSed (6151327338).671693ad6(underos-verify-lock):connector-imap42 passed;service-mailbox101 passed; both typechecks exit 0;speccheck:livenessexit 0, and the lint liveness pin 100 passed;skip(): 4 red) and F2 (no per-announcement charge: 1 red), each restored to blob == HEAD;imapkit@5.0.1andhoodiecrow-imap@3.3.1, 18 of 18 each;dispatch-gates --ran: 130 derived, 130 run, all exit 0.44aabc877(underos-verify-lock):speccheck:generated, all 14 current;@objectstack/connector-imap: 2 files, 41 passed;@objectstack/service-mailbox: 8 files, 97 passed; both typechecks exit 0;maxLiteralBytesnot passed, no one-at-a-time re-read, no byte budget, no post-fetch length check), each red as predicted and restored to blob == HEAD;imapkit@5.0.1andhoodiecrow-imap@3.3.1, 18 of 18 each;dispatch-gates --ran: 132 derived, 132 run; 131 exit 0, andcheck:platform-checklistexits 1 withmain's 8 findings;spectest:repowas not re-run, because the hardening touches no spec file.27aa15166:@objectstack/connector-imap: 2 files, 37 tests passed.@objectstack/service-mailbox: 8 files, 91 tests passed (stage 1 had 64). These run on a real ObjectQL over driver-sql and better-sqlite3:memory:, plus a bootedObjectKernel.@objectstack/lintlint-liveness-properties.test.ts: 100 passed.pnpm --filter @objectstack/spec test:repo: 58 files, 1007 passed.tsc --listFilescounts 2 of 2 and 8 of 8 test files.scripts/ablation-replace.mjs. Each anchor hit once, each blob change was proven on disk, each file was restored to blob == HEAD withgit diff HEADempty, and the tests ran on mutated source (vitest aliases every workspace import tosrc). All 11 went red as predicted:BODY[]instead ofBODY.PEEK[](1), andSELECTinstead ofEXAMINE(1);message/globalnot read — 2;node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 132 families on this head. All 132 were run, one log each, and--ranreconciles: "132 derived, 132 run, 0 NOT-MEASURED, 0 UNRUN".check:dual-build-cjs-loads,check:published-files,check:i18n,check:system-context-census,check:tenant-audit-census,check:lockstep-package-count,check:changeset-no-majorandcheck:generatedafter a real spec build.pnpm check:platform-checklistexits 1 with 8 findings, the same 8 as on the merge base (Acceptance notes).pnpm check:i18n-coverageexits 0, with none new.origin/main9c7cfbf0a. Those commits touch none of this diff's files. One of them changesscripts/engine-double-contract.pinned.json, which the derivation reads; its gate ran green on this tree's copy.eslint --no-inline-config --format jsonover the 33 changed lintable files gives 33 files, 0 errors, 0 warnings, 0 fatal.eslint.config.mjsfiles**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}; the rest of the diff is md, mdx and json. The count is read from the JSON.parserOptions.project), so no untouched file's verdict can move.turbo ls --affectedis not used for any narrowing claim here.Acceptance notes
check:platform-checklistis red on the merge base itself (175e8d414, run in a throwaway worktree): the same 8 ABSENT SYMBOL / SYMBOL ANCHORS LOST findings indocs/qa/platform-checklist/areas/{access-security,attachments-storage,search}.json. This diff touches none of its inputs. Not fixed here..changeset/22871-mail-ingest-seam.md) still says no mail source feeds the seam. It is not edited; this PR's changeset says it supersedes that line.sys_mailboxrow itself (organization, address, owner) still has no administration door. Server code writes it under the system context, as after stage 1. The README says so.pnpm-lock.yaml:pnpm installadded the two workspace importer entries and also flipped one unrelatedvitestpeer variant (esbuild 0.28.2 ↔ 0.28.1), the normalization stage 1 met in the other direction. The lockfile delta was rebuilt from the base lockfile with only the two importer entries (15 lines), andpnpm install --frozen-lockfilevalidates it.维护者速读(草稿)
改了什么
@objectstack/connector-imap。它只做协议和认证:以只读方式连接一个 IMAP 邮箱,读出邮件原文。用 EXAMINE 打开文件夹,用 BODY.PEEK 读取,所以不会把邮件标成已读,也不改动邮箱。@objectstack/service-mailbox新增邮箱同步。imap.accountFor),插件把同步注册成定时任务,定期把新邮件交给阶段 1 已上线的接收入口。sys_mailbox新增 4 个只读字段,记录同步进度:文件夹、UIDVALIDITY、最后同步的 UID、最后同步时间。凭据不存数据库。message/global(国际化邮件头)时也能识别发件人;mailAssociation的 liveness 改为 live 这一项,已拆到单独的小 PR fix(spec): the mailAssociation liveness row goes live — the mailbox matcher reads it, so the stale author warning is dropped #23314 先行落地。为什么改
plugin-emailis outbound-only, so email-to-record is impossible in any app #8998(维护者已「同意」,5311578770)的第一阶段。.emlattachment, through the server-side writer: the attachment item of #22871, chained #23307,该卡等服务端附件写入能力 service-storage: a server-side verb that stores bytes and attaches them to a record (sys_file+sys_attachment), under the parent's organization, quota and access hooks: the prerequisite for inbound-mail attachments #23306 落地后再做。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 在本 PR 落地时关闭。风险与代价(含回滚)
imap.accountFor的部署才会连接外部邮箱。仅通过requires: ['mailbox']自动装配的部署不同步任何邮箱,行为与今天一致,启动时会打印一行说明。席位意见
(终稿与席位意见见 PR 评论
6157331354。)你要做的
Generated by Claude Code