Repository navigation
Commit 1e62f71
feat(service-mailbox): sys_mail_message, its record associations and the restricted unassociated pool — the inbound-mail capability (manage_mailboxes, requires: mailbox) (#22993)
Fixes #22870
Clause-②: yes (widening)
PR 2 of 2 for #22870 (PR 1, `FieldSchema.mailAssociation`, landed as
`e5687cb6b`). Built from the stage-0 report `6115941082` §3.1, §3.2,
§3.4, §3.5 and §4 row 2, under ruling `6116489638` (Q2 → C3, Q4 → A).
This PR completes the card's scope: the inbound-mail objects, their
registration, both read gates, the boot wiring, `manage_mailboxes` and
the `mailbox` capability token. The ingest half (`connector-imap`,
ingest, dedupe, auto-reply suppression, re-match, MIME parsing, the
source marker, `sys_mailbox`'s connector columns) stays with #22871.
## What changes
- **New package `@objectstack/service-mailbox`** (ADR-0029 D1/D2: a
capability owns its objects, registration and gates).
`MailboxServicePlugin` registers three `engine-owned` objects, data API
`get` and `list` only:
- `sys_mail_message`: the RFC 5322 mirror (stage-0 §3.1, 18 columns).
`body_html` is `textarea`, never `html`. `enable.files: true`, so
attachments are `sys_attachment` rows whose gate inherits this object's
gate. The unique key is `(mailbox_id, dedupe_key)` per organization.
- `sys_mail_association`: one row per message-to-record link (§3.2),
with the ActivityPointer pair `object_name` and `record_id`
(`referenceVia: 'object_name'`). The unique key `(mail_message_id,
object_name, record_id)` makes re-match idempotent.
- `sys_mailbox`: the identity half only, `name`, `address` and
`owner_user_id`, with `requiredPermissions: ['manage_mailboxes']`.
- **Two read gates** (`mail-read-visibility.ts`, §3.4), registered as
data middleware on `find`, `findOne`, `count` and `aggregate`, so a
list's `total` agrees with its rows:
- **Message gate.** A message is served to a caller who can read a
record that one of its associations names, to the owner of its mailbox,
or to a holder of `manage_mailboxes`. A pool message has no association,
so an ordinary member reads it as `404 RECORD_NOT_FOUND`.
- **Association gate.** A row is served to a caller who can read the
record it names, or to a holder of `manage_mailboxes`.
- Both use a bounded system pre-scan (2,000 rows) and fail closed.
System reads are not narrowed. If the engine has no middleware seam, the
plugin logs at `error` and names the consequence.
- **`manage_mailboxes`** (Q4 → A) is declared in `PLATFORM_CAPABILITIES`
with `scope: 'org'`. It is held by default through
`ADMIN_FULL_ACCESS_CAPABILITIES` and by `organization_admin`, and so by
its derived no-bypass variant. Its readers are both gates and
`sys_mailbox.requiredPermissions`.
- **`mailbox` capability token**: it is in `PLATFORM_CAPABILITY_TOKENS`
and `PLATFORM_CAPABILITY_PROVIDERS` (open edition), and it has a
`CAPABILITY_PROVIDERS` row in core. `@objectstack/cli` and
`@objectstack/verify` depend on the package, so `serve` and `bootStack`
mount it. The showcase now declares `requires: [..., 'mailbox']`. The
token is not on the always-on slate.
- **Hoisted resolver.** `resolveReadableParentIds` moves to
`@objectstack/core`
(`packages/core/src/security/readable-parent-ids.ts`). The plugin-audit
export and the service-storage private copy are deleted, and both
packages import it from core. The two bodies were byte-identical
(compared before the move), so no existing gate changes its answer.
- **Pins and generated files**:
- the `service-mailbox` group in `platform-object-names.ts`;
- three ADR-0131 inventory rows (fate `attribution`; the message is
anchored to its mailbox, the association to its message);
- `platform-object-tenancy-census.json`, regenerated: 87 registered, 52
in reach;
- i18n bundles for en, zh-CN, ja-JP and es-ES, translated by hand. The
coverage baseline is 0 untranslated;
- the plugin-security grant goldens (the platform and organization
admins now carry `manage_mailboxes`);
- the lockstep package count (71) and the `llms.txt` package count (70);
- a new row 47 on `content/docs/permissions/system-context.mdx`, for the
gates' `isSystem` bypass. Later rows are renumbered, the `rows 1–62` and
`row 60` references follow, and the census gate's ledger and self-test
track the shift.
## Verification record (head `204aa2e8d`)
- **Showcase boot through the data API**:
`packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts`,
9/9. This is the §3.4 probe table, and every refusal asserts both
`status` and `code`:
| Probe | Measured |
|---|---|
| reader reads a message associated with its own contact (positive
control) | `200` |
| **reader reads the unassociated pool message (the pin)** | **`404`,
`RECORD_NOT_FOUND`** |
| reader reads a message associated only with a contact it cannot read |
`404`, `RECORD_NOT_FOUND` |
| reader lists the mailbox | only the associated message; `total` is 1 |
| reader lists associations | only the one naming its own record |
| mailbox owner reads and lists | pool served; `total` is 3 |
| `manage_mailboxes` holder, and the seeded platform admin, read the
pool (exemption control) | `200` |
| member with no object grant reads the pool (the `403` control) |
`403`, `PERMISSION_DENIED` |
| the mailbox owner reads `sys_mailbox` (object grant held, capability
not) | `403`, `PERMISSION_DENIED`; the holder gets `200` |
- **Ablation** (scripts/ablation-replace.mjs, then a rebuild, then
scripts/ablation-dist-preflight.mjs):
- **Mutation.** `installMailReadVisibility` returned before registering
either middleware. The marker reached `dist/` in 2 built files. The
mutated tree's dts step failed on a narrowing the mutation itself
caused, but the JS was emitted, and the preflight proves the marker is
in it.
- **Result.** The pin went red with 4 failed and 5 passed: the pool read
`expected 200 to be 404`, the foreign read likewise, the list `total`
was 2 instead of 1, and the association list likewise.
- **Restore.** The blob equals HEAD, `git diff HEAD` is empty, `git
status --porcelain` is empty, the marker is absent from `dist/`, and the
suite is 9/9 again.
- **A void first attempt.** It carried its marker only in a comment,
which the build strips, so the mutated build left no marker in `dist/`.
Its reading was discarded and the ablation was redone with the marker in
a string literal.
- **Package suites**:
- `@objectstack/service-mailbox` 14/14: plugin registration, the
provider identity, the capability pins, object shape, and the write-door
check with its control;
- `@objectstack/core` 2381;
- `@objectstack/plugin-audit` 743;
- `@objectstack/service-storage` 846;
- `@objectstack/plugin-security` 4057 passed, 45 skipped;
- `@objectstack/verify` 250;
- `@objectstack/platform-objects` 1083;
- `@objectstack/example-showcase` 409;
- `@objectstack/cli` unit 4265, with the 2 identity-pin failures fixed
and re-run;
- spec `src/kernel`, `src/security`, `src/identity` and
`src/system/constants`, 1738;
- dogfood: authz-conformance, authz-probe-blind-spot,
platform-app-object-entry-views, the attachment, comment, activity and
ledger gate suites, the audit-capability suite,
security-catalog-showcase and the new suite, 244 passed, 1 skipped. The
attachment, comment, activity and ledger gate suites are the behavioural
proof that the resolver hoist changes nothing.
- **typecheck**: green for core, plugin-audit, service-storage,
plugin-security, verify, service-mailbox, dogfood, example-showcase,
spec and cli.
- **eslint** (`--no-inline-config`) on the 42 changed `.ts`/`.mjs`
files: 0 errors, 0 warnings. This is a targeted run, not a proof of
narrowing; the repo-wide `pnpm lint` is CI's.
- **Gates**: `node scripts/pm/dispatch-gates.mjs --commands` derived 155
commands on `204aa2e8d`, and all exit 0. The `--ran` reconciliation
reports 155 derived, 155 run and 0 NOT-MEASURED, a derived zero.
`check:generated`: all 14 artifacts up to date.
- **`check:platform-checklist`** (outside per-PR CI) exits 1 with 8
problems on the base `e23eccb16` and the same 8 on the head; the problem
sets are identical line for line. The hoist removed no symbol that the
checklist anchors.
- **Size**: 2891 changed lines (+2739 / -152, 58 files, generated files
included), under 3000.
## Acceptance notes
- **The resolver's shared home is `@objectstack/core`.** All three
consumers already depend on it, and it already hosts
`withoutOperationPrivateKeys`. A `ReadableParentIdsEngine` structural
type keeps core free of an engine dependency. ADR-0139 symbol-anchored
the old location, so its one pointer is re-anchored to
`packages/core/src/security/readable-parent-ids.ts#resolveReadableParentIds`,
the pointer alone with no prose change. This puts `docs/adr/**`, a Tier
H path, in the diff, so the PR lands only on a maintainer approval.
- **Files outside the claim's listed surface, each needed mechanically
by the listed work:**
- `packages/cli/package.json` and `packages/verify/package.json`: the
provider table's readers must declare the provider package;
- `.changeset/config.json`: the new package joins the fixed group;
- `packages/qa/dogfood/test/…`: the showcase boot test lives with the
other showcase boots, because a test inside the package would need
`@objectstack/verify` and the example as dev dependencies, a cycle;
- `packages/cli/test/serve-capability-identity.test.ts`: the identity
pin;
- `content/docs/permissions/system-context.mdx` and
`scripts/check-system-context-census.mjs`: required by the census gate;
- `packages/spec/llms.txt`,
`content/docs/protocol/backward-compatibility.mdx` and
`scripts/publish-smoke-pack.mjs`: package counts;
- `docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md`:
the anchor the hoist moved;
- `packages/services/service-mailbox/README.md`: the README its
`files[]` lists;
- `scripts/i18n-coverage-baseline.json`: the new package's coverage
entry.
- **Liveness.** Per stage-0 §3.5, object columns carry no ledger rows.
`manage_mailboxes` and `mailbox` land together with their readers in
this PR. The `packages/spec/liveness/README.md` field-cell sentence was
not ridden.
- **Writer window.** Nothing writes these rows until #22871 lands. Every
object is `engine-owned` with `get` and `list` only, so no author or API
caller can create rows that nothing reads.
- **Organization stamp.** The dogfood fixture inserts as the system with
the reader's organization, the way ADR-0131 D9 requires the ingest
service to stamp. The cross-organization negative test that §3.2 lists
belongs to #22871's matcher.
- **Labels.** No labels are written: this dispatch's write budget names
none.
## 维护者速读(草稿)
- **改了什么**:新增 `@objectstack/service-mailbox` 能力包。应用在 `requires` 里写上
`'mailbox'`
后,平台会注册三张入站邮件系统表:邮箱(`sys_mailbox`)、邮件镜像(`sys_mail_message`)、邮件与业务记录的关联(`sys_mail_association`)。同时装上两道读取门,并新增能力名
`manage_mailboxes`,平台管理员和组织管理员默认持有。另把一个“判断调用者能否读某条父记录”的公共函数从两个包挪进
`@objectstack/core`,ADR-0139 里指向它的那一个链接随之更新,ADR 正文不变。
-
**为什么改**:入站邮件第一阶段(#22870)要求“未匹配到任何记录的邮件池默认受限,只有邮箱管理员和邮箱所有者可见,不对全组织开放”。现在普通成员只能读到与自己能看的记录关联的邮件;读池中邮件得到
404,按“不存在”处理,不泄露存在性。真正收信的 IMAP 接入属于 #22871,本 PR 不包含。
- **风险与代价(含回滚)**:三张表只在应用声明 `mailbox`
能力时才注册,不声明的部署零影响。公共函数的搬家经逐字节比对,原附件、评论、动态、审计日志的读取门行为不变,对应 dogfood
套件全绿。代价是组织管理员默认多了一个能力。回滚:revert 本 PR 即可;由于还没有写入方,不会留下数据。
- **席位意见**:
- **你要做的(一个动作)**:因为本 PR 改动了 `docs/adr/**`(只是 ADR-0139 的一个链接),需要你在 PR
上点一次 Approve,之后由席位走合并队列落地。
---
_Generated by [Claude
Code](https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent c9b5b24 commit 1e62f71
58 files changed
Lines changed: 2739 additions & 152 deletions
File tree
- .changeset
- content/docs
- permissions
- protocol
- docs/adr
- examples/app-showcase
- packages
- cli
- src/utils
- test
- core/src
- security
- plugins
- plugin-audit/src
- plugin-security/src
- objects
- qa/dogfood/test
- services
- service-mailbox
- scripts
- src
- objects
- translations
- service-storage/src
- spec
- src
- identity
- kernel
- security
- system/constants
- verify
- scripts
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
| 68 | + | |
68 | 69 | | |
69 | 70 | | |
70 | 71 | | |
| |||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
114 | 114 | | |
115 | 115 | | |
116 | 116 | | |
117 | | - | |
| 117 | + | |
118 | 118 | | |
119 | 119 | | |
120 | 120 | | |
| |||
192 | 192 | | |
193 | 193 | | |
194 | 194 | | |
195 | | - | |
| 195 | + | |
196 | 196 | | |
197 | 197 | | |
198 | 198 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
60 | 60 | | |
61 | 61 | | |
62 | 62 | | |
63 | | - | |
| 63 | + | |
64 | 64 | | |
65 | 65 | | |
66 | 66 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
106 | | - | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
107 | 110 | | |
108 | 111 | | |
109 | 112 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| 93 | + | |
93 | 94 | | |
94 | 95 | | |
95 | 96 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
521 | 521 | | |
522 | 522 | | |
523 | 523 | | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
524 | 530 | | |
525 | 531 | | |
526 | 532 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
123 | 123 | | |
124 | 124 | | |
125 | 125 | | |
| 126 | + | |
126 | 127 | | |
127 | 128 | | |
128 | 129 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
181 | 181 | | |
182 | 182 | | |
183 | 183 | | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
184 | 191 | | |
185 | 192 | | |
186 | 193 | | |
| |||
0 commit comments