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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ expects: `classifyPluginFile` refuses any path outside `apps/web/src/plugins/<id
subdir prefix has been stripped. A plugin ships **no migration, no wrangler toml and no
`package.json`** — those three are the host's, always.

A plugin's release notes live in `plugins/<id>/docs/upgrades/` and nowhere else. `pnpm plugin add`
copies them to the host's `docs/plugins/<id>/upgrades/`, and `pnpm plugin export` copies that host
directory back verbatim. After an export, delete `plugins/<id>/docs/plugins/`
(`find plugins -path '*docs/plugins*'` should print nothing): that copy duplicates the notes at the
same host path, and it is how 3.6.0 shipped two of them.

## The plugin API

A plugin's compatibility with a kit is **observed, not declared**. `pnpm plugin export` derives the
Expand Down
28 changes: 0 additions & 28 deletions plugins/freeagent/docs/plugins/freeagent/upgrades/README.md

This file was deleted.

41 changes: 0 additions & 41 deletions plugins/freeagent/docs/plugins/freeagent/upgrades/unreleased.md

This file was deleted.

11 changes: 9 additions & 2 deletions plugins/freeagent/docs/upgrades/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,18 @@ manual: false

## What changed

_Nothing yet. Add an entry here in the same pull request as the change — see `README.md` beside this
file for the fields and for what "How to apply" has to say._
No code change to freeagent. The duplicated `docs/plugins/freeagent/upgrades/` copy inside the plugin directory, left there by `pnpm plugin export` in 3.6.0, is removed.

- A host's `docs/plugins/freeagent/upgrades/` now comes only from the plugin's `docs/upgrades/`, its one canonical home. The duplicate could put a stale `unreleased.md` (`previous: null`) at the same host path.

## How to apply

1. `pnpm plugin upgrade freeagent --apply`. It changes the version stamps and the release notes under `docs/plugins/freeagent/upgrades/`.

## Conflicts to expect

None.

## Verify

1. `pnpm plugin check` reports `freeagent` checks out at the new version.
5 changes: 4 additions & 1 deletion plugins/inbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ job. Other plugins subscribe to parsed messages through the `inbox:consumers` ex
|---|---|
| Email | A catch-all `emailHandlers` entry. Unknown addresses, senders that aren't allowed and failed authentication are bounced with a reason the forwarding mailbox shows |
| UI | **Inbox**: messages, sender and forwarder, authentication verdicts, text body, attachments, raw/HTML downloads, reprocess and delete. **Settings → Inbox**: address, allow-list, require authentication, on/off |
| Forward as attachment | Each `message/rfc822` part of a message (Gmail's "Forward as attachment", with or without a disposition) becomes a **message of its own**, linked to the forward it came in: its own sender, subject, date and attachments, the forward's authentication verdict, and who forwarded it. An embedded email already received is linked, not stored again. Nesting stops at 3 levels and 50 emails per message |
| Forwarding | Gmail and Proton forwarding confirmations are let through before anyone is allow-listed, and their **code and link** are shown at the top of the message |
| Knowledge | Each parsed message (headers + text) and its PDF/Office/HTML/text attachments are indexed into the kit's knowledge base (`inbox:message`, `inbox:attachment`), so chat can search your mail. Upserted on reprocess, removed on delete, switchable in Settings |
| Agent tools | `inbox_list_messages` and `inbox_get_message`, both read-only |
| Extension | `inbox:consumers`: a plugin contributes `{ id, onMessage(message, ctx) }` and is called once per parsed message, with attachment refs it reads through `readInboxAttachment` |
| Extension | `inbox:consumers`: a plugin contributes `{ id, onMessage(message, ctx) }` and is called once per parsed message — so once per embedded email of a forward — with attachment refs it reads through `readInboxAttachment`, and `parentMessageId` / `forwardedBy` / `forwardedMessageIds` for forwarded mail |
| API | `GET/PUT /api/inbox/settings`, `GET /api/inbox/messages`, `GET /api/inbox/messages/:id`, `…/:id/raw`, `…/:id/html`, `GET /api/inbox/attachments/:id`, `POST …/:id/reprocess`, `DELETE …/:id` |
| Data | `inbox_settings`, `inbox_messages` and `inbox_attachments`, all with row-level security. Bytes live in R2 under `tenants/<id>/inbox/` and are purged with the tenant |
| CLI | `rocketflare inbox list [--status]` and `rocketflare inbox status` |
Expand Down Expand Up @@ -73,3 +74,5 @@ Every member can read the inbox and its attachments. Owners and admins change th
- No outbound replies.
- The routing rule is a manual dashboard step; provisioning doesn't create it.
- The HTML body is a download only and is never rendered in the app.
- A message split out of a forward carries the forward's SPF/DKIM/DMARC verdict; its own signatures can't be checked once it has been re-wrapped.
- Messages received before 3.7.0 aren't split until they are reprocessed.
21 changes: 19 additions & 2 deletions plugins/inbox/apps/web/src/plugins/inbox/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,26 @@ Inbound email for an organisation: Cloudflare Email Routing → the kit's `email
`nosniff`; the UI shows the text body only. These are bytes a stranger chose.
- **Parse is idempotent.** Attachments are unique per (message, sha256) and upserted, so a
reprocess keeps attachment ids a consumer may have stored. Consumers must be idempotent too.
- **Forwarded as attachment** (`services/forwarded.ts`): postal-mime runs with
`forceRfc822Attachments`, so every `message/rfc822` part — with or without a disposition — is
an attachment, never merged into the parent body. Each becomes a CHILD message: its own row
(`parent_message_id`, `forward_depth`, `source_sha256`), its own R2 prefix, the embedded
headers (read from the header block only), the PARENT's `auth` (the inner signatures cannot be
verified) and `forwarded_by` (the top-level envelope sender), then its own `inbox.parse`. The
parent keeps the `.eml` row with `expanded_message_id` set; such rows are excluded from consumer
`attachments`, knowledge and the parent's `attachment_count`. Idempotency is the unique
`(tenant_id, parent_message_id, source_sha256)` index plus the per-tenant Message-ID index (an
email already received is LINKED, not stored again) — never memory. Caps:
`INBOX_FORWARD_MAX_DEPTH` (3; at the cap parts stay plain `.eml`s) and
`INBOX_FORWARD_MAX_CHILDREN` (50 per message; the rest stay attachments and the message's
`error` says so). A child still `received` when its parent is re-run is re-enqueued, so a
failed enqueue cannot strand it — consumers must tolerate the rare second call. Deleting a
parent leaves its children (`parent_message_id` is `set null`); each is deleted on its own.
- **Consumers** (`InboxConsumer`, exported from `index.ts`) are called for `kind = 'message'` only,
never for forwarding confirmations. A consumer's throw is logged and written to the message's
`error`; it does not stop the others and does not retry the job.
never for forwarding confirmations — once per message, so once per embedded email (a child
carries `parentMessageId`/`forwardedBy`; a parent lists `forwardedMessageIds`). A consumer's
throw is logged and written to the message's `error`; it does not stop the others and does not
retry the job.
- **Knowledge** (`services/knowledge.ts`): after consumers, a parsed message (headers + text) and
each non-image attachment are ingested with `source: 'inbox:message' | 'inbox:attachment'` and the
row's id as `externalId` — upserts, so a reprocess is safe. Best-effort: failures land on the
Expand Down
34 changes: 33 additions & 1 deletion plugins/inbox/apps/web/src/plugins/inbox/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ import {
type InboxAuth,
type InboxConfirmation,
type InboxMessage,
type InboxMessageLink,
type InboxMessageListQuery,
type InboxMessageSummary,
inboxMessageListQuerySchema,
type UpdateInboxSettings,
updateInboxSettingsSchema,
} from '@rocketflare/shared/plugins/inbox/index'
import { and, count, desc, eq, type SQL } from 'drizzle-orm'
import { and, count, desc, eq, inArray, type SQL } from 'drizzle-orm'
import type { RequestCtx } from '@/plugins/api'
import {
createRouter,
Expand Down Expand Up @@ -54,9 +55,30 @@ function toSummary(row: InboxMessageRow): InboxMessageSummary {
receivedAt: row.receivedAt,
attachmentCount: row.attachmentCount,
rawSize: row.rawSize,
parentMessageId: row.parentMessageId,
forwardedBy: row.forwardedBy,
forwardedCount: row.forwardedCount,
}
}

const linkColumns = {
id: inboxMessages.id,
subject: inboxMessages.subject,
fromAddress: inboxMessages.fromAddress,
fromName: inboxMessages.fromName,
}

/** The other messages one message links to — its wrapper, the emails split out of it. */
async function loadLinks(ctx: RequestCtx, ids: readonly string[]): Promise<InboxMessageLink[]> {
if (ids.length === 0) return []
const rows = await ctx.db
.select(linkColumns)
.from(inboxMessages)
.where(and(eq(inboxMessages.tenantId, ctx.tenantId), inArray(inboxMessages.id, [...ids])))
const byId = new Map(rows.map(r => [r.id, r]))
return ids.flatMap(id => byId.get(id) ?? [])
}

function download(body: ReadableStream, contentType: string, filename: string, size?: number) {
const headers = new Headers({
'Content-Type': contentType,
Expand Down Expand Up @@ -153,6 +175,13 @@ inboxRouter.get('/messages/:id', async c => {
.from(inboxAttachments)
.where(and(eq(inboxAttachments.tenantId, ctx.tenantId), eq(inboxAttachments.messageId, row.id)))
.orderBy(inboxAttachments.createdAt)
const forwardedIds = [
...new Set(attachments.flatMap(a => (a.expandedMessageId ? [a.expandedMessageId] : []))),
]
const [parent, forwardedMessages] = await Promise.all([
row.parentMessageId ? loadLinks(ctx, [row.parentMessageId]) : Promise.resolve([]),
loadLinks(ctx, forwardedIds),
])
const body: InboxMessage = {
...toSummary(row),
messageIdHeader: row.messageIdHeader,
Expand All @@ -169,8 +198,11 @@ inboxRouter.get('/messages/:id', async c => {
size: a.size,
sha256: a.sha256,
inline: a.inline,
expandedMessageId: a.expandedMessageId,
})
),
parent: parent[0] ?? null,
forwardedMessages,
}
return c.json(body)
})
Expand Down
38 changes: 37 additions & 1 deletion plugins/inbox/apps/web/src/plugins/inbox/db/schema/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,18 @@
* `tenants/<id>/inbox/<message id>/raw.eml` (purged with the tenant by the kit); the row carries
* what the list and the consumers need. `message_id_header` is unique per tenant when present,
* so a message delivered twice (two forwarding rules, a retried SMTP hand-off) is stored once.
* - `inbox_attachments` — one row per extracted part, cascading with its message, bytes in R2.
* A message that arrived as a `message/rfc822` part of another (Gmail's "Forward as
* attachment") is a row of its own — a CHILD — linked by `parent_message_id`, carrying the
* embedded email's own headers, the parent's authentication verdict and `forwarded_by`.
* `(tenant_id, parent_message_id, source_sha256)` is unique, so a redelivered parse cannot
* mint a second child for the same part.
* - `inbox_attachments` — one row per extracted part, cascading with its message, bytes in R2. An
* embedded message's row keeps the original `.eml` and points at the message it became through
* `expanded_message_id` — such a row is never handed to consumers or the knowledge base.
*/
import { sql } from 'drizzle-orm'
import {
type AnyPgColumn,
boolean,
index,
integer,
Expand Down Expand Up @@ -75,6 +83,24 @@ export const inboxMessages = pgTable(
attachmentCount: integer('attachment_count').notNull().default(0),
error: text('error'),
parsedAt: timestamp('parsed_at', { withTimezone: true }),
/**
* The message this one was forwarded inside (a `message/rfc822` part of it), else null.
* `set null`, not cascade: deleting the wrapper leaves the receipts it carried in place.
*/
parentMessageId: uuid('parent_message_id').references((): AnyPgColumn => inboxMessages.id, {
onDelete: 'set null',
}),
/**
* Who forwarded a child — the parent's authenticated envelope sender (the top-level one, at
* any depth). The child's own SPF/DKIM/DMARC cannot be checked; it inherits the parent's.
*/
forwardedBy: text('forwarded_by'),
/** 0 for mail that arrived on its own; a child is its parent's depth + 1 (capped). */
forwardDepth: integer('forward_depth').notNull().default(0),
/** SHA-256 of the embedded part a child was made from — its idempotency key with the parent. */
sourceSha256: text('source_sha256'),
/** How many embedded messages this one was split into (new children plus linked duplicates). */
forwardedCount: integer('forwarded_count').notNull().default(0),
...timestamps(),
},
table => [
Expand All @@ -83,6 +109,9 @@ export const inboxMessages = pgTable(
uniqueIndex('inbox_messages_tenant_message_id_uidx')
.on(table.tenantId, table.messageIdHeader)
.where(sql`${table.messageIdHeader} IS NOT NULL`),
uniqueIndex('inbox_messages_tenant_parent_part_uidx')
.on(table.tenantId, table.parentMessageId, table.sourceSha256)
.where(sql`${table.parentMessageId} IS NOT NULL`),
tenantIsolation('inbox_messages'),
]
)
Expand All @@ -101,6 +130,13 @@ export const inboxAttachments = pgTable(
sha256: text('sha256').notNull(),
storageKey: text('storage_key').notNull(),
inline: boolean('inline').notNull().default(false),
/**
* For an embedded `message/rfc822` part: the inbox message it was split into (a new child, or
* an earlier message with the same Message-ID). Null for an ordinary attachment.
*/
expandedMessageId: uuid('expanded_message_id').references(() => inboxMessages.id, {
onDelete: 'set null',
}),
...timestamps(),
},
table => [
Expand Down
21 changes: 16 additions & 5 deletions plugins/inbox/apps/web/src/plugins/inbox/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,19 @@
"createRouter",
"Database",
"defineTool",
"deleteIngestedDocument",
"EMAIL_CATCH_ALL",
"emailCtx",
"EmailCtx",
"emailLocalPart",
"emailSubaddress",
"ingestDocument",
"ingestDocumentFile",
"isUniqueViolation",
"jobCtx",
"JobCtx",
"JobHandler",
"PluginContext",
"recordActivity",
"requestCtx",
"RequestCtx",
Expand All @@ -45,7 +49,7 @@
"ToolCtx",
"validate"
],
"@/plugins/api/peers": ["extensions"],
"@/plugins/api/peers": ["allTables", "extensions"],
"@/plugins/api/ui-wiring": ["NavGuard", "UiPlugin"],
"@/plugins/api/ui": [
"api",
Expand All @@ -66,7 +70,14 @@
"timeAgo",
"usePermissions"
],
"@/db/schema/kit": ["tenantIsolation", "tenantRef", "tenants", "timestamps", "users"],
"@/db/schema/kit": [
"tenantIsolation",
"tenantRef",
"tenants",
"tenantUsers",
"timestamps",
"users"
],
"'../api' (apps/cli/src/plugins/api.ts)": [
"CliPlugin",
"CommandContext",
Expand Down Expand Up @@ -110,9 +121,9 @@
"rlsExcluded": []
},
"migrations": [
"inbox_settings \u2014 one row per tenant (unique tenant_id) plus a GLOBALLY unique local_part, the sender allow-list and require_authentication; RLS policy",
"inbox_messages \u2014 one row per accepted message, raw bytes in R2; partial unique (tenant_id, message_id_header); RLS policy",
"inbox_attachments \u2014 one row per kept part, cascading with its message, bytes in R2; unique (tenant_id, message_id, sha256); RLS policy"
"inbox_settings — one row per tenant (unique tenant_id) plus a GLOBALLY unique local_part, the sender allow-list and require_authentication; RLS policy",
"inbox_messages — one row per accepted message, raw bytes in R2; partial unique (tenant_id, message_id_header); a message split out of a forward links its parent (parent_message_id, self-FK set null) with forwarded_by, forward_depth, source_sha256 and partial unique (tenant_id, parent_message_id, source_sha256); forwarded_count; RLS policy",
"inbox_attachments — one row per kept part, cascading with its message, bytes in R2; unique (tenant_id, message_id, sha256); expanded_message_id (FK set null) for an embedded message split into its own row; RLS policy"
],
"subdir": "plugins/inbox"
}
Loading
Loading