Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions .changeset/sys-email-headers-internal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
'@objectstack/platform-objects': minor
'@objectstack/plugin-email': patch
---

Stop serving custom email headers through the generic data-API read of `sys_email` (#8149).

**What this closes.** `sys_email.headers_json` — the custom headers handed to `IEmailService.send`, the ordinary place a relay credential or provider token goes — was readable by every caller the data API admits (list, get, an explicit `?select=headers_json`). The column is now declared `internal: true`, so the engine omits it from every generic read with no system carve-out (#7728); `SYSTEM_CTX` does not reopen it either. This is the same shape #8118 ruled on for `sys_http_delivery.headers_json`: this change adopts that remedy rather than deciding it a second time.

**Delivery is unaffected, and fail-closed.** `sys_email` is not delivered from the in-memory message but FROM THE ROW: the after-insert outbox drain hook, the `email.send.async` queue subscriber and the boot outbox sweep all re-read the row and hand it to `EmailService.deliverPersistedRow`. All three read through `engine.find`, which is exactly what the flag empties — so the recovery ships with the flag. `deliverPersistedRow` now recovers the column through ObjectQL's privileged accessor (`resolveInternalField`, consumed unchanged) and sends every authored header verbatim. A message whose headers cannot be recovered is NOT sent without them: a missing header is not self-announcing — a relay that does not require it accepts the mail while the delivery silently deviates from the authored configuration. That case throws and leaves the row `queued`, not `failed`, so the queue retry or the next boot's sweep delivers it intact.

**New optional seam.** `EmailPersistence.readHeadersJson(rowIds)` — the readback the plugin wires off the raw engine. It probes the OBJECT SCHEMA flag, never the absence of the key from a result row: `headers_json` is `required: false` and most real rows carry no custom headers at all, so a key-absence inference would treat every ordinary email as redacted (the regression measured on `sys_account`'s optional token columns in #7987/PR #8675). Engines that do not redact are left untouched and trigger no privileged read.

**What this deliberately does NOT close.** The row still holds the header map in cleartext at rest. Encrypting it (`Field.secret()`) was measured and rejected on #8118 — an orphan `sys_secret` row per message with no cascade or retention, a boot-window fail-open, and a per-row decrypt on every delivery — and this change adopts that ruling unchanged.
Original file line numberDiff line numberDiff line change
Expand Up@@ -2050,7 +2050,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
headers_json: {
label: "Headers (JSON)",
help: "Custom headers supplied to IEmailService.send, as a JSON object of name → value. Written in both delivery modes (it is audit evidence as much as delivery input). Absent on rows written before this column existed, which read back as \"no custom headers\"."
help: "Custom headers supplied to IEmailService.send, as a JSON object of name → value. Written in both delivery modes (it is audit evidence as much as delivery input). Absent on rows written before this column existed, which read back as \"no custom headers\". Never returned on the generic data path (#8149) — headers are the ordinary place a credential goes; the delivery paths recover it through the engine's privileged accessor."
},
attachments_json: {
label: "Attachments (JSON)",
Expand Down
40 changes: 39 additions & 1 deletion packages/platform-objects/src/audit/sys-email.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,13 +115,51 @@ export const SysEmail = ObjectSchema.create({
// part of the message the row cannot carry is therefore a part a durable
// delivery silently drops — which is why messages with headers or
// attachments used to be pushed back onto inline delivery instead.
// [#8149] `internal: true` — custom headers are the ordinary place a
// credential goes (an SMTP relay's `Authorization`, a provider token, a
// routing secret), the same shape #8118 ruled on for
// `sys_http_delivery.headers_json`, and this table is readable over the
// ordinary data API (`enable.apiMethods` below). So the engine OMITS the
// column from every generic read: list, get, an explicit
// `?select=headers_json`, and the write-response bodies — with no system
// carve-out (#7728's explicit design; `SYSTEM_CTX` does not reopen it).
//
// The redaction sits at the ROW layer, so it covers every producer of the
// column by construction, not just the one that exists today
// (`IEmailService.send` → `encodeHeadersForRow`). Unlike
// `sys_http_delivery`, `sys_email` has no second authoring population to
// cover — `apiMethods` admits no `create`, so the only writer is the mail
// service's own persistence seam — but the placement means an in-process
// writer added later inherits the protection instead of having to
// re-declare it.
//
// Delivery is unaffected: the durable delivery paths (queue worker, boot
// outbox sweep, the after-insert drain hook) all re-read the row and hand
// it to `EmailService.deliverPersistedRow`, which recovers this column
// through ObjectQL's privileged accessor (`resolveInternalField`, the
// remedy #7728 named and #8118 landed) — see
// `plugin-email/src/internal-header-readback.ts`. Fail-closed: a message
// whose authored headers cannot be recovered is NOT sent without them (a
// header that silently goes missing is not self-announcing — the receiver
// that does not require it accepts the mail while the delivery deviates
// from the authored configuration).
//
// Read-side only, deliberately, exactly as #8118 ruled for the sibling
// column: storage is untouched and the row still carries the map in
// cleartext. `Field.secret()` was measured and REJECTED there (an orphan
// `sys_secret` row per delivery with no cascade or retention, a
// boot-window fail-open, a per-row decrypt on every tick); this card
// adopts that decision rather than re-deciding it.
headers_json: Field.textarea({
label: 'Headers (JSON)',
required: false,
internal: true,
description:
'Custom headers supplied to IEmailService.send, as a JSON object of name → value. '
+ 'Written in both delivery modes (it is audit evidence as much as delivery input). '
+ 'Absent on rows written before this column existed, which read back as "no custom headers".',
+ 'Absent on rows written before this column existed, which read back as "no custom headers". '
+ 'Never returned on the generic data path (#8149) — headers are the ordinary place a '
+ 'credential goes; the delivery paths recover it through the engine\'s privileged accessor.',
group: 'Content',
}),

Expand Down
Loading
Loading