From 73871f007c5a93520da2e7d3949393a1deec0c9a Mon Sep 17 00:00:00 2001 From: Clifton Cunningham Date: Sat, 24 Oct 2026 10:38:47 +0100 Subject: [PATCH 1/4] Add the inbox plugin: inbound email through Cloudflare Email Routing Requires kit 0.22.0 (emailHandlers slot, storage() on background contexts). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- README.md | 1 + plugins/inbox/README.md | 74 ++++ .../inbox/apps/cli/src/plugins/inbox/index.ts | 65 +++ .../apps/web/src/plugins/inbox/CLAUDE.md | 26 ++ .../apps/web/src/plugins/inbox/api/routes.ts | 246 +++++++++++ .../web/src/plugins/inbox/api/settings.ts | 57 +++ .../web/src/plugins/inbox/db/schema/inbox.ts | 118 +++++ .../web/src/plugins/inbox/db/schema/index.ts | 2 + .../inbox/apps/web/src/plugins/inbox/index.ts | 71 +++ .../apps/web/src/plugins/inbox/jobs/parse.ts | 15 + .../apps/web/src/plugins/inbox/plugin.json | 49 +++ .../web/src/plugins/inbox/services/parse.ts | 290 +++++++++++++ .../web/src/plugins/inbox/services/receive.ts | 168 ++++++++ .../web/src/plugins/inbox/services/rules.ts | 185 ++++++++ .../src/plugins/inbox/tests/api/inbox.test.ts | 405 ++++++++++++++++++ .../plugins/inbox/tests/config/rules.test.ts | 180 ++++++++ .../src/plugins/inbox/tools/inbox-tools.ts | 152 +++++++ .../src/plugins/inbox/ui/hooks/useInbox.ts | 64 +++ .../apps/web/src/plugins/inbox/ui/index.ts | 36 ++ .../src/plugins/inbox/ui/pages/InboxPage.tsx | 271 ++++++++++++ .../plugins/inbox/ui/pages/InboxSettings.tsx | 146 +++++++ .../web/src/plugins/inbox/ui/query-keys.ts | 20 + plugins/inbox/docs/upgrades/README.md | 28 ++ plugins/inbox/docs/upgrades/unreleased.md | 37 ++ .../shared/src/plugins/inbox/index.ts | 192 +++++++++ plugins/inbox/rocketflare-plugin.json | 145 +++++++ plugins/inbox/skills/inbox/SKILL.md | 97 +++++ 28 files changed, 3141 insertions(+), 1 deletion(-) create mode 100644 plugins/inbox/README.md create mode 100644 plugins/inbox/apps/cli/src/plugins/inbox/index.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/CLAUDE.md create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/api/routes.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/api/settings.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/db/schema/inbox.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/db/schema/index.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/index.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/jobs/parse.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/plugin.json create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/services/parse.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/services/receive.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/services/rules.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/tests/api/inbox.test.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/tests/config/rules.test.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/tools/inbox-tools.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/ui/hooks/useInbox.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/ui/index.ts create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/ui/pages/InboxPage.tsx create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/ui/pages/InboxSettings.tsx create mode 100644 plugins/inbox/apps/web/src/plugins/inbox/ui/query-keys.ts create mode 100644 plugins/inbox/docs/upgrades/README.md create mode 100644 plugins/inbox/docs/upgrades/unreleased.md create mode 100644 plugins/inbox/packages/shared/src/plugins/inbox/index.ts create mode 100644 plugins/inbox/rocketflare-plugin.json create mode 100644 plugins/inbox/skills/inbox/SKILL.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9fc8f8..0be8da7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: plugins: uses: rocketflare-dev/rocketflare/.github/workflows/plugin-ci.yml@main with: - plugin_subdirs: '["plugins/analytics", "plugins/web-knowledge", "plugins/connectors", "plugins/m365", "plugins/issue-reports"]' + plugin_subdirs: '["plugins/analytics", "plugins/web-knowledge", "plugins/connectors", "plugins/m365", "plugins/issue-reports", "plugins/inbox"]' # On a kit-released dispatch, prove against the ref that was just cut — used verbatim, with # the range NOT consulted. That is what makes the notification worth sending: resolving the # range again would pick the same versions as the last run and prove nothing new. Empty on diff --git a/README.md b/README.md index 282d670..51929f9 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ sees what and its known gaps. They are also published at | [**connectors**](plugins/connectors/README.md) | Organisation connections: admin consent once, then directory and calendar sync every 15 minutes; Settings → Connections, My calendar, CLI and the `connectors` setup skill | kit 0.13.0 and a provider | | [**m365**](plugins/m365/README.md) | Microsoft 365 as a `connectors` provider: Entra admin consent, app-only Graph delta sync of users, groups and calendars | kit 0.12.0 and `connectors` | | [**issue-reports**](plugins/issue-reports/README.md) | Ideas & issues: anyone talks a bug or an idea through with an agent that asks until it is clear, checks for a duplicate, and logs it as a GitHub issue (or a comment) once they approve the draft | kit 0.21.0 and a GitHub token | +| [**inbox**](plugins/inbox/README.md) | Inbound email through Cloudflare Email Routing: per-organisation address and sender allow-list, SPF/DKIM/DMARC checks, parsed bodies and attachments, Gmail/Proton forwarding-confirmation codes, an Inbox page, CLI, read-only agent tools, and an `inbox:consumers` extension other plugins build on | kit 0.22.0 and Email Routing on the domain | ## Installing one diff --git a/plugins/inbox/README.md b/plugins/inbox/README.md new file mode 100644 index 0000000..e8b59c7 --- /dev/null +++ b/plugins/inbox/README.md @@ -0,0 +1,74 @@ +# inbox + +> Inbound email for your app: forward receipts, invoices and notices to an address on your domain +> and they arrive parsed, with their attachments, ready for other plugins to act on. + +Mail reaches the Worker through **Cloudflare Email Routing** and the kit's `email` entry. Each +organisation picks a local part (`receipts`). Mail to `receipts@`, or +`receipts+anything@`, is checked against the organisation's sender allow-list and the SPF, DKIM +and DMARC verdicts Cloudflare recorded. Accepted mail is stored raw in R2 and parsed in a queued +job. Other plugins subscribe to parsed messages through the `inbox:consumers` extension. + +## What it adds + +| Area | What you get | +|---|---| +| 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 | +| 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 | +| 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` | +| 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//inbox/` and are purged with the tenant | +| CLI | `rocketflare inbox list [--status]` and `rocketflare inbox status` | +| Skill | `/inbox`: Email Routing setup, Gmail and Proton forwarding, testing, troubleshooting | + +## Requirements + +- Rocketflare kit **0.22.0** or later (the `emailHandlers` slot and `storage()` on background contexts). +- The receiving domain on Cloudflare, with Email Routing enabled and a rule that sends the address to the Worker. +- The kit's `FILES` (R2) and `JOBS_QUEUE` bindings, which every kit app already has. + +## Install + +```bash +pnpm plugin add https://github.com/rocketflare-dev/rocketflare-plugins.git@ --subdir plugins/inbox +pnpm plugin add https://github.com/rocketflare-dev/rocketflare-plugins.git@ --subdir plugins/inbox --apply +pnpm db:generate --name plugin-inbox- && pnpm db:migrate +``` + +Then run `/inbox setup`. + +## Building on it + +```ts +import { inboxExtensions } from '@rocketflare/shared/plugins/inbox/index' +import { type InboxConsumer, readInboxAttachment } from '@/plugins/inbox' + +const receipts: InboxConsumer = { + id: 'receipts', + async onMessage(message, ctx) { + // Quick and idempotent: enqueue your own job, keyed on message.messageId. + await ctx.enqueue({ type: 'receipts.extract', payload: { tenantId: message.tenantId, messageId: message.messageId } }) + }, +} + +export const receiptsServer = { + // … + extensions: inboxExtensions({ consumers: [receipts] }), +} +``` + +Declare `requires: { plugins: ["inbox"] }` in your `plugin.json`. + +## Who sees what + +Every member can read the inbox and its attachments. Owners and admins change the settings, reprocess messages and delete them. + +## Known gaps + +- Bounced messages aren't stored or listed. The bounce reason goes only to the forwarding mailbox and the Worker log. +- One local part per organisation. +- 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. diff --git a/plugins/inbox/apps/cli/src/plugins/inbox/index.ts b/plugins/inbox/apps/cli/src/plugins/inbox/index.ts new file mode 100644 index 0000000..3de398b --- /dev/null +++ b/plugins/inbox/apps/cli/src/plugins/inbox/index.ts @@ -0,0 +1,65 @@ +/** + * `rocketflare inbox …` — the plugin's CLI half. + * + * rocketflare inbox list [--status parsed|received|failed] [--json] GET /api/inbox/messages + * rocketflare inbox status [--json] GET /api/inbox/settings + */ +import { + INBOX_ID, + inboxMessageListResponseSchema, + inboxSettingsSchema, + inboxShared, +} from '@rocketflare/shared/plugins/inbox/index' +import type { CliPlugin, CommandContext } from '../api' +import { formatDate, renderTable, requireClient } from '../api' + +export async function runInboxList( + ctx: CommandContext, + options: { status?: string } +): Promise { + const query = options.status ? `?status=${encodeURIComponent(options.status)}` : '' + const { data, raw } = await requireClient(ctx).request('GET', `/api/inbox/messages${query}`, { + schema: inboxMessageListResponseSchema, + }) + ctx.out.data(raw, () => + data.items.length === 0 + ? 'No messages.' + : renderTable(data.items, [ + { header: 'Received', value: m => formatDate(m.receivedAt) }, + { header: 'From', value: m => m.fromAddress ?? m.envelopeFrom }, + { header: 'Subject', value: m => m.subject }, + { header: 'Files', value: m => m.attachmentCount }, + { header: 'Status', value: m => m.status }, + ]) + ) +} + +export async function runInboxStatus(ctx: CommandContext): Promise { + const { data, raw } = await requireClient(ctx).request('GET', '/api/inbox/settings', { + schema: inboxSettingsSchema, + }) + ctx.out.data(raw, () => + [ + `Inbox: ${data.enabled ? 'on' : 'off'}`, + `Address: ${data.localPart ?? 'not set'}`, + `Allowed senders: ${data.allowedSenders.length ? data.allowedSenders.join(', ') : 'none'}`, + `Authentication: ${data.requireAuthentication ? 'required' : 'not required'}`, + ].join('\n') + ) +} + +export const inboxCli: CliPlugin = { + shared: inboxShared, + register(program, action) { + const root = program.command(INBOX_ID).description('inbound email (a plugin)') + root + .command('list', { isDefault: true }) + .description('list received messages, newest first') + .option('--status ', 'received | parsed | failed') + .action(action((ctx, cmd) => runInboxList(ctx, cmd.opts()))) + root + .command('status') + .description('show the inbox address and allow-list') + .action(action(ctx => runInboxStatus(ctx))) + }, +} diff --git a/plugins/inbox/apps/web/src/plugins/inbox/CLAUDE.md b/plugins/inbox/apps/web/src/plugins/inbox/CLAUDE.md new file mode 100644 index 0000000..8930265 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/CLAUDE.md @@ -0,0 +1,26 @@ +# inbox plugin — rules for this directory + +Inbound email for an organisation: Cloudflare Email Routing → the kit's `email` entry → +`emailHandlers` (this plugin claims `*`) → `services/receive.ts` → R2 + `inbox_messages` → +`inbox.parse` → `services/parse.ts` → every `inbox:consumers` extension. + +- **`email()` stays cheap.** `receiveMessage` decides (address → organisation, allow-list, + authentication), stores the raw bytes, writes ONE row and enqueues. No MIME parsing there — + that is `inbox.parse`, which has the queue's retries and its own CPU budget. +- **Refuse before you write.** Every rejection is `message.setReject(reason)` (a permanent bounce + the forwarding mailbox shows its owner) and happens before any R2 or Postgres write. A throw + AFTER the decision is rethrown by the kit as a temporary failure, so the sender retries. +- **The one unscoped query** is `settingsForLocalPart` — a message names an address, not a + tenant. It is in `unscopedAllowlist` with its reason; every other query carries `tenant_id`. +- **R2 keys live under `tenants//inbox//`** (`messagePrefix`), so the kit's + tenant purge removes them and deleting a message is one prefix. +- **Never render a body.** The HTML body and raw `.eml` are served as `attachment` with + `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. +- **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. +- **Forwarding confirmations** (Gmail's `forwarding-noreply@google.com`, Proton domains) are + accepted before the allow-list names anyone — that is the only way to read the code — but must + still authenticate. Logic and tables: `services/rules.ts`, `tests/config/rules.test.ts`. diff --git a/plugins/inbox/apps/web/src/plugins/inbox/api/routes.ts b/plugins/inbox/apps/web/src/plugins/inbox/api/routes.ts new file mode 100644 index 0000000..cbf4e33 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/api/routes.ts @@ -0,0 +1,246 @@ +/** + * `/api/inbox` — settings, the message list, one message, its raw `.eml`, its HTML body, an + * attachment's bytes, reprocess and delete. + * + * Downloads are always `Content-Disposition: attachment` with `X-Content-Type-Options: nosniff`: + * these are bytes a stranger chose, and rendering them on this origin would hand that stranger a + * page with the user's cookies. The HTML body in particular is served as an attachment, never + * inline. + */ +import { + INBOX_MESSAGE_SUBJECT, + INBOX_MESSAGES_ENTITY, + INBOX_PARSE_JOB, + INBOX_SETTINGS_ENTITY, + INBOX_SETTINGS_SUBJECT, + type InboxAttachment, + type InboxAuth, + type InboxConfirmation, + type InboxMessage, + 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 type { RequestCtx } from '@/plugins/api' +import { + createRouter, + isUniqueViolation, + recordActivity, + requestCtx, + validate, +} from '@/plugins/api' +import { type InboxMessageRow, inboxAttachments, inboxMessages } from '../db/schema' +import { messagePrefix } from '../services/receive' +import { loadSettings, saveSettings, toSettings } from './settings' + +export const inboxRouter = createRouter() + +function toSummary(row: InboxMessageRow): InboxMessageSummary { + return { + id: row.id, + status: row.status as InboxMessageSummary['status'], + kind: row.kind as InboxMessageSummary['kind'], + envelopeFrom: row.envelopeFrom, + fromAddress: row.fromAddress, + fromName: row.fromName, + toAddress: row.toAddress, + subaddress: row.subaddress, + subject: row.subject, + sentAt: row.sentAt, + receivedAt: row.receivedAt, + attachmentCount: row.attachmentCount, + rawSize: row.rawSize, + } +} + +function download(body: ReadableStream, contentType: string, filename: string, size?: number) { + const headers = new Headers({ + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename.replace(/"/g, '')}"`, + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'private, no-store', + }) + if (size !== undefined) headers.set('Content-Length', String(size)) + return new Response(body, { headers }) +} + +async function loadMessage(ctx: RequestCtx, id: string): Promise { + const [row] = await ctx.db + .select() + .from(inboxMessages) + .where(and(eq(inboxMessages.tenantId, ctx.tenantId), eq(inboxMessages.id, id))) + .limit(1) + return row ?? ctx.notFound('Message not found', 'inbox_message_not_found') +} + +// ---- settings ----------------------------------------------------------------------------------- + +inboxRouter.get('/settings', async c => { + const ctx: RequestCtx = requestCtx(c) + ctx.guard('read', INBOX_SETTINGS_SUBJECT) + return c.json(toSettings(await loadSettings(ctx.db, ctx.tenantId))) +}) + +inboxRouter.put('/settings', validate('json', updateInboxSettingsSchema), async c => { + const ctx: RequestCtx = requestCtx(c) + ctx.guard('manage', INBOX_SETTINGS_SUBJECT) + const body = ctx.valid('json') + const existing = await loadSettings(ctx.db, ctx.tenantId) + const localPart = body.localPart ?? existing?.localPart ?? null + if ((body.enabled ?? existing?.enabled) && !localPart) { + ctx.badRequest('Choose an address before turning the inbox on', 'inbox_local_part_required') + } + let row: Awaited> + try { + row = await saveSettings(ctx.db, ctx.tenantId, ctx.userId, body) + } catch (err) { + if (isUniqueViolation(err)) { + ctx.conflict('That address is already taken', 'inbox_local_part_taken') + } + throw err + } + ctx.defer(() => + recordActivity(ctx.db, { + tenantId: ctx.tenantId, + userId: ctx.userId, + type: 'inbox.settings.updated', + subjectType: INBOX_SETTINGS_SUBJECT, + subjectId: row.id, + metadata: { + enabled: row.enabled, + localPart: row.localPart, + allowedSenders: row.allowedSenders.length, + }, + }) + ) + ctx.nudge(INBOX_SETTINGS_ENTITY) + return c.json(toSettings(row)) +}) + +// ---- messages ----------------------------------------------------------------------------------- + +inboxRouter.get('/messages', validate('query', inboxMessageListQuerySchema), async c => { + const ctx: RequestCtx = requestCtx(c) + ctx.guard('read', INBOX_MESSAGE_SUBJECT) + const query = ctx.valid('query') + const filters: SQL[] = [eq(inboxMessages.tenantId, ctx.tenantId)] + if (query.status) filters.push(eq(inboxMessages.status, query.status)) + if (query.kind) filters.push(eq(inboxMessages.kind, query.kind)) + const where = and(...filters) + const [rows, totals] = await Promise.all([ + ctx.db + .select() + .from(inboxMessages) + .where(where) + .orderBy(desc(inboxMessages.receivedAt)) + .limit(query.pageSize) + .offset((query.page - 1) * query.pageSize), + ctx.db.select({ total: count() }).from(inboxMessages).where(where), + ]) + return c.json(ctx.page(rows.map(toSummary), totals[0]?.total ?? 0, query)) +}) + +inboxRouter.get('/messages/:id', async c => { + const ctx: RequestCtx = requestCtx(c) + ctx.guard('read', INBOX_MESSAGE_SUBJECT) + const row = await loadMessage(ctx, ctx.uuid('id')) + const attachments = await ctx.db + .select() + .from(inboxAttachments) + .where(and(eq(inboxAttachments.tenantId, ctx.tenantId), eq(inboxAttachments.messageId, row.id))) + .orderBy(inboxAttachments.createdAt) + const body: InboxMessage = { + ...toSummary(row), + messageIdHeader: row.messageIdHeader, + auth: { spf: null, dkim: null, dmarc: null, ...(row.auth as Partial) }, + textBody: row.textBody, + hasHtmlBody: row.htmlKey !== null, + confirmation: (row.confirmation as InboxConfirmation | null) ?? null, + error: row.error, + attachments: attachments.map( + (a): InboxAttachment => ({ + id: a.id, + filename: a.filename, + contentType: a.contentType, + size: a.size, + sha256: a.sha256, + inline: a.inline, + }) + ), + } + return c.json(body) +}) + +inboxRouter.get('/messages/:id/raw', async c => { + const ctx: RequestCtx = requestCtx(c) + ctx.guard('read', INBOX_MESSAGE_SUBJECT) + const row = await loadMessage(ctx, ctx.uuid('id')) + const object = await ctx.storage().get(row.rawKey) + if (!object) ctx.notFound('The raw message is missing', 'inbox_raw_missing') + return download(object.body, 'message/rfc822', `${row.id}.eml`, object.size) +}) + +inboxRouter.get('/messages/:id/html', async c => { + const ctx: RequestCtx = requestCtx(c) + ctx.guard('read', INBOX_MESSAGE_SUBJECT) + const row = await loadMessage(ctx, ctx.uuid('id')) + if (!row.htmlKey) ctx.notFound('This message has no HTML body', 'inbox_html_missing') + const object = await ctx.storage().get(row.htmlKey) + if (!object) ctx.notFound('The HTML body is missing', 'inbox_html_missing') + return download(object.body, 'text/html; charset=utf-8', `${row.id}.html`, object.size) +}) + +inboxRouter.get('/attachments/:id', async c => { + const ctx: RequestCtx = requestCtx(c) + ctx.guard('read', INBOX_MESSAGE_SUBJECT) + const id = ctx.uuid('id') + const [row] = await ctx.db + .select() + .from(inboxAttachments) + .where(and(eq(inboxAttachments.tenantId, ctx.tenantId), eq(inboxAttachments.id, id))) + .limit(1) + if (!row) ctx.notFound('Attachment not found', 'inbox_attachment_not_found') + const object = await ctx.storage().get(row.storageKey) + if (!object) ctx.notFound('The attachment is missing', 'inbox_attachment_missing') + return download(object.body, row.contentType, row.filename, object.size) +}) + +inboxRouter.post('/messages/:id/reprocess', async c => { + const ctx: RequestCtx = requestCtx(c) + ctx.guard('update', INBOX_MESSAGE_SUBJECT) + const row = await loadMessage(ctx, ctx.uuid('id')) + const job = await ctx.enqueue({ + type: INBOX_PARSE_JOB, + payload: { tenantId: ctx.tenantId, messageId: row.id }, + }) + return c.json({ jobId: job.id, type: INBOX_PARSE_JOB }, 202) +}) + +inboxRouter.delete('/messages/:id', async c => { + const ctx: RequestCtx = requestCtx(c) + ctx.guard('delete', INBOX_MESSAGE_SUBJECT) + const row = await loadMessage(ctx, ctx.uuid('id')) + await ctx.db + .delete(inboxMessages) + .where(and(eq(inboxMessages.tenantId, ctx.tenantId), eq(inboxMessages.id, row.id))) + // The row is the truth; the objects follow. A failure here leaves orphans under the tenant's + // prefix, which the tenant purge still removes. + const storage = ctx.storage() + const objects = await storage.list(`${messagePrefix(ctx.tenantId, row.id)}/`) + if (objects.length > 0) await storage.deleteMany(objects.map(o => o.key)) + ctx.defer(() => + recordActivity(ctx.db, { + tenantId: ctx.tenantId, + userId: ctx.userId, + type: 'inbox.message.deleted', + subjectType: INBOX_MESSAGE_SUBJECT, + subjectId: row.id, + metadata: { fromAddress: row.fromAddress }, + }) + ) + ctx.nudge(INBOX_MESSAGES_ENTITY, row.id) + return c.body(null, 204) +}) diff --git a/plugins/inbox/apps/web/src/plugins/inbox/api/settings.ts b/plugins/inbox/apps/web/src/plugins/inbox/api/settings.ts new file mode 100644 index 0000000..178e824 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/api/settings.ts @@ -0,0 +1,57 @@ +/** + * `inbox_settings`: read, shape and upsert one organisation's row. The local part is unique across + * tenants (it is how a message finds its organisation), so a taken one is a 409 at the route rather + * than a raw unique violation. + */ +import type { InboxSettings, UpdateInboxSettings } from '@rocketflare/shared/plugins/inbox/index' +import { eq } from 'drizzle-orm' +import type { Database } from '@/plugins/api' +import { type InboxSettingsRow, inboxSettings } from '../db/schema' + +export async function loadSettings( + db: Database, + tenantId: string +): Promise { + const [row] = await db + .select() + .from(inboxSettings) + .where(eq(inboxSettings.tenantId, tenantId)) + .limit(1) + return row +} + +export function toSettings(row: InboxSettingsRow | undefined): InboxSettings { + return { + enabled: row?.enabled ?? false, + localPart: row?.localPart ?? null, + allowedSenders: row?.allowedSenders ?? [], + requireAuthentication: row?.requireAuthentication ?? true, + updatedAt: row?.updatedAt ?? null, + } +} + +export async function saveSettings( + db: Database, + tenantId: string, + userId: string | null, + body: UpdateInboxSettings +): Promise { + const values = { + ...(body.enabled !== undefined && { enabled: body.enabled }), + ...(body.localPart !== undefined && { localPart: body.localPart }), + ...(body.allowedSenders !== undefined && { + allowedSenders: [...new Set(body.allowedSenders)], + }), + ...(body.requireAuthentication !== undefined && { + requireAuthentication: body.requireAuthentication, + }), + updatedByUserId: userId, + } + const [row] = await db + .insert(inboxSettings) + .values({ tenantId, ...values }) + .onConflictDoUpdate({ target: inboxSettings.tenantId, set: values }) + .returning() + if (!row) throw new Error('inbox_settings: upsert returned no row') + return row +} diff --git a/plugins/inbox/apps/web/src/plugins/inbox/db/schema/inbox.ts b/plugins/inbox/apps/web/src/plugins/inbox/db/schema/inbox.ts new file mode 100644 index 0000000..5142515 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/db/schema/inbox.ts @@ -0,0 +1,118 @@ +/** + * The inbox plugin's tables (D31). Kit conventions throughout: `tenantRef()` first, `timestamps()`, + * every index led by `tenant_id`, `tenantIsolation()` on each table. + * + * - `inbox_settings` — one row per organisation: the local part it receives on (UNIQUE across + * tenants — it is how an inbound message finds its organisation, so that one lookup is the + * plugin's only unscoped query), the sender allow-list and whether authentication is required. + * - `inbox_messages` — one row per accepted message. The raw RFC 822 bytes live in R2 under + * `tenants//inbox//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. + */ +import { sql } from 'drizzle-orm' +import { + boolean, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core' +import { tenantIsolation, tenantRef, tenants, timestamps, users } from '../../../../db/schema/kit' + +export const inboxSettings = pgTable( + 'inbox_settings', + { + id: uuid('id').primaryKey().defaultRandom(), + tenantId: tenantRef(tenants), + enabled: boolean('enabled').notNull().default(false), + localPart: text('local_part'), + allowedSenders: text('allowed_senders').array().notNull().default(sql`'{}'::text[]`), + requireAuthentication: boolean('require_authentication').notNull().default(true), + updatedByUserId: uuid('updated_by_user_id').references(() => users.id, { + onDelete: 'set null', + }), + ...timestamps(), + }, + table => [ + uniqueIndex('inbox_settings_tenant_uidx').on(table.tenantId), + uniqueIndex('inbox_settings_local_part_uidx').on(table.localPart), + tenantIsolation('inbox_settings'), + ] +) + +export const inboxMessages = pgTable( + 'inbox_messages', + { + id: uuid('id').primaryKey().defaultRandom(), + tenantId: tenantRef(tenants), + status: text('status').notNull().default('received'), + kind: text('kind').notNull().default('message'), + envelopeFrom: text('envelope_from').notNull(), + fromAddress: text('from_address'), + fromName: text('from_name'), + toAddress: text('to_address').notNull(), + subaddress: text('subaddress'), + subject: text('subject'), + messageIdHeader: text('message_id_header'), + sentAt: timestamp('sent_at', { withTimezone: true }), + receivedAt: timestamp('received_at', { withTimezone: true }).notNull().defaultNow(), + rawKey: text('raw_key').notNull(), + rawSize: integer('raw_size').notNull(), + htmlKey: text('html_key'), + textBody: text('text_body'), + /** `{ spf, dkim, dmarc }` from `Authentication-Results`. */ + auth: jsonb('auth').notNull().default({}), + /** `{ provider, code, url }` for a forwarding confirmation, else null. */ + confirmation: jsonb('confirmation'), + attachmentCount: integer('attachment_count').notNull().default(0), + error: text('error'), + parsedAt: timestamp('parsed_at', { withTimezone: true }), + ...timestamps(), + }, + table => [ + index('inbox_messages_tenant_received_idx').on(table.tenantId, table.receivedAt), + index('inbox_messages_tenant_status_idx').on(table.tenantId, table.status), + uniqueIndex('inbox_messages_tenant_message_id_uidx') + .on(table.tenantId, table.messageIdHeader) + .where(sql`${table.messageIdHeader} IS NOT NULL`), + tenantIsolation('inbox_messages'), + ] +) + +export const inboxAttachments = pgTable( + 'inbox_attachments', + { + id: uuid('id').primaryKey().defaultRandom(), + tenantId: tenantRef(tenants), + messageId: uuid('message_id') + .notNull() + .references(() => inboxMessages.id, { onDelete: 'cascade' }), + filename: text('filename').notNull(), + contentType: text('content_type').notNull(), + size: integer('size').notNull(), + sha256: text('sha256').notNull(), + storageKey: text('storage_key').notNull(), + inline: boolean('inline').notNull().default(false), + ...timestamps(), + }, + table => [ + index('inbox_attachments_tenant_message_idx').on(table.tenantId, table.messageId), + uniqueIndex('inbox_attachments_message_sha_uidx').on( + table.tenantId, + table.messageId, + table.sha256 + ), + tenantIsolation('inbox_attachments'), + ] +) + +export type InboxSettingsRow = typeof inboxSettings.$inferSelect +export type InboxMessageRow = typeof inboxMessages.$inferSelect +export type NewInboxMessageRow = typeof inboxMessages.$inferInsert +export type InboxAttachmentRow = typeof inboxAttachments.$inferSelect diff --git a/plugins/inbox/apps/web/src/plugins/inbox/db/schema/index.ts b/plugins/inbox/apps/web/src/plugins/inbox/db/schema/index.ts new file mode 100644 index 0000000..5abec76 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/db/schema/index.ts @@ -0,0 +1,2 @@ +/** What `apps/web/src/plugins/schema.ts` re-exports for this plugin. */ +export * from './inbox' diff --git a/plugins/inbox/apps/web/src/plugins/inbox/index.ts b/plugins/inbox/apps/web/src/plugins/inbox/index.ts new file mode 100644 index 0000000..b6e5128 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/index.ts @@ -0,0 +1,71 @@ +/** + * `inbox` — the server entry (D31). Read top to bottom: the catch-all email handler, the one job, + * the authed mount, two read-only agent tools, and the grants. What it exports beyond the + * `ServerPlugin` is the consumer contract — `InboxConsumer`, `InboxParsedMessage` and the two + * readers — which is how another plugin (a receipts matcher, a ticketing bridge) builds on mail. + * + * The handler claims `*`: Email Routing decides which addresses reach the Worker at all, and this + * plugin decides which ORGANISATION an address belongs to (`inbox_settings.local_part`). A plugin + * that wants a dedicated address of its own claims that local part exactly, which wins over `*`. + */ +import { + INBOX_MESSAGE_SUBJECT, + INBOX_PARSE_JOB, + INBOX_SETTINGS_SUBJECT, + inboxShared, +} from '@rocketflare/shared/plugins/inbox/index' +import type { ServerPlugin, Tool } from '@/plugins/api' +import { EMAIL_CATCH_ALL, emailCtx, toolCtx } from '@/plugins/api' +import { inboxRouter } from './api/routes' +import { handleInboxParse } from './jobs/parse' +import { receiveMessage } from './services/receive' +import { getInboxMessageTool, listInboxMessagesTool } from './tools/inbox-tools' + +export type { InboxAttachmentRef, InboxConsumer, InboxParsedMessage } from './services/parse' +export { readInboxAttachment, readInboxHtml } from './services/parse' + +export const inboxServer = { + shared: inboxShared, + mounts: [['/api/inbox', inboxRouter]], + emailHandlers: [ + { + localPart: EMAIL_CATCH_ALL, + name: 'inbox', + handle: async (message, raw) => { + const ctx = emailCtx(raw) + await receiveMessage(message, ctx, { + authenticationOptional: ctx.config.APP_ENV === 'development', + }) + }, + }, + ], + jobHandlers: { [INBOX_PARSE_JOB]: handleInboxParse }, + agentTools: raw => { + const ctx = toolCtx(raw) + return [listInboxMessagesTool(ctx) as Tool, getInboxMessageTool(ctx) as Tool] + }, + /** Additive, over this plugin's own subjects: admins configure and tidy, members read. */ + grants: { + owner: can => { + can('manage', INBOX_MESSAGE_SUBJECT) + can('manage', INBOX_SETTINGS_SUBJECT) + }, + admin: can => { + can('manage', INBOX_MESSAGE_SUBJECT) + can('manage', INBOX_SETTINGS_SUBJECT) + }, + support: can => { + can('read', INBOX_MESSAGE_SUBJECT) + can('read', INBOX_SETTINGS_SUBJECT) + }, + member: can => { + can('read', INBOX_MESSAGE_SUBJECT) + can('read', INBOX_SETTINGS_SUBJECT) + }, + }, + unscopedAllowlist: { + 'src/plugins/inbox/services/receive.ts': + 'settingsForLocalPart resolves the organisation FROM the recipient address — an inbound ' + + 'message names no tenant, and the unique index on local_part makes the answer one row.', + }, +} satisfies ServerPlugin diff --git a/plugins/inbox/apps/web/src/plugins/inbox/jobs/parse.ts b/plugins/inbox/apps/web/src/plugins/inbox/jobs/parse.ts new file mode 100644 index 0000000..157876e --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/jobs/parse.ts @@ -0,0 +1,15 @@ +/** + * `inbox.parse` — registered in `ServerPlugin.jobHandlers`. The work is `services/parse.ts`; this + * is the adapter. A throw (R2 or Postgres unavailable, a malformed consumer contribution) retries + * with the kit's backoff, which is safe because the parse is idempotent. + */ +import type { JobOf } from '@rocketflare/shared/jobs' +import type { INBOX_PARSE_JOB } from '@rocketflare/shared/plugins/inbox/index' +import type { JobHandler } from '@/plugins/api' +import { jobCtx } from '@/plugins/api' +import { parseMessage } from '../services/parse' + +export const handleInboxParse: JobHandler = ( + job: JobOf, + ctx +) => parseMessage(jobCtx(ctx), job.payload.tenantId, job.payload.messageId) diff --git a/plugins/inbox/apps/web/src/plugins/inbox/plugin.json b/plugins/inbox/apps/web/src/plugins/inbox/plugin.json new file mode 100644 index 0000000..f048fc3 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/plugin.json @@ -0,0 +1,49 @@ +{ + "id": "inbox", + "label": "Inbox", + "version": "3.5.1", + "repo": "https://github.com/rocketflare-dev/rocketflare-plugins.git", + "minKit": "0.22.0", + "anchor": "apps/web/src/plugins/inbox/plugin.json", + "paths": [ + "apps/web/src/plugins/inbox/**", + "packages/shared/src/plugins/inbox/**", + "apps/cli/src/plugins/inbox/**" + ], + "registries": [ + "apps/web/src/plugins/server.ts", + "apps/web/src/plugins/ui.ts", + "apps/web/src/plugins/schema.ts", + "packages/shared/src/plugins/index.ts", + "apps/cli/src/plugins/index.ts" + ], + "requires": { + "surfaces": [], + "plugins": [] + }, + "uses": {}, + "dependencies": { + "apps/web": { + "postal-mime": "^4.1.4" + }, + "packages/shared": {}, + "apps/cli": {} + }, + "bindings": [], + "crons": [], + "apiPrefixes": [], + "coreEdits": [], + "vars": [], + "workerExports": [], + "skills": ["inbox"], + "schema": { + "tables": ["inbox_settings", "inbox_messages", "inbox_attachments"], + "rlsExcluded": [] + }, + "migrations": [ + "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); RLS policy", + "inbox_attachments — one row per kept part, cascading with its message, bytes in R2; unique (tenant_id, message_id, sha256); RLS policy" + ], + "subdir": "plugins/inbox" +} diff --git a/plugins/inbox/apps/web/src/plugins/inbox/services/parse.ts b/plugins/inbox/apps/web/src/plugins/inbox/services/parse.ts new file mode 100644 index 0000000..e254e4b --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/services/parse.ts @@ -0,0 +1,290 @@ +/** + * `inbox.parse`: the raw message in R2 → bodies, attachments, a confirmation code, and a call to + * every consumer plugin. Safe to re-run (the reprocess route does exactly that): attachments are + * unique per (message, sha256), the row is overwritten, and consumers are required to be + * idempotent too. + */ +import { + INBOX_EXTENSION_KEYS, + INBOX_MESSAGES_ENTITY, + INBOX_TEXT_BODY_MAX, + type InboxConfirmation, +} from '@rocketflare/shared/plugins/inbox/index' +import { and, eq } from 'drizzle-orm' +import PostalMime from 'postal-mime' +import { z } from 'zod' +import type { JobCtx } from '@/plugins/api' +import { extensions } from '@/plugins/api/peers' +import { type InboxMessageRow, inboxAttachments, inboxMessages } from '../db/schema' +import { messagePrefix } from './receive' +import { + extractConfirmation, + extractForwardedOriginal, + isForwardSubject, + keepPart, + safeFilename, +} from './rules' + +/** One stored attachment, as a consumer sees it. Read the bytes with `readInboxAttachment`. */ +export interface InboxAttachmentRef { + id: string + filename: string + contentType: string + size: number + sha256: string + inline: boolean +} + +/** What a consumer is handed for each parsed message. */ +export interface InboxParsedMessage { + tenantId: string + messageId: string + envelopeFrom: string + fromAddress: string | null + fromName: string | null + toAddress: string + subaddress: string | null + subject: string | null + sentAt: Date | null + receivedAt: Date + textBody: string | null + hasHtmlBody: boolean + attachments: readonly InboxAttachmentRef[] +} + +/** + * A plugin that wants inbound mail contributes one of these under `inbox:consumers` + * (`extensions: inboxExtensions({ consumers: [myConsumer] })`). Called for every parsed message of + * kind `message` (never for forwarding confirmations), after its attachments are stored. Keep it + * quick — enqueue your own job — and idempotent: a reprocess calls it again. A throw is logged and + * recorded on the message; it does not stop the other consumers. + */ +export interface InboxConsumer { + id: string + onMessage(message: InboxParsedMessage, ctx: JobCtx): Promise +} + +const consumerSchema = z.object({ + id: z.string().min(1), + onMessage: z.custom(v => typeof v === 'function'), +}) + +/** Every installed consumer, narrowed — a malformed contribution fails loudly, by plugin. */ +export function inboxConsumers(): InboxConsumer[] { + return extensions(INBOX_EXTENSION_KEYS.consumers).map(raw => { + const parsed = consumerSchema.safeParse(raw) + if (!parsed.success) { + throw new Error(`inbox: a contributed consumer is malformed: ${parsed.error.message}`) + } + return parsed.data as InboxConsumer + }) +} + +async function sha256Hex(bytes: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes) + return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('') +} + +function toArrayBuffer(content: ArrayBuffer | Uint8Array | string): ArrayBuffer { + if (typeof content === 'string') return new TextEncoder().encode(content).buffer as ArrayBuffer + if (content instanceof Uint8Array) { + return content.buffer.slice( + content.byteOffset, + content.byteOffset + content.byteLength + ) as ArrayBuffer + } + return content +} + +export function toParsedMessage( + row: InboxMessageRow, + attachments: readonly InboxAttachmentRef[] +): InboxParsedMessage { + return { + tenantId: row.tenantId, + messageId: row.id, + envelopeFrom: row.envelopeFrom, + fromAddress: row.fromAddress, + fromName: row.fromName, + toAddress: row.toAddress, + subaddress: row.subaddress, + subject: row.subject, + sentAt: row.sentAt, + receivedAt: row.receivedAt, + textBody: row.textBody, + hasHtmlBody: row.htmlKey !== null, + attachments, + } +} + +export async function parseMessage( + ctx: JobCtx, + tenantId: string, + messageId: string, + consumers: readonly InboxConsumer[] = inboxConsumers() +): Promise { + const [row] = await ctx.db + .select() + .from(inboxMessages) + .where(and(eq(inboxMessages.tenantId, tenantId), eq(inboxMessages.id, messageId))) + .limit(1) + if (!row) { + // Deleted between receive and parse: nothing to do, and retrying cannot bring it back. + ctx.logger.warn({ tenantId, messageId }, 'inbox.parse: message no longer exists') + return + } + + const storage = ctx.storage() + const object = await storage.get(row.rawKey) + if (!object) { + await ctx.db + .update(inboxMessages) + .set({ status: 'failed', error: 'The raw message is missing from storage' }) + .where(and(eq(inboxMessages.tenantId, tenantId), eq(inboxMessages.id, messageId))) + return + } + const raw = await new Response(object.body).arrayBuffer() + const email = await PostalMime.parse(raw) + + const prefix = messagePrefix(tenantId, messageId) + let htmlKey: string | null = null + if (email.html) { + htmlKey = `${prefix}/body.html` + await storage.put(htmlKey, email.html, { contentType: 'text/html; charset=utf-8' }) + } + + const kept: InboxAttachmentRef[] = [] + for (const [index, part] of (email.attachments ?? []).entries()) { + const bytes = toArrayBuffer(part.content) + const contentType = (part.mimeType || 'application/octet-stream').toLowerCase() + const inline = part.disposition === 'inline' || Boolean(part.related) + if (!keepPart({ inline, contentType, size: bytes.byteLength })) continue + const sha256 = await sha256Hex(bytes) + const filename = safeFilename(part.filename, `attachment-${index + 1}`) + const attachmentId = crypto.randomUUID() + const storageKey = `${prefix}/attachments/${sha256.slice(0, 16)}-${filename}` + await storage.put(storageKey, bytes, { contentType }) + const [saved] = await ctx.db + .insert(inboxAttachments) + .values({ + id: attachmentId, + tenantId, + messageId, + filename, + contentType, + size: bytes.byteLength, + sha256, + storageKey, + inline, + }) + // A reprocess finds the same bytes: keep the existing row (and its id, which a consumer + // may already have recorded) rather than minting a second. + .onConflictDoUpdate({ + target: [inboxAttachments.tenantId, inboxAttachments.messageId, inboxAttachments.sha256], + set: { filename, contentType, storageKey, inline }, + }) + .returning() + if (saved) { + kept.push({ + id: saved.id, + filename: saved.filename, + contentType: saved.contentType, + size: saved.size, + sha256: saved.sha256, + inline: saved.inline, + }) + } + } + + const text = email.text ?? null + let confirmation = row.confirmation as InboxConfirmation | null + if (row.kind === 'confirmation' && confirmation) { + confirmation = extractConfirmation(confirmation.provider, row.subject, text) + } + + // A hand-forwarded message names its real sender in the quoted header block. + let fromAddress = email.from?.address?.toLowerCase() ?? row.fromAddress + let fromName = email.from?.name || row.fromName + if (row.kind === 'message' && isForwardSubject(email.subject ?? row.subject)) { + const original = extractForwardedOriginal(text) + if (original) { + fromAddress = original.fromAddress + fromName = original.fromName ?? fromName + } + } + + const [updated] = await ctx.db + .update(inboxMessages) + .set({ + status: 'parsed', + fromAddress, + fromName, + subject: email.subject ?? row.subject, + sentAt: email.date ? new Date(email.date) : row.sentAt, + textBody: text ? text.slice(0, INBOX_TEXT_BODY_MAX) : null, + htmlKey, + confirmation, + attachmentCount: kept.length, + error: null, + parsedAt: new Date(), + }) + .where(and(eq(inboxMessages.tenantId, tenantId), eq(inboxMessages.id, messageId))) + .returning() + + if (updated && updated.kind === 'message') { + const failures: string[] = [] + const parsed = toParsedMessage(updated, kept) + for (const consumer of consumers) { + try { + await consumer.onMessage(parsed, ctx) + } catch (err) { + ctx.logger.error({ err, consumer: consumer.id, messageId }, 'inbox: consumer failed') + failures.push(`${consumer.id}: ${err instanceof Error ? err.message : String(err)}`) + } + } + if (failures.length > 0) { + await ctx.db + .update(inboxMessages) + .set({ error: failures.join('; ').slice(0, 2000) }) + .where(and(eq(inboxMessages.tenantId, tenantId), eq(inboxMessages.id, messageId))) + } + } + await ctx.nudge(tenantId, INBOX_MESSAGES_ENTITY, messageId) +} + +/** The bytes of one attachment, for a consumer (or the download route). Null when gone. */ +export async function readInboxAttachment( + ctx: Pick, + tenantId: string, + attachmentId: string +): Promise<{ bytes: ArrayBuffer; filename: string; contentType: string } | null> { + const [row] = await ctx.db + .select() + .from(inboxAttachments) + .where(and(eq(inboxAttachments.tenantId, tenantId), eq(inboxAttachments.id, attachmentId))) + .limit(1) + if (!row) return null + const object = await ctx.storage().get(row.storageKey) + if (!object) return null + return { + bytes: await new Response(object.body).arrayBuffer(), + filename: row.filename, + contentType: row.contentType, + } +} + +/** The HTML body of one message, for a consumer that renders it (a receipt with no PDF). */ +export async function readInboxHtml( + ctx: Pick, + tenantId: string, + messageId: string +): Promise { + const [row] = await ctx.db + .select({ htmlKey: inboxMessages.htmlKey }) + .from(inboxMessages) + .where(and(eq(inboxMessages.tenantId, tenantId), eq(inboxMessages.id, messageId))) + .limit(1) + if (!row?.htmlKey) return null + const object = await ctx.storage().get(row.htmlKey) + return object ? new Response(object.body).text() : null +} diff --git a/plugins/inbox/apps/web/src/plugins/inbox/services/receive.ts b/plugins/inbox/apps/web/src/plugins/inbox/services/receive.ts new file mode 100644 index 0000000..b56abc1 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/services/receive.ts @@ -0,0 +1,168 @@ +/** + * The `email()` half: decide, store, queue — and nothing slower. Parsing a 20 MB PDF-laden message + * inside the Email Worker would spend its CPU budget on a sender who is waiting for an SMTP answer, + * so the raw bytes go to R2 and `inbox.parse` does the rest. + * + * Order matters for what a failure means. Everything that can REFUSE (unknown address, sender, + * authentication) runs before any write and answers `setReject` — a permanent bounce the forwarding + * mailbox will show its owner. Everything after that is storage, and a throw there is rethrown by + * the kit as a temporary failure, so the sender retries rather than the message being lost. + */ +import { + INBOX_MESSAGES_ENTITY, + INBOX_PARSE_JOB, + type InboxConfirmation, +} from '@rocketflare/shared/plugins/inbox/index' +import { and, eq } from 'drizzle-orm' +import type { Database, EmailCtx } from '@/plugins/api' +import { emailLocalPart, emailSubaddress } from '@/plugins/api' +import { type InboxSettingsRow, inboxMessages, inboxSettings } from '../db/schema' +import { acceptMessage, parseAuthenticationResults } from './rules' + +/** The R2 prefix for one message: raw bytes, HTML body and attachments all live beneath it. */ +export function messagePrefix(tenantId: string, messageId: string): string { + return `tenants/${tenantId}/inbox/${messageId}` +} + +/** + * The organisation that receives on this local part. The plugin's one cross-tenant read: a message + * names an address, not a tenant, and the unique index on `local_part` makes the answer one row. + */ +export async function settingsForLocalPart( + db: Database, + localPart: string +): Promise { + const [row] = await db + .select() + .from(inboxSettings) + .where(eq(inboxSettings.localPart, localPart)) + .limit(1) + return row +} + +/** `"Name" ` → `{ address, name }`; null when no address can be read. */ +export function parseAddressHeader( + value: string | null +): { address: string; name: string | null } | null { + if (!value) return null + const angle = /^(.*?)<([^>]+@[^>]+)>/.exec(value) + if (angle?.[2]) { + const name = angle[1]?.trim().replace(/^"|"$/g, '') || null + return { address: angle[2].trim().toLowerCase(), name } + } + const bare = /([^\s<>",]+@[^\s<>",]+)/.exec(value) + return bare?.[1] ? { address: bare[1].toLowerCase(), name: null } : null +} + +/** `Date:` → a Date, or null when absent or unparseable. */ +function headerDate(value: string | null): Date | null { + if (!value) return null + const d = new Date(value) + return Number.isNaN(d.getTime()) ? null : d +} + +/** Why a message was refused, in the words the forwarding mailbox will show its owner. */ +const REJECT_REASONS = { + address: 'Unknown recipient', + disabled: 'This inbox is not accepting mail', + sender: 'Sender not allowed', + authentication: 'Sender could not be authenticated', +} as const + +export type ReceiveOutcome = + | { status: 'rejected'; reason: keyof typeof REJECT_REASONS } + | { status: 'duplicate'; messageId: string } + | { status: 'stored'; messageId: string; tenantId: string } + +/** + * Take one message. Exported so tests drive it with a fake `ForwardableEmailMessage`; the + * registered handler in `index.ts` is a one-line adapter. + */ +export async function receiveMessage( + message: ForwardableEmailMessage, + ctx: EmailCtx, + options: { authenticationOptional?: boolean } = {} +): Promise { + const reject = (reason: keyof typeof REJECT_REASONS): ReceiveOutcome => { + // Addresses and the reason only — never a subject or body in a log line. + ctx.logger.warn({ to: message.to, from: message.from, reason }, 'inbox: message refused') + message.setReject(REJECT_REASONS[reason]) + return { status: 'rejected', reason } + } + + const settings = await settingsForLocalPart(ctx.db, emailLocalPart(message.to)) + if (!settings) return reject('address') + + const headerFrom = parseAddressHeader(message.headers.get('from')) + const auth = parseAuthenticationResults( + [message.headers.get('authentication-results')].filter((h): h is string => Boolean(h)) + ) + const verdict = acceptMessage({ + enabled: settings.enabled, + allowedSenders: settings.allowedSenders, + // Local `wrangler dev` deliveries carry no Authentication-Results at all. + requireAuthentication: settings.requireAuthentication && !options.authenticationOptional, + envelopeFrom: message.from, + headerFrom: headerFrom?.address ?? null, + auth, + }) + if (!verdict.accept) return reject(verdict.reason) + + const tenantId = settings.tenantId + const messageIdHeader = message.headers.get('message-id')?.trim() || null + if (messageIdHeader) { + const [existing] = await ctx.db + .select({ id: inboxMessages.id }) + .from(inboxMessages) + .where( + and( + eq(inboxMessages.tenantId, tenantId), + eq(inboxMessages.messageIdHeader, messageIdHeader) + ) + ) + .limit(1) + if (existing) { + ctx.logger.info({ messageId: existing.id }, 'inbox: duplicate delivery ignored') + return { status: 'duplicate', messageId: existing.id } + } + } + + const id = crypto.randomUUID() + const rawKey = `${messagePrefix(tenantId, id)}/raw.eml` + const raw = await new Response(message.raw).arrayBuffer() + await ctx.storage().put(rawKey, raw, { contentType: 'message/rfc822' }) + + const confirmation: InboxConfirmation | null = verdict.confirmationProvider + ? { provider: verdict.confirmationProvider, code: null, url: null } + : null + const inserted = await ctx.db + .insert(inboxMessages) + .values({ + id, + tenantId, + kind: confirmation ? 'confirmation' : 'message', + envelopeFrom: message.from.toLowerCase(), + fromAddress: headerFrom?.address ?? null, + fromName: headerFrom?.name ?? null, + toAddress: message.to.toLowerCase(), + subaddress: emailSubaddress(message.to), + subject: message.headers.get('subject'), + messageIdHeader, + sentAt: headerDate(message.headers.get('date')), + rawKey, + rawSize: raw.byteLength, + auth, + confirmation, + }) + // Two deliveries racing past the lookup above: the unique index decides, the loser is a no-op. + .onConflictDoNothing() + .returning({ id: inboxMessages.id }) + if (inserted.length === 0) { + await ctx.storage().delete(rawKey) + return { status: 'duplicate', messageId: id } + } + + await ctx.enqueue({ type: INBOX_PARSE_JOB, payload: { tenantId, messageId: id } }) + await ctx.nudge(tenantId, INBOX_MESSAGES_ENTITY, id) + return { status: 'stored', messageId: id, tenantId } +} diff --git a/plugins/inbox/apps/web/src/plugins/inbox/services/rules.ts b/plugins/inbox/apps/web/src/plugins/inbox/services/rules.ts new file mode 100644 index 0000000..9105399 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/services/rules.ts @@ -0,0 +1,185 @@ +/** + * The inbox's decisions as pure functions — who may send, what Cloudflare said about them, whether + * a message is a forwarding confirmation, and who REALLY sent a forwarded message. No I/O, so every + * rule is a table test (`tests/config/rules.test.ts`). + */ +import type { InboxAuth, InboxConfirmation } from '@rocketflare/shared/plugins/inbox/index' + +/** `Me+Tag@Example.com` → `me@example.com`: lower-cased, subaddress dropped. */ +export function normaliseAddress(address: string): string { + const trimmed = address.trim().toLowerCase() + const at = trimmed.lastIndexOf('@') + if (at === -1) return trimmed + const local = trimmed.slice(0, at) + const plus = local.indexOf('+') + return `${plus === -1 ? local : local.slice(0, plus)}${trimmed.slice(at)}` +} + +/** `a@b.com` matches `a@b.com` or `@b.com`; `+tags` are ignored on both sides. */ +export function senderMatches(address: string, allowList: readonly string[]): boolean { + const normal = normaliseAddress(address) + const domain = normal.slice(normal.lastIndexOf('@')) + return allowList.some(entry => { + const e = entry.trim().toLowerCase() + return e.startsWith('@') ? domain === e : normaliseAddress(e) === normal + }) +} + +const AUTH_METHODS = ['spf', 'dkim', 'dmarc'] as const + +/** + * The verdicts in `Authentication-Results` (RFC 8601) — the FIRST result per method across every + * such header, which is the one the receiving edge (Cloudflare) prepended. `null` where absent. + */ +export function parseAuthenticationResults(headers: readonly string[]): InboxAuth { + const auth: InboxAuth = { spf: null, dkim: null, dmarc: null } + for (const header of headers) { + for (const method of AUTH_METHODS) { + if (auth[method] !== null) continue + const match = new RegExp(`\\b${method}=([a-z]+)`, 'i').exec(header) + if (match?.[1]) auth[method] = match[1].toLowerCase() + } + } + return auth +} + +/** Any one of SPF, DKIM or DMARC passing. Forwarded mail usually passes SPF for the forwarder. */ +export function isAuthenticated(auth: InboxAuth): boolean { + return auth.spf === 'pass' || auth.dkim === 'pass' || auth.dmarc === 'pass' +} + +/** Senders of forwarding confirmations — accepted before an allow-list exists, so the code shows. */ +const CONFIRMATION_SENDERS: Record = { + 'forwarding-noreply@google.com': 'gmail', + '@proton.me': 'proton', + '@protonmail.com': 'proton', + '@protonmail.ch': 'proton', +} + +/** Which provider a confirmation sender belongs to, or null for everyone else. */ +export function confirmationProvider(envelopeFrom: string): InboxConfirmation['provider'] | null { + for (const [entry, provider] of Object.entries(CONFIRMATION_SENDERS)) { + if (senderMatches(envelopeFrom, [entry])) return provider + } + return null +} + +export type AcceptVerdict = + | { accept: true; confirmationProvider: InboxConfirmation['provider'] | null } + | { accept: false; reason: 'disabled' | 'sender' | 'authentication' } + +/** + * Whether to take a message at all. A forwarding confirmation is accepted from its provider even + * before the allow-list names anybody (that is the only way to see the code), but it still has to + * authenticate — anyone can claim to be Google. Everything else needs an allow-listed envelope + * sender OR `From:` header, and authentication when the organisation requires it. + */ +export function acceptMessage(input: { + enabled: boolean + allowedSenders: readonly string[] + requireAuthentication: boolean + envelopeFrom: string + headerFrom: string | null + auth: InboxAuth +}): AcceptVerdict { + const authenticated = isAuthenticated(input.auth) + const allowed = + senderMatches(input.envelopeFrom, input.allowedSenders) || + (input.headerFrom !== null && senderMatches(input.headerFrom, input.allowedSenders)) + const provider = allowed ? null : confirmationProvider(input.envelopeFrom) + if (provider) { + return authenticated + ? { accept: true, confirmationProvider: provider } + : { accept: false, reason: 'authentication' } + } + if (!input.enabled) return { accept: false, reason: 'disabled' } + if (!allowed) return { accept: false, reason: 'sender' } + if (input.requireAuthentication && !authenticated) { + return { accept: false, reason: 'authentication' } + } + return { accept: true, confirmationProvider: null } +} + +/** + * The code and link in a forwarding-confirmation message. Gmail puts the code in the subject + * (`(#123456789) Gmail Forwarding Confirmation`) and the body (`Confirmation code: 123456789`), + * and the link on `mail-settings.google.com` / `mail.google.com/mail/vf-`; Proton sends a link. + */ +export function extractConfirmation( + provider: InboxConfirmation['provider'], + subject: string | null, + text: string | null +): InboxConfirmation { + const body = text ?? '' + const code = + /confirmation code:\s*(\d{6,})/i.exec(body)?.[1] ?? + /\(#(\d{6,})\)/.exec(subject ?? '')?.[1] ?? + null + const urls = body.match(/https:\/\/[^\s<>"')]+/g) ?? [] + const pattern = + provider === 'gmail' + ? /mail-settings\.google\.com|mail\.google\.com\/mail\/vf-/ + : provider === 'proton' + ? /proton\.me|protonmail\./ + : /./ + const url = urls.find(u => pattern.test(u)) ?? null + return { provider, code, url } +} + +/** Is this the subject line of a hand-forwarded message? */ +export function isForwardSubject(subject: string | null): boolean { + return /^\s*(fwd?|fw)\s*:/i.test(subject ?? '') +} + +export interface ForwardedOriginal { + fromAddress: string + fromName: string | null + subject: string | null +} + +/** + * The original sender of a HAND-forwarded message, read from the quoted header block mail clients + * insert (`---------- Forwarded message ---------` / `Begin forwarded message:` / Outlook's + * `From: … Sent: …`). Automatic forwarding keeps the original `From:` header, so this is only + * consulted when the subject says `Fwd:`. Null when no block can be found. + */ +export function extractForwardedOriginal(text: string | null): ForwardedOriginal | null { + if (!text) return null + const marker = + /-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message:|-{2,}\s*Original Message\s*-{2,}/i.exec( + text + ) + const block = marker ? text.slice(marker.index, marker.index + 2000) : text.slice(0, 2000) + const from = /^\s*>?\s*From:\s*(.+)$/im.exec(block)?.[1]?.trim() + if (!from) return null + const angle = /^(.*?)<([^>]+@[^>]+)>/.exec(from) + const bare = /([^\s<>"]+@[^\s<>"]+)/.exec(from) + const fromAddress = (angle?.[2] ?? bare?.[1])?.trim().toLowerCase() + if (!fromAddress) return null + const name = angle?.[1]?.trim().replace(/^"|"$/g, '') || null + const subject = /^\s*>?\s*Subject:\s*(.+)$/im.exec(block)?.[1]?.trim() ?? null + return { fromAddress, fromName: name, subject } +} + +/** A filename safe for an R2 key and a `Content-Disposition` header. */ +export function safeFilename(name: string | null | undefined, fallback: string): string { + const cleaned = (name ?? '') + .normalize('NFKD') + .replace(/[^\w.\- ]+/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120) + return cleaned || fallback +} + +/** + * Inline parts worth keeping: anything that is not an image, and images large enough not to be a + * logo or a tracking pixel. A receipt sent as an inline image is still kept; a 2 KB footer is not. + */ +export const INLINE_IMAGE_MIN_BYTES = 20_000 + +export function keepPart(part: { inline: boolean; contentType: string; size: number }): boolean { + if (!part.inline) return true + if (!part.contentType.startsWith('image/')) return true + return part.size >= INLINE_IMAGE_MIN_BYTES +} diff --git a/plugins/inbox/apps/web/src/plugins/inbox/tests/api/inbox.test.ts b/plugins/inbox/apps/web/src/plugins/inbox/tests/api/inbox.test.ts new file mode 100644 index 0000000..8c835cc --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/tests/api/inbox.test.ts @@ -0,0 +1,405 @@ +/** + * The inbox end to end on real Postgres and the in-memory R2/queue: settings, the receive + * decisions (address, sender, authentication, confirmations, duplicates), parsing a real MIME + * message into a text body and attachments, the consumer call, the download routes, and tenant + * isolation — another organisation can neither list, read, download nor delete. + */ +import { + createTestEnv, + createTestSession, + createTestTenant, + createTestUser, + json, + linkUserToTenant, + request, + sessionCookieHeader, + setupTestDatabase, + stubs, +} from '@testkit/integration' +import { makeEmailCtx, makeJobCtx } from '@testkit/unit' +import { and, eq } from 'drizzle-orm' +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { inboxAttachments, inboxMessages } from '../../db/schema' +import type { InboxConsumer, InboxParsedMessage } from '../../index' +import { parseMessage, readInboxAttachment } from '../../services/parse' +import { receiveMessage } from '../../services/receive' + +const db = setupTestDatabase() +const BASE = '/api/inbox' +const ORIGIN = { Origin: 'http://localhost:3000' } +const AUTH_PASS = 'mx.cloudflare.net; spf=pass smtp.mailfrom=me@gmail.com; dkim=pass; dmarc=pass' + +let tenantId: string +let localPart: string +let ownerCookie: Record +let memberCookie: Record +let otherCookie: Record + +/** A small multipart message: a text body and one PDF attachment. */ +function mime(opts: { + from?: string + subject?: string + messageId?: string + body?: string + pdf?: string +}): string { + const boundary = 'b1_inbox_test' + const pdf = Buffer.from(opts.pdf ?? '%PDF-1.4 receipt').toString('base64') + return [ + `From: ${opts.from ?? 'Stripe '}`, + 'To: me@gmail.com', + `Subject: ${opts.subject ?? 'Your receipt #1234'}`, + `Message-ID: ${opts.messageId ?? `<${crypto.randomUUID()}@stripe.com>`}`, + 'Date: Thu, 23 Oct 2026 10:00:00 +0000', + 'MIME-Version: 1.0', + `Content-Type: multipart/mixed; boundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/plain; charset=utf-8', + '', + opts.body ?? 'Thanks for your payment of £12.00.', + `--${boundary}`, + 'Content-Type: application/pdf; name="receipt.pdf"', + 'Content-Disposition: attachment; filename="receipt.pdf"', + 'Content-Transfer-Encoding: base64', + '', + pdf, + `--${boundary}--`, + '', + ].join('\r\n') +} + +/** The slice of `ForwardableEmailMessage` the plugin reads, built from a raw MIME string. */ +function message(raw: string, envelope: { from: string; to: string; auth?: string | null }) { + const headerBlock = raw.split('\r\n\r\n')[0] ?? '' + const headers = new Headers() + for (const line of headerBlock.split('\r\n')) { + const idx = line.indexOf(':') + if (idx > 0) headers.append(line.slice(0, idx).trim(), line.slice(idx + 1).trim()) + } + if (envelope.auth !== null) headers.set('Authentication-Results', envelope.auth ?? AUTH_PASS) + return { + from: envelope.from, + to: envelope.to, + headers, + raw: new Response(raw).body as ReadableStream, + rawSize: raw.length, + canBeForwarded: true, + setReject: vi.fn(), + forward: vi.fn(), + reply: vi.fn(), + } as unknown as ForwardableEmailMessage & { setReject: ReturnType } +} + +beforeAll(async () => { + tenantId = (await createTestTenant(db)).id + const otherTenantId = (await createTestTenant(db)).id + localPart = `receipts-${crypto.randomUUID().slice(0, 8)}` + + const owner = await createTestUser(db) + await linkUserToTenant(db, owner.id, tenantId, 'owner') + ownerCookie = { + ...sessionCookieHeader(await createTestSession(db, owner.id, tenantId)), + ...ORIGIN, + } + + const member = await createTestUser(db) + await linkUserToTenant(db, member.id, tenantId, 'member') + memberCookie = { + ...sessionCookieHeader(await createTestSession(db, member.id, tenantId)), + ...ORIGIN, + } + + const outsider = await createTestUser(db) + await linkUserToTenant(db, outsider.id, otherTenantId, 'owner') + otherCookie = { + ...sessionCookieHeader(await createTestSession(db, outsider.id, otherTenantId)), + ...ORIGIN, + } + + const res = await request( + `${BASE}/settings`, + { method: 'PUT', headers: ownerCookie }, + { json: { enabled: true, localPart, allowedSenders: ['me@gmail.com'] } } + ) + expect(res.status).toBe(200) +}) + +describe('settings', () => { + it('lets members read and only admins write', async () => { + const read = await request(`${BASE}/settings`, { headers: memberCookie }) + expect(await json(read)).toMatchObject({ + enabled: true, + localPart, + requireAuthentication: true, + }) + const write = await request( + `${BASE}/settings`, + { method: 'PUT', headers: memberCookie }, + { json: { enabled: false } } + ) + expect(write.status).toBe(403) + }) + + it('refuses a local part another organisation owns', async () => { + const res = await request( + `${BASE}/settings`, + { method: 'PUT', headers: otherCookie }, + { json: { localPart } } + ) + expect(res.status).toBe(409) + expect(await json(res)).toMatchObject({ code: 'inbox_local_part_taken' }) + }) + + it('refuses turning on without an address', async () => { + const res = await request( + `${BASE}/settings`, + { method: 'PUT', headers: otherCookie }, + { json: { enabled: true } } + ) + expect(res.status).toBe(400) + }) +}) + +describe('receive', () => { + it('rejects an address no organisation owns, before touching storage', async () => { + const env = createTestEnv() + const msg = message(mime({}), { from: 'me@gmail.com', to: 'nobody-here@example.test' }) + const outcome = await receiveMessage(msg, makeEmailCtx({ db, env })) + expect(outcome).toEqual({ status: 'rejected', reason: 'address' }) + expect(msg.setReject).toHaveBeenCalledWith('Unknown recipient') + expect(stubs(env).queue.messages).toHaveLength(0) + }) + + it('rejects a sender not on the allow-list, and one that failed authentication', async () => { + const env = createTestEnv() + const stranger = message(mime({ from: 'x@spam.test' }), { + from: 'x@spam.test', + to: `${localPart}@example.test`, + }) + expect(await receiveMessage(stranger, makeEmailCtx({ db, env }))).toEqual({ + status: 'rejected', + reason: 'sender', + }) + const forged = message(mime({}), { + from: 'me@gmail.com', + to: `${localPart}@example.test`, + auth: 'mx.cloudflare.net; spf=fail; dkim=none; dmarc=fail', + }) + expect(await receiveMessage(forged, makeEmailCtx({ db, env }))).toEqual({ + status: 'rejected', + reason: 'authentication', + }) + }) + + it('stores, queues and parses an allowed message, then calls consumers', async () => { + const env = createTestEnv() + const msg = message(mime({}), { + from: 'me+caf_=x@gmail.com', + to: `${localPart}+gmail@example.test`, + }) + const outcome = await receiveMessage(msg, makeEmailCtx({ db, env })) + expect(outcome.status).toBe('stored') + if (outcome.status !== 'stored') return + expect(msg.setReject).not.toHaveBeenCalled() + expect(stubs(env).queue.messages.map(m => (m.body as { type: string }).type)).toEqual([ + 'inbox.parse', + ]) + + const seen: InboxParsedMessage[] = [] + const consumer: InboxConsumer = { id: 'test', onMessage: async m => void seen.push(m) } + const jobCtx = makeJobCtx({ db, env }) + await parseMessage(jobCtx, tenantId, outcome.messageId, [consumer]) + + const [row] = await db + .select() + .from(inboxMessages) + .where(and(eq(inboxMessages.tenantId, tenantId), eq(inboxMessages.id, outcome.messageId))) + expect(row).toMatchObject({ + status: 'parsed', + kind: 'message', + fromAddress: 'receipts@stripe.com', + subaddress: 'gmail', + subject: 'Your receipt #1234', + attachmentCount: 1, + }) + expect(row?.textBody).toContain('£12.00') + + expect(seen).toHaveLength(1) + expect(seen[0]?.attachments).toEqual([ + expect.objectContaining({ filename: 'receipt.pdf', contentType: 'application/pdf' }), + ]) + const attachmentId = seen[0]?.attachments[0]?.id ?? '' + const file = await readInboxAttachment(jobCtx, tenantId, attachmentId) + expect(new TextDecoder().decode(file?.bytes)).toBe('%PDF-1.4 receipt') + + // A reprocess finds the same bytes and keeps the same attachment row. + await parseMessage(jobCtx, tenantId, outcome.messageId, [consumer]) + const attachments = await db + .select() + .from(inboxAttachments) + .where(eq(inboxAttachments.messageId, outcome.messageId)) + expect(attachments.map(a => a.id)).toEqual([attachmentId]) + expect(seen).toHaveLength(2) + }) + + it('stores a message delivered twice only once', async () => { + const env = createTestEnv() + const raw = mime({ messageId: `` }) + const to = `${localPart}@example.test` + const first = await receiveMessage( + message(raw, { from: 'me@gmail.com', to }), + makeEmailCtx({ db, env }) + ) + const second = await receiveMessage( + message(raw, { from: 'me@gmail.com', to }), + makeEmailCtx({ db, env }) + ) + expect(first.status).toBe('stored') + expect(second).toEqual({ + status: 'duplicate', + messageId: first.status === 'stored' ? first.messageId : '', + }) + }) + + it('records a hand-forwarded message under its original sender', async () => { + const env = createTestEnv() + const raw = mime({ + from: 'Me ', + subject: 'Fwd: Invoice INV-9', + body: 'fyi\n\n---------- Forwarded message ---------\nFrom: AWS \nSubject: Invoice INV-9\n', + }) + const outcome = await receiveMessage( + message(raw, { from: 'me@gmail.com', to: `${localPart}@example.test` }), + makeEmailCtx({ db, env }) + ) + if (outcome.status !== 'stored') throw new Error('not stored') + await parseMessage(makeJobCtx({ db, env }), tenantId, outcome.messageId, []) + const [row] = await db + .select() + .from(inboxMessages) + .where(eq(inboxMessages.id, outcome.messageId)) + expect(row).toMatchObject({ fromAddress: 'billing@aws.example', fromName: 'AWS' }) + }) + + it('accepts a Gmail forwarding confirmation and surfaces its code', async () => { + const env = createTestEnv() + const raw = [ + 'From: Gmail Team ', + 'Subject: (#812345678) Gmail Forwarding Confirmation - Receive Mail from me@gmail.com', + `Message-ID: <${crypto.randomUUID()}@google.com>`, + 'Content-Type: text/plain; charset=utf-8', + '', + 'Confirmation code: 812345678', + 'https://mail-settings.google.com/mail/vf-abc', + '', + ].join('\r\n') + const outcome = await receiveMessage( + message(raw, { from: 'forwarding-noreply@google.com', to: `${localPart}@example.test` }), + makeEmailCtx({ db, env }) + ) + if (outcome.status !== 'stored') throw new Error('not stored') + const seen: InboxParsedMessage[] = [] + await parseMessage(makeJobCtx({ db, env }), tenantId, outcome.messageId, [ + { id: 'test', onMessage: async m => void seen.push(m) }, + ]) + const res = await request(`${BASE}/messages/${outcome.messageId}`, { headers: memberCookie }) + expect(await json(res)).toMatchObject({ + kind: 'confirmation', + confirmation: { + provider: 'gmail', + code: '812345678', + url: 'https://mail-settings.google.com/mail/vf-abc', + }, + }) + // A confirmation is not mail anybody subscribed to. + expect(seen).toEqual([]) + }) +}) + +describe('routes and tenant isolation', () => { + let messageId: string + let attachmentId: string + const env = createTestEnv() + + beforeAll(async () => { + const outcome = await receiveMessage( + message(mime({ subject: 'Isolation receipt' }), { + from: 'me@gmail.com', + to: `${localPart}@example.test`, + }), + makeEmailCtx({ db, env }) + ) + if (outcome.status !== 'stored') throw new Error('not stored') + messageId = outcome.messageId + await parseMessage(makeJobCtx({ db, env }), tenantId, messageId, []) + const [att] = await db + .select() + .from(inboxAttachments) + .where(eq(inboxAttachments.messageId, messageId)) + attachmentId = att?.id ?? '' + }) + + it('lists and downloads for a member, always as an attachment', async () => { + const list = await request(`${BASE}/messages`, { headers: memberCookie }, { env }) + const body = await json<{ items: { id: string }[] }>(list) + expect(body.items.map(m => m.id)).toContain(messageId) + + const file = await request( + `${BASE}/attachments/${attachmentId}`, + { headers: memberCookie }, + { env } + ) + expect(file.status).toBe(200) + expect(file.headers.get('content-disposition')).toBe('attachment; filename="receipt.pdf"') + expect(file.headers.get('x-content-type-options')).toBe('nosniff') + expect(await file.text()).toBe('%PDF-1.4 receipt') + + const raw = await request( + `${BASE}/messages/${messageId}/raw`, + { headers: memberCookie }, + { env } + ) + expect(raw.headers.get('content-type')).toBe('message/rfc822') + }) + + it('never lets another organisation list, read, download or delete', async () => { + const list = await request(`${BASE}/messages`, { headers: otherCookie }, { env }) + const body = await json<{ items: { id: string }[] }>(list) + expect(body.items.map(m => m.id)).not.toContain(messageId) + for (const path of [ + `${BASE}/messages/${messageId}`, + `${BASE}/messages/${messageId}/raw`, + `${BASE}/attachments/${attachmentId}`, + ]) { + expect((await request(path, { headers: otherCookie }, { env })).status).toBe(404) + } + const deleted = await request( + `${BASE}/messages/${messageId}`, + { method: 'DELETE', headers: otherCookie }, + { env } + ) + expect(deleted.status).toBe(404) + const [row] = await db.select().from(inboxMessages).where(eq(inboxMessages.id, messageId)) + expect(row).toBeDefined() + }) + + it('lets a member read but not delete; an owner deletes the row and its objects', async () => { + const denied = await request( + `${BASE}/messages/${messageId}`, + { method: 'DELETE', headers: memberCookie }, + { env } + ) + expect(denied.status).toBe(403) + const res = await request( + `${BASE}/messages/${messageId}`, + { method: 'DELETE', headers: ownerCookie }, + { env } + ) + expect(res.status).toBe(204) + const [row] = await db.select().from(inboxMessages).where(eq(inboxMessages.id, messageId)) + expect(row).toBeUndefined() + const left = await stubs(env).files.list({ prefix: `tenants/${tenantId}/inbox/${messageId}/` }) + expect(left.objects).toHaveLength(0) + }) +}) diff --git a/plugins/inbox/apps/web/src/plugins/inbox/tests/config/rules.test.ts b/plugins/inbox/apps/web/src/plugins/inbox/tests/config/rules.test.ts new file mode 100644 index 0000000..c3ca417 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/tests/config/rules.test.ts @@ -0,0 +1,180 @@ +/** The inbox's pure decisions, as tables (`services/rules.ts`). */ +import { describe, expect, it } from 'vitest' +import { + acceptMessage, + confirmationProvider, + extractConfirmation, + extractForwardedOriginal, + isForwardSubject, + keepPart, + normaliseAddress, + parseAuthenticationResults, + safeFilename, + senderMatches, +} from '../../services/rules' + +const PASS = { spf: 'pass', dkim: null, dmarc: null } +const FAIL = { spf: 'fail', dkim: 'fail', dmarc: 'fail' } + +describe('addresses', () => { + it('normalises case and drops the subaddress', () => { + expect(normaliseAddress(' Me+Caf_=x@Gmail.COM ')).toBe('me@gmail.com') + expect(normaliseAddress('no-at-sign')).toBe('no-at-sign') + }) + + it('matches whole addresses and @domains, ignoring +tags', () => { + expect(senderMatches('me+caf_=receipts=x.dev@gmail.com', ['me@gmail.com'])).toBe(true) + expect(senderMatches('billing@stripe.com', ['@stripe.com'])).toBe(true) + expect(senderMatches('billing@evilstripe.com', ['@stripe.com'])).toBe(false) + expect(senderMatches('you@gmail.com', ['me@gmail.com'])).toBe(false) + }) +}) + +describe('authentication', () => { + it('reads the first verdict per method across headers', () => { + expect( + parseAuthenticationResults([ + 'mx.cloudflare.net; dkim=pass header.d=gmail.com; spf=pass smtp.mailfrom=me@gmail.com; dmarc=pass', + 'other; spf=fail', + ]) + ).toEqual({ spf: 'pass', dkim: 'pass', dmarc: 'pass' }) + expect(parseAuthenticationResults([])).toEqual({ spf: null, dkim: null, dmarc: null }) + }) +}) + +describe('acceptMessage', () => { + const base = { + enabled: true, + allowedSenders: ['me@gmail.com'], + requireAuthentication: true, + envelopeFrom: 'me@gmail.com', + headerFrom: 'receipts@vendor.com', + auth: PASS, + } + + it('accepts an allow-listed, authenticated forwarder', () => { + expect(acceptMessage(base)).toEqual({ accept: true, confirmationProvider: null }) + }) + + it('accepts on the From header when the envelope is not listed', () => { + expect( + acceptMessage({ ...base, envelopeFrom: 'bounce@srs.vendor.com', headerFrom: 'me@gmail.com' }) + ).toEqual({ accept: true, confirmationProvider: null }) + }) + + it('refuses an unlisted sender, a failed authentication and a disabled inbox', () => { + expect(acceptMessage({ ...base, envelopeFrom: 'x@y.com', headerFrom: null })).toEqual({ + accept: false, + reason: 'sender', + }) + expect(acceptMessage({ ...base, auth: FAIL })).toEqual({ + accept: false, + reason: 'authentication', + }) + expect(acceptMessage({ ...base, enabled: false })).toEqual({ + accept: false, + reason: 'disabled', + }) + }) + + it('skips authentication only when the organisation does not require it', () => { + expect(acceptMessage({ ...base, auth: FAIL, requireAuthentication: false }).accept).toBe(true) + }) + + it('lets an authenticated Gmail confirmation through before anyone is allow-listed', () => { + const confirmation = { + ...base, + enabled: false, + allowedSenders: [], + envelopeFrom: 'forwarding-noreply@google.com', + headerFrom: 'forwarding-noreply@google.com', + } + expect(acceptMessage(confirmation)).toEqual({ accept: true, confirmationProvider: 'gmail' }) + expect(acceptMessage({ ...confirmation, auth: FAIL })).toEqual({ + accept: false, + reason: 'authentication', + }) + }) + + it('treats an allow-listed Proton mailbox as a sender, not a confirmation', () => { + expect( + acceptMessage({ ...base, allowedSenders: ['me@proton.me'], envelopeFrom: 'me@proton.me' }) + ).toEqual({ accept: true, confirmationProvider: null }) + expect(confirmationProvider('noreply@proton.me')).toBe('proton') + expect(confirmationProvider('me@gmail.com')).toBeNull() + }) +}) + +describe('extractConfirmation', () => { + it('finds Gmail’s code in the body or the subject, and its link', () => { + const text = + 'me@gmail.com has requested to automatically forward mail to your email address.\n' + + 'Confirmation code: 812345678\n\nTo allow, click: https://mail-settings.google.com/mail/vf-%5BANGjdJ%5D-abc\n' + + 'Other: https://support.google.com/mail' + expect(extractConfirmation('gmail', null, text)).toEqual({ + provider: 'gmail', + code: '812345678', + url: 'https://mail-settings.google.com/mail/vf-%5BANGjdJ%5D-abc', + }) + expect( + extractConfirmation('gmail', '(#998877665) Gmail Forwarding Confirmation', 'no code here') + .code + ).toBe('998877665') + }) + + it('finds Proton’s link', () => { + expect( + extractConfirmation( + 'proton', + 'Confirm forwarding', + 'Open https://account.proton.me/confirm?x=1' + ).url + ).toBe('https://account.proton.me/confirm?x=1') + }) +}) + +describe('forwarded originals', () => { + it('recognises forward subjects', () => { + expect(isForwardSubject('Fwd: Your receipt')).toBe(true) + expect(isForwardSubject('FW: Invoice')).toBe(true) + expect(isForwardSubject('Your receipt')).toBe(false) + }) + + it('reads the Gmail, Apple and Outlook header blocks', () => { + expect( + extractForwardedOriginal( + 'hi\n\n---------- Forwarded message ---------\nFrom: Stripe \nDate: Mon\nSubject: Your receipt #1234\nTo: me@gmail.com\n' + ) + ).toEqual({ + fromAddress: 'receipts@stripe.com', + fromName: 'Stripe', + subject: 'Your receipt #1234', + }) + expect( + extractForwardedOriginal( + 'Begin forwarded message:\n\n> From: "AWS" \n> Subject: Invoice\n' + ) + ).toMatchObject({ fromAddress: 'no-reply@aws.amazon.com', fromName: 'AWS' }) + expect( + extractForwardedOriginal( + '-----Original Message-----\nFrom: billing@vendor.co.uk\nSent: Tuesday\nSubject: Bill\n' + ) + ).toMatchObject({ fromAddress: 'billing@vendor.co.uk', fromName: null }) + expect(extractForwardedOriginal('just a note')).toBeNull() + }) +}) + +describe('parts and filenames', () => { + it('keeps attachments and big inline images, drops logos', () => { + expect(keepPart({ inline: false, contentType: 'image/png', size: 10 })).toBe(true) + expect(keepPart({ inline: true, contentType: 'image/png', size: 2_000 })).toBe(false) + expect(keepPart({ inline: true, contentType: 'image/jpeg', size: 200_000 })).toBe(true) + expect(keepPart({ inline: true, contentType: 'application/pdf', size: 10 })).toBe(true) + }) + + it('makes filenames safe for keys and headers', () => { + expect(safeFilename('Invoice "March"/2026.pdf', 'x')).toBe('Invoice March2026.pdf') + expect(safeFilename('', 'attachment-1')).toBe('attachment-1') + expect(safeFilename(null, 'attachment-2')).toBe('attachment-2') + }) +}) diff --git a/plugins/inbox/apps/web/src/plugins/inbox/tools/inbox-tools.ts b/plugins/inbox/apps/web/src/plugins/inbox/tools/inbox-tools.ts new file mode 100644 index 0000000..ac4defe --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/tools/inbox-tools.ts @@ -0,0 +1,152 @@ +/** + * Two read-only tools for chat and agents: list recent inbound messages, and read one (text body, + * windowed, plus its attachment list). Nothing here writes, so nothing needs approval. Every + * member may read the inbox (`read InboxMessage`), so the tenant predicate is the whole scope. + */ +import { and, desc, eq, ilike, or } from 'drizzle-orm' +import { z } from 'zod' +import type { Tool, ToolCtx } from '@/plugins/api' +import { defineTool } from '@/plugins/api' +import { inboxAttachments, inboxMessages } from '../db/schema' + +export const LIST_INBOX_MESSAGES_TOOL = 'inbox_list_messages' +export const GET_INBOX_MESSAGE_TOOL = 'inbox_get_message' + +const DEFAULT_LIMIT = 20 +const MAX_LIMIT = 100 +const DEFAULT_WINDOW = 8_000 + +export const listInboxMessagesInputSchema = z.object({ + search: z + .string() + .trim() + .max(200) + .optional() + .describe('Match against the sender address, sender name or subject (case-insensitive)'), + limit: z.coerce + .number() + .int() + .min(1) + .max(MAX_LIMIT) + .optional() + .describe(`How many to list (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}), newest first`), +}) +export type ListInboxMessagesInput = z.infer + +export function listInboxMessagesTool(ctx: ToolCtx): Tool { + return defineTool({ + name: LIST_INBOX_MESSAGES_TOOL, + description: + 'List email received by this workspace’s inbox (forwarded receipts, invoices, notices), ' + + 'newest first: sender, subject, dates and attachment count. Use inbox_get_message to read one.', + schema: listInboxMessagesInputSchema, + async handler(input) { + const filters = [eq(inboxMessages.tenantId, ctx.tenantId)] + if (input.search) { + const like = `%${input.search.replace(/[%_\\]/g, m => `\\${m}`)}%` + const match = or( + ilike(inboxMessages.fromAddress, like), + ilike(inboxMessages.fromName, like), + ilike(inboxMessages.subject, like) + ) + if (match) filters.push(match) + } + const rows = await ctx.db + .select() + .from(inboxMessages) + .where(and(...filters)) + .orderBy(desc(inboxMessages.receivedAt)) + .limit(input.limit ?? DEFAULT_LIMIT) + if (rows.length === 0) { + return JSON.stringify({ + messages: [], + hint: input.search + ? 'No inbound message matches that search. Try a shorter term, or none.' + : 'The inbox has received nothing yet. Say so rather than inventing a message.', + }) + } + return JSON.stringify({ + messages: rows.map(r => ({ + messageId: r.id, + from: r.fromAddress, + fromName: r.fromName, + subject: r.subject, + kind: r.kind, + status: r.status, + sentAt: r.sentAt?.toISOString() ?? null, + receivedAt: r.receivedAt.toISOString(), + attachments: r.attachmentCount, + source: r.subaddress, + })), + }) + }, + }) +} + +export const getInboxMessageInputSchema = z.object({ + messageId: z.string().uuid().describe('The messageId from inbox_list_messages'), + offset: z.coerce.number().int().min(0).optional().describe('Character offset into the body'), + maxChars: z.coerce + .number() + .int() + .min(500) + .max(20_000) + .optional() + .describe(`How much of the body to return (default ${DEFAULT_WINDOW})`), +}) +export type GetInboxMessageInput = z.infer + +export function getInboxMessageTool(ctx: ToolCtx): Tool { + return defineTool({ + name: GET_INBOX_MESSAGE_TOOL, + description: + 'Read one inbound email: headers, its plain-text body (windowed by offset/maxChars) and the ' + + 'names and types of its attachments.', + schema: getInboxMessageInputSchema, + async handler(input) { + const [row] = await ctx.db + .select() + .from(inboxMessages) + .where(and(eq(inboxMessages.tenantId, ctx.tenantId), eq(inboxMessages.id, input.messageId))) + .limit(1) + if (!row) { + return JSON.stringify({ + error: 'not_found', + hint: 'No message with that id in this workspace. List messages with inbox_list_messages.', + }) + } + const attachments = await ctx.db + .select() + .from(inboxAttachments) + .where( + and(eq(inboxAttachments.tenantId, ctx.tenantId), eq(inboxAttachments.messageId, row.id)) + ) + const body = row.textBody ?? '' + const offset = input.offset ?? 0 + const maxChars = input.maxChars ?? DEFAULT_WINDOW + return JSON.stringify({ + messageId: row.id, + from: row.fromAddress, + fromName: row.fromName, + forwardedBy: row.envelopeFrom, + subject: row.subject, + sentAt: row.sentAt?.toISOString() ?? null, + receivedAt: row.receivedAt.toISOString(), + body: body.slice(offset, offset + maxChars), + bodyLength: body.length, + ...(offset + maxChars < body.length && { + hint: `Body continues — call again with offset ${offset + maxChars}.`, + }), + ...(row.status !== 'parsed' && { + note: `This message is ${row.status}; the body may be empty.`, + }), + attachments: attachments.map(a => ({ + attachmentId: a.id, + filename: a.filename, + contentType: a.contentType, + size: a.size, + })), + }) + }, + }) +} diff --git a/plugins/inbox/apps/web/src/plugins/inbox/ui/hooks/useInbox.ts b/plugins/inbox/apps/web/src/plugins/inbox/ui/hooks/useInbox.ts new file mode 100644 index 0000000..7cf53f7 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/ui/hooks/useInbox.ts @@ -0,0 +1,64 @@ +import { + type InboxMessageListQuery, + inboxMessageListResponseSchema, + inboxMessageSchema, + inboxReprocessResponseSchema, + inboxSettingsSchema, + type UpdateInboxSettings, +} from '@rocketflare/shared/plugins/inbox/index' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { api } from '@/plugins/api/ui' +import { inboxMessageKeys, inboxSettingsKeys } from '../query-keys' + +const BASE = '/api/inbox' + +export function useInboxMessages(query: Partial) { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(query)) if (v !== undefined) params.set(k, String(v)) + return useQuery({ + queryKey: inboxMessageKeys.list(query), + queryFn: () => + api.get(`${BASE}/messages?${params.toString()}`, { schema: inboxMessageListResponseSchema }), + }) +} + +export function useInboxMessage(id: string | null) { + return useQuery({ + queryKey: inboxMessageKeys.detail(id ?? ''), + queryFn: () => api.get(`${BASE}/messages/${id}`, { schema: inboxMessageSchema }), + enabled: Boolean(id), + }) +} + +export function useReprocessInboxMessage() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + api.post(`${BASE}/messages/${id}/reprocess`, {}, { schema: inboxReprocessResponseSchema }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: inboxMessageKeys.all }), + }) +} + +export function useDeleteInboxMessage() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`${BASE}/messages/${id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: inboxMessageKeys.all }), + }) +} + +export function useInboxSettings() { + return useQuery({ + queryKey: inboxSettingsKeys.all, + queryFn: () => api.get(`${BASE}/settings`, { schema: inboxSettingsSchema }), + }) +} + +export function useUpdateInboxSettings() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body: UpdateInboxSettings) => + api.put(`${BASE}/settings`, body, { schema: inboxSettingsSchema }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: inboxSettingsKeys.all }), + }) +} diff --git a/plugins/inbox/apps/web/src/plugins/inbox/ui/index.ts b/plugins/inbox/apps/web/src/plugins/inbox/ui/index.ts new file mode 100644 index 0000000..7490b69 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/ui/index.ts @@ -0,0 +1,36 @@ +/** + * `inbox` — the UI entry (D31): one page (`/inbox`), one nav item, one settings tab. Pages are + * `lazy()` so none of this rides in the main bundle. + */ +import { EnvelopeIcon } from '@heroicons/react/24/outline' +import { + INBOX_MESSAGE_SUBJECT, + INBOX_SETTINGS_SUBJECT, + inboxShared, +} from '@rocketflare/shared/plugins/inbox/index' +import { createElement, lazy, Suspense } from 'react' +import type { NavGuard, UiPlugin } from '@/plugins/api/ui-wiring' +import { inboxQueryKeys } from './query-keys' + +const InboxPage = lazy(() => import('./pages/InboxPage')) +const InboxSettingsPage = lazy(() => import('./pages/InboxSettings')) + +const readInbox: NavGuard = { action: 'read', subject: INBOX_MESSAGE_SUBJECT } + +export const inboxUi = { + shared: inboxShared, + routes: [{ path: '/inbox', Component: InboxPage, guard: readInbox }], + nav: [{ items: [{ to: '/inbox', label: 'Inbox', icon: EnvelopeIcon, guard: readInbox }] }], + settingsTabs: ({ can }) => + can('read', INBOX_SETTINGS_SUBJECT) + ? [ + { + id: 'inbox', + label: 'Inbox', + icon: createElement(EnvelopeIcon, { className: 'w-4 h-4' }), + content: createElement(Suspense, { fallback: null }, createElement(InboxSettingsPage)), + }, + ] + : [], + queryKeys: inboxQueryKeys, +} satisfies UiPlugin diff --git a/plugins/inbox/apps/web/src/plugins/inbox/ui/pages/InboxPage.tsx b/plugins/inbox/apps/web/src/plugins/inbox/ui/pages/InboxPage.tsx new file mode 100644 index 0000000..6bb2a9b --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/ui/pages/InboxPage.tsx @@ -0,0 +1,271 @@ +/** + * Inbox: what arrived, newest first, and one message beside it — sender, forwarding mailbox, + * authentication verdicts, the text body and the attachments. A forwarding confirmation shows its + * code and link at the top, because that is the only reason anybody opens one. + * + * Bodies are shown as TEXT only. The HTML body and the raw message are downloads (served as + * attachments by the API), never rendered here: they are markup a stranger wrote. + */ +import { EnvelopeIcon, PaperClipIcon } from '@heroicons/react/24/outline' +import type { InboxMessage, InboxMessageSummary } from '@rocketflare/shared/plugins/inbox/index' +import { useState } from 'react' +import { + ConfirmModal, + EmptyState, + formatBytes, + formatDateTime, + PageHeader, + PaginationControls, + SectionPanel, + SkeletonRows, + showToast, + timeAgo, + usePermissions, +} from '@/plugins/api/ui' +import { + useDeleteInboxMessage, + useInboxMessage, + useInboxMessages, + useReprocessInboxMessage, +} from '../hooks/useInbox' + +const STATUS_BADGE: Record = { + received: 'badge-ghost', + parsed: 'badge-success', + failed: 'badge-error', +} + +export default function InboxPage() { + const [page, setPage] = useState(1) + const [selected, setSelected] = useState(null) + const list = useInboxMessages({ page, pageSize: 25 }) + + return ( +
+ +
+ + {list.isPending ? ( +
+ +
+ ) : list.isError || !list.data ? ( +

The inbox could not be loaded.

+ ) : list.data.items.length === 0 ? ( + + ) : ( + <> +
    + {list.data.items.map(m => ( +
  • + +
  • + ))} +
+ + + )} +
+ {selected ? ( + setSelected(null)} /> + ) : ( + +

Choose a message to read it.

+
+ )} +
+
+ ) +} + +function MessageDetail({ id, onDeleted }: { id: string; onDeleted: () => void }) { + const message = useInboxMessage(id) + if (message.isPending) { + return ( + + + + ) + } + if (message.isError || !message.data) { + return ( + +

This message could not be loaded.

+
+ ) + } + return +} + +function Verdict({ label, value }: { label: string; value: string | null }) { + const tone = value === 'pass' ? 'badge-success' : value ? 'badge-warning' : 'badge-ghost' + return {`${label} ${value ?? '—'}`} +} + +function MessageBody({ message, onDeleted }: { message: InboxMessage; onDeleted: () => void }) { + const { can } = usePermissions() + const reprocess = useReprocessInboxMessage() + const remove = useDeleteInboxMessage() + const [confirmDelete, setConfirmDelete] = useState(false) + + return ( + + + Download .eml + + {message.hasHtmlBody && ( + + Download HTML + + )} + {can('update', 'InboxMessage') && ( + + )} + {can('delete', 'InboxMessage') && ( + + )} + + } + > + {message.confirmation && ( +
+
+

+ Forwarding confirmation + {message.confirmation.provider !== 'other' && + ` from ${message.confirmation.provider}`} +

+ {message.confirmation.code && ( +

+ Code: {message.confirmation.code} +

+ )} + {message.confirmation.url && ( + + Open the confirmation link + + )} + {!message.confirmation.code && !message.confirmation.url && ( +

No code or link was found — read the body below.

+ )} +
+
+ )} +
+
From
+
+ {message.fromName ? `${message.fromName} <${message.fromAddress}>` : message.fromAddress} +
+
Forwarded by
+
{message.envelopeFrom}
+
To
+
{message.toAddress}
+
Sent
+
{message.sentAt ? formatDateTime(message.sentAt) : '—'}
+
Received
+
{formatDateTime(message.receivedAt)}
+
Checks
+
+ + + +
+
+ {message.error &&

{message.error}

} + {message.attachments.length > 0 && ( +
    + {message.attachments.map(a => ( +
  • + + + {a.filename} + + + {a.contentType} · {formatBytes(a.size)} + +
  • + ))} +
+ )} +
+        {message.textBody ?? (message.status === 'parsed' ? '(no text body)' : 'Not parsed yet.')}
+      
+ setConfirmDelete(false)} + onConfirm={() => + remove.mutate(message.id, { + onSuccess: () => { + setConfirmDelete(false) + onDeleted() + }, + }) + } + /> +
+ ) +} diff --git a/plugins/inbox/apps/web/src/plugins/inbox/ui/pages/InboxSettings.tsx b/plugins/inbox/apps/web/src/plugins/inbox/ui/pages/InboxSettings.tsx new file mode 100644 index 0000000..fc8aad9 --- /dev/null +++ b/plugins/inbox/apps/web/src/plugins/inbox/ui/pages/InboxSettings.tsx @@ -0,0 +1,146 @@ +/** + * Settings → Inbox: the address this organisation receives on, who may send to it, and whether + * mail must authenticate. Read-only without `manage InboxSettings`. + * + * The domain half of the address is whatever Cloudflare Email Routing routes to this Worker, which + * the app cannot know — the page shows this site's host as the likely one and says so. + */ +import { + INBOX_LOCAL_PART_RE, + INBOX_SENDER_RE, + type InboxSettings as Settings, +} from '@rocketflare/shared/plugins/inbox/index' +import { useState } from 'react' +import { + ApiError, + SectionPanel, + SectionPanelSkeleton, + SettingInput, + SettingRow, + SettingToggle, + showToast, + usePermissions, +} from '@/plugins/api/ui' +import { useInboxSettings, useUpdateInboxSettings } from '../hooks/useInbox' + +export default function InboxSettingsPage() { + const settings = useInboxSettings() + if (settings.isPending) return + if (settings.isError || !settings.data) { + return ( + +

The inbox settings could not be loaded.

+
+ ) + } + return +} + +function parseSenders(text: string): string[] { + return text + .split(/[\s,;]+/) + .map(s => s.trim().toLowerCase()) + .filter(Boolean) +} + +function SettingsForm({ settings }: { settings: Settings }) { + const { can } = usePermissions() + const canManage = can('manage', 'InboxSettings') + const update = useUpdateInboxSettings() + const [enabled, setEnabled] = useState(settings.enabled) + const [localPart, setLocalPart] = useState(settings.localPart ?? '') + const [senders, setSenders] = useState(settings.allowedSenders.join('\n')) + const [requireAuth, setRequireAuth] = useState(settings.requireAuthentication) + + const parsedSenders = parseSenders(senders) + const badSender = parsedSenders.find(s => !INBOX_SENDER_RE.test(s)) + const localPartError = + localPart && !INBOX_LOCAL_PART_RE.test(localPart) + ? 'Lower-case letters, digits, dots, dashes or underscores' + : undefined + const host = typeof window === 'undefined' ? 'your-domain' : window.location.hostname + + const save = () => + update.mutate( + { + enabled, + ...(localPart && { localPart }), + allowedSenders: parsedSenders, + requireAuthentication: requireAuth, + }, + { + onSuccess: () => showToast('Inbox settings saved', 'success'), + onError: err => + showToast(err instanceof ApiError ? err.message : 'Could not save the settings', 'error'), + } + ) + + return ( + + setLocalPart(v.trim().toLowerCase())} + placeholder="receipts" + disabled={!canManage} + error={localPartError} + /> + +
+