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
12 changes: 12 additions & 0 deletions .changeset/large-email-attachments-storage-refs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/plugin-email": minor
"@objectstack/platform-objects": patch
---

plugin-email: large attachments (>256 KiB) now get durable queue delivery, with their content held out of the `sys_email` row

A message whose attachments exceeded the in-row budget was pushed back onto inline delivery — whole, but with none of the durability queue delivery exists to provide, which meant the platform was weakest about exactly the mail that matters most (a signed contract, an exported report). Its content now goes to the `file-storage` capability, the row records a `storageKey` plus the audit metadata, and the queue worker fetches the content back to rebuild the message.

- **Zero migration.** `attachments_json` declared `storageKey` from the start; this adds the producer and the reader. Attachments at or under `SYS_EMAIL_ATTACHMENT_LIMIT_BYTES` still go in the row exactly as before, and the boundary includes equality.
- **The row stays an audit log, not a blob store.** `filename` / `contentType` / `size` / `hash` stay on the row permanently; the content is a delivery artifact and is deleted a grace window (24h) after the row reaches a terminal state, at which point `storageKey` is replaced by `contentReclaimedAt`. Reclamation is a delayed `email.attachment.reclaim` queue job that carries the storage keys, so a row deleted in the meantime reclaims its content instead of orphaning it.
- **Nothing degrades silently.** No `file-storage` capability, or an upload that fails, keeps today's behaviour — inline delivery of the whole message — and says which of the two it was and how to fix it. On the way back, content that cannot be fetched (outage, missing object, no capability on the worker, truncated or substituted bytes) fails the row loudly; a message is never delivered without an attachment it declares.
Original file line number Diff line number Diff line change
Expand Up @@ -2051,7 +2051,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
attachments_json: {
label: "Attachments (JSON)",
help: "Attachments as a JSON array of { filename, contentType?, size, hash, cid?, contentForm, inline?, storageKey? }, with content base64 in `inline`. Written only when the combined raw size is within the plugin-email budget (SYS_EMAIL_ATTACHMENT_LIMIT_BYTES, 256 KiB — ~350 KB of base64 at worst); a larger message is delivered inline and stores nothing here, so the row stays bounded. `storageKey` (out-of-row content) has no producer yet — objectstack#5172."
help: "Attachments as a JSON array of { filename, contentType?, size, hash, cid?, contentForm, inline?, storageKey?, contentReclaimedAt? }. Content up to the plugin-email budget (SYS_EMAIL_ATTACHMENT_LIMIT_BYTES, 256 KiB combined raw — ~350 KB of base64 at worst) is base64 in `inline`; larger content goes to the file-storage capability and the element carries `storageKey` instead, so the row stays bounded either way. filename/contentType/size/hash are PERMANENT audit evidence; out-of-row content is a delivery artifact and is deleted a grace window after the row reaches a terminal state, at which point `storageKey` is replaced by `contentReclaimedAt`."
},
status: {
label: "Status",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2051,7 +2051,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
attachments_json: {
label: "Adjuntos (JSON)",
help: "Adjuntos como un array JSON de { filename, contentType?, size, hash, cid?, contentForm, inline?, storageKey? }, con el contenido en base64 en `inline`. Solo se escribe cuando el tamaño bruto combinado está dentro del presupuesto de plugin-email (SYS_EMAIL_ATTACHMENT_LIMIT_BYTES, 256 KiB — ~350 KB de base64 en el peor caso); un mensaje mayor se entrega en línea y aquí no se almacena nada, de modo que la fila permanece acotada. `storageKey` (contenido fuera de la fila) todavía no tiene productor — objectstack#5172."
help: "Adjuntos como un array JSON de { filename, contentType?, size, hash, cid?, contentForm, inline?, storageKey?, contentReclaimedAt? }. El contenido que cabe en el presupuesto de plugin-email (SYS_EMAIL_ATTACHMENT_LIMIT_BYTES, 256 KiB brutos combinados — ~350 KB de base64 en el peor caso) va en base64 en `inline`; el contenido mayor se guarda en la capacidad file-storage y el elemento lleva `storageKey` en su lugar, de modo que la fila permanece acotada en ambos casos. filename/contentType/size/hash son evidencia de auditoría PERMANENTE; el contenido fuera de la fila es un artefacto de entrega y se elimina tras un periodo de gracia una vez que la fila alcanza un estado terminal, momento en el que `storageKey` se sustituye por `contentReclaimedAt`."
},
status: {
label: "Estado",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2051,7 +2051,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
attachments_json: {
label: "添付ファイル(JSON)",
help: "添付ファイルの JSON 配列で、要素は { filename, contentType?, size, hash, cid?, contentForm, inline?, storageKey? } の形をとり、内容は base64 で `inline` に格納されます。添付の合計生サイズが plugin-email の予算(SYS_EMAIL_ATTACHMENT_LIMIT_BYTES、256 KiB —— 最悪でも base64 で約 350 KB)に収まる場合にのみ書き込まれます。上限を超えるメッセージはインライン配信され、この列には何も保存されないため、行のサイズは有界に保たれます。`storageKey`(行外の内容)にはまだプロデューサーがありません —— objectstack#5172。"
help: "添付ファイルの JSON 配列で、要素は { filename, contentType?, size, hash, cid?, contentForm, inline?, storageKey?, contentReclaimedAt? } の形をとります。plugin-email の予算(SYS_EMAIL_ATTACHMENT_LIMIT_BYTES、合計生サイズ 256 KiB —— 最悪でも base64 で約 350 KB)以内の内容は base64 で `inline` に格納され、それを超える内容は file-storage ケイパビリティに置かれて要素は代わりに `storageKey` を持つため、いずれの場合も行のサイズは有界に保たれます。filename/contentType/size/hash は**恒久的な**監査証跡です。行外の内容は配信用の成果物であり、行が終端状態に達してから猶予期間を過ぎると削除され、その時点で `storageKey` は `contentReclaimedAt` に置き換わります。"
},
status: {
label: "ステータス",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2051,7 +2051,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
attachments_json: {
label: "附件(JSON)",
help: "附件的 JSON 数组,元素形状为 { filename, contentType?, size, hash, cid?, contentForm, inline?, storageKey? },内容以 base64 存放在 `inline` 中。仅当附件合计原始大小在 plugin-email 的预算内(SYS_EMAIL_ATTACHMENT_LIMIT_BYTES,256 KiB —— 最坏约 350 KB 的 base64)时才写入;超出上限的邮件改走内联投递,此列不落任何内容,因此行体积有界。`storageKey`(行外内容)目前还没有生产者 —— 见 objectstack#5172。"
help: "附件的 JSON 数组,元素形状为 { filename, contentType?, size, hash, cid?, contentForm, inline?, storageKey?, contentReclaimedAt? }。在 plugin-email 预算(SYS_EMAIL_ATTACHMENT_LIMIT_BYTES,合计原始大小 256 KiB —— 最坏约 350 KB 的 base64)以内的内容以 base64 存放在 `inline`;超出的内容存入 file-storage 能力,元素改为携带 `storageKey`,因此两种情况下行体积都有界。filename/contentType/size/hash 是**永久**审计证据;行外内容属于投递工件,在行到达终态并经过宽限窗后被删除,届时 `storageKey` 由 `contentReclaimedAt` 取代。"
},
status: {
label: "状态",
Expand Down
10 changes: 6 additions & 4 deletions packages/platform-objects/src/audit/sys-email.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,12 @@ export const SysEmail = ObjectSchema.create({
required: false,
description:
'Attachments as a JSON array of { filename, contentType?, size, hash, cid?, contentForm, '
+ 'inline?, storageKey? }, with content base64 in `inline`. Written only when the combined raw '
+ 'size is within the plugin-email budget (SYS_EMAIL_ATTACHMENT_LIMIT_BYTES, 256 KiB — ~350 KB of '
+ 'base64 at worst); a larger message is delivered inline and stores nothing here, so the row '
+ 'stays bounded. `storageKey` (out-of-row content) has no producer yet — objectstack#5172.',
+ 'inline?, storageKey?, contentReclaimedAt? }. Content up to the plugin-email budget '
+ '(SYS_EMAIL_ATTACHMENT_LIMIT_BYTES, 256 KiB combined raw — ~350 KB of base64 at worst) is base64 in '
+ '`inline`; larger content goes to the file-storage capability and the element carries `storageKey` '
+ 'instead, so the row stays bounded either way. filename/contentType/size/hash are PERMANENT audit '
+ 'evidence; out-of-row content is a delivery artifact and is deleted a grace window after the row '
+ 'reaches a terminal state, at which point `storageKey` is replaced by `contentReclaimedAt`.',
group: 'Content',
}),

Expand Down
Loading
Loading