diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index 8869c6e..9d5ca69 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -71,6 +71,7 @@ import { FieldWidget } from '@/components/forms/FieldWidget'; import { DnsConnectCard } from '@/features/dns/DnsConnectCard'; import { changedFields } from '@/features/settings/changedFromDefault'; import { isSieveScriptField } from '@/lib/sievepad'; +import { interpreterFor } from '@/lib/sieveLimits'; import { reloadActionFor } from '@/lib/settingsApply'; import type { Field, Fields, Form, FormField, Schema } from '@/types/schema'; @@ -899,6 +900,7 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic sieveScriptName={ isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined } + sieveInterpreter={interpreterFor(resolved.obj.objectName)} helpScope={helpScope} explainTarget={ isCreate diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index f1eff85..1665c56 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -34,6 +34,7 @@ import { EventPicker } from '@/features/events/EventPicker'; import type { ExpressionHints } from '@/components/expression/simple'; import { OtpAuthField } from '@/components/forms/OtpAuthField'; import { SievepadButton } from '@/components/forms/SievepadButton'; +import type { SieveInterpreter } from '@/lib/sieveLimits'; import { bytesToHuman, humanToBytes, @@ -70,6 +71,8 @@ export interface FieldWidgetProps { error?: string; schema: Schema; sieveScriptName?: string; + /** inbuxa: the interpreter that runs that script, for the playground's limits. */ + sieveInterpreter?: SieveInterpreter; /** INBUXA: the object or schema that owns this field, for its help id (`scope.field`). */ helpScope?: string; /** inbuxa: the saved record this field belongs to, for "Explain this" (ai-explain EX-17). */ @@ -94,8 +97,19 @@ function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optio export function FieldWidget(props: FieldWidgetProps) { const { t } = useTranslation(); - const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName, helpScope, explainTarget } = - props; + const { + field, + formField, + value, + onChange, + readOnly, + error, + schema, + sieveScriptName, + sieveInterpreter = 'user', + helpScope, + explainTarget, + } = props; const helpId = helpScope ? `${helpScope}.${formField.name}` : undefined; // INBUXA: the option's default, for its tooltip, and whether it has been changed. const defaultValue = helpScope ? schema.fields[helpScope]?.defaults?.[formField.name] : undefined; @@ -158,7 +172,15 @@ export function FieldWidget(props: FieldWidgetProps) { /> ); case 'blobId': - return ; + return ( + + ); case 'objectId': return ( {widget} {sieveScriptName !== undefined && ft.type === 'string' && ( - + )} {error &&

{error}

} @@ -1034,9 +1060,10 @@ interface BlobFieldProps { onChange: (value: unknown) => void; readOnly: boolean; sieveScriptName?: string; + sieveInterpreter: SieveInterpreter; } -function BlobField({ value, onChange, readOnly, sieveScriptName }: BlobFieldProps) { +function BlobField({ value, onChange, readOnly, sieveScriptName, sieveInterpreter }: BlobFieldProps) { const { t } = useTranslation(); const blobId = typeof value === 'string' ? value : null; const [content, setContent] = useState(''); @@ -1102,7 +1129,9 @@ function BlobField({ value, onChange, readOnly, sieveScriptName }: BlobFieldProp rows={8} className="font-mono text-xs" /> - {sieveScriptName !== undefined && } + {sieveScriptName !== undefined && ( + + )} {modified && (

{t('field.contentModified', 'Content modified (will be saved as a new blob)')} diff --git a/src/components/forms/SievepadButton.tsx b/src/components/forms/SievepadButton.tsx index d83ca49..8988be6 100644 --- a/src/components/forms/SievepadButton.tsx +++ b/src/components/forms/SievepadButton.tsx @@ -13,19 +13,22 @@ import { Bug } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { toast } from '@/hooks/use-toast'; import { openInSievepad } from '@/lib/sievepad'; +import type { SieveInterpreter } from '@/lib/sieveLimits'; interface SievepadButtonProps { scriptName: string; source: string; + interpreter: SieveInterpreter; } // inbuxa: the playground ships with the console, so the script goes nowhere -// else and there is nothing to warn about first. -export function SievepadButton({ scriptName, source }: SievepadButtonProps) { +// else and there is nothing to warn about first. It runs under the limits +// of the interpreter that runs it on the server. +export function SievepadButton({ scriptName, source, interpreter }: SievepadButtonProps) { const { t } = useTranslation(); const open = () => { - openInSievepad(scriptName || t('sievepad.defaultName', 'Sieve script'), source).catch(() => { + openInSievepad(scriptName || t('sievepad.defaultName', 'Sieve script'), source, interpreter).catch(() => { toast({ title: t('sievepad.failed', "Couldn't open the Sieve playground."), variant: 'destructive' }); }); }; diff --git a/src/lib/sieveLimits.test.ts b/src/lib/sieveLimits.test.ts new file mode 100644 index 0000000..c73566f --- /dev/null +++ b/src/lib/sieveLimits.test.ts @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; + +import { interpreterFor, playgroundSettings } from './sieveLimits'; + +const CAPABILITIES = [ + 'fileinto', + 'vacation', + 'duplicate', + 'variables', + 'comparator-elbonia', + 'vnd.inbuxa.while', + 'vnd.inbuxa.expressions', +]; + +describe('interpreterFor', () => { + it('runs system scripts on the system interpreter and the rest on the user one', () => { + expect(interpreterFor('x:SieveSystemScript')).toBe('system'); + expect(interpreterFor('x:SieveUserScript')).toBe('user'); + expect(interpreterFor('SieveScript')).toBe('user'); + }); +}); + +describe('playgroundSettings', () => { + it('gives account scripts the live limits, without while or the disabled extensions', () => { + const settings = playgroundSettings( + 'user', + { + disableCapabilities: { duplicate: true }, + maxCpuCycles: 5000, + maxNestedForEvery: 3, + maxStringLength: 4096, + maxVarNameLength: 32, + maxScriptSize: 102400, + protectedHeaders: { Received: true, 'Auto-Submitted': true }, + allowedNotifyUris: ['mailto'], + defaultSubject: 'Automated reply', + defaultExpiryVacation: 2592000000, + defaultExpiryDuplicate: 604800000, + dkimSignDomain: { else: 'false' }, + }, + CAPABILITIES, + ); + expect(settings).toEqual({ + capabilities: ['fileinto', 'vacation', 'variables', 'comparator-elbonia', 'vnd.inbuxa.expressions'], + cpuLimit: 5000, + maxNestedForeverypart: 3, + maxStringSize: 4096, + maxVariableNameSize: 32, + maxScriptSize: 102400, + maxReceivedHeaders: 4294967295, + protectedHeaders: ['Received', 'Auto-Submitted'], + validNotificationUris: ['mailto'], + vacationDefaultSubject: 'Automated reply', + defaultVacationExpiry: 2592000, + defaultDuplicateExpiry: 604800, + }); + }); + + it('gives system scripts while, no mailbox actions and the fixed compiler limits', () => { + const settings = playgroundSettings( + 'system', + { maxCpuCycles: 1048576, maxVarSize: 52428800, duplicateExpiry: 604800000, noCapabilityCheck: true }, + CAPABILITIES, + ); + expect(settings.capabilities).toEqual([ + 'variables', + 'comparator-elbonia', + 'vnd.inbuxa.while', + 'vnd.inbuxa.expressions', + ]); + expect(settings).toMatchObject({ + noCapabilityCheck: true, + cpuLimit: 1048576, + maxVariableSize: 52428800, + defaultDuplicateExpiry: 604800, + maxStringSize: 52428800, + maxHeaderSize: 10240, + maxIncludes: 10, + validNotificationUris: ['mailto'], + }); + }); + + it('leaves out what the server did not send', () => { + expect(playgroundSettings('user', {}, CAPABILITIES)).toEqual({ + capabilities: ['fileinto', 'vacation', 'duplicate', 'variables', 'comparator-elbonia', 'vnd.inbuxa.expressions'], + maxReceivedHeaders: 4294967295, + }); + }); +}); diff --git a/src/lib/sieveLimits.ts b/src/lib/sieveLimits.ts new file mode 100644 index 0000000..f4f5034 --- /dev/null +++ b/src/lib/sieveLimits.ts @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: the Sieve playground runs a script under the limits and + * extensions the server gives that kind of script, read live from + * x:SieveUserInterpreter or x:SieveSystemInterpreter, so a script that + * breaks a limit fails in the playground as it would on delivery. The + * mapping follows inbuxa-server's crates/common/src/config/mailstore/ + * scripts.rs; what it sets in code rather than from the settings is + * repeated here. + */ + +import { getAccountId, jmapGet } from '@/services/jmap/client'; +import { useSchemaStore } from '@/stores/schemaStore'; + +export type SieveInterpreter = 'user' | 'system'; + +/** Which interpreter runs a script field's object. */ +export function interpreterFor(objectName: string): SieveInterpreter { + return objectName === 'x:SieveSystemScript' ? 'system' : 'user'; +} + +const OBJECT: Record = { + user: 'x:SieveUserInterpreter', + system: 'x:SieveSystemInterpreter', +}; + +// sieve-rs's usize::MAX as the playground clamps it: the server lifts the +// interpreter's own Received limit for account scripts and checks it while +// delivering instead. +const UNLIMITED = 4294967295; + +// What the server switches off for system scripts, which run before +// delivery and have no mailbox to act on. +const SYSTEM_DISABLED = [ + 'fileinto', + 'vacation', + 'vacation-seconds', + 'fcc', + 'mailbox', + 'mailboxid', + 'mboxmetadata', + 'servermetadata', + 'imapsieve', + 'duplicate', +]; + +type Settings = Record; + +/** A JMAP set (an object keyed by value) or a plain list, as strings. */ +function members(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string'); + if (value && typeof value === 'object') { + return Object.entries(value as Record) + .filter(([, present]) => present) + .map(([key]) => key); + } + return []; +} + +/** Copies the numbers that are set, under the playground's names. */ +function numbers(from: Settings, names: Record, to: Settings) { + for (const [server, playground] of Object.entries(names)) { + const value = from[server]; + if (typeof value === 'number' && Number.isFinite(value)) to[playground] = value; + } +} + +function seconds(ms: unknown): number | undefined { + return typeof ms === 'number' && Number.isFinite(ms) ? Math.round(ms / 1000) : undefined; +} + +/** + * The playground settings for a script run by `interpreter`, from that + * interpreter's live settings and every Sieve capability the server knows. + */ +export function playgroundSettings(interpreter: SieveInterpreter, live: Settings, capabilities: string[]): Settings { + const settings: Settings = {}; + if (interpreter === 'user') { + const disabled = new Set([...members(live.disableCapabilities), 'vnd.inbuxa.while']); + settings.capabilities = capabilities.filter((name) => !disabled.has(name)); + numbers( + live, + { + maxCpuCycles: 'cpuLimit', + maxHeaderSize: 'maxHeaderSize', + maxIncludes: 'maxIncludes', + maxLocalVars: 'maxLocalVariables', + maxMatchVars: 'maxMatchVariables', + maxNestedBlocks: 'maxNestedBlocks', + maxNestedForEvery: 'maxNestedForeverypart', + maxNestedIncludes: 'maxNestedIncludes', + maxNestedTests: 'maxNestedTests', + maxOutMessages: 'maxOutMessages', + maxRedirects: 'maxRedirects', + maxScriptSize: 'maxScriptSize', + maxStringLength: 'maxStringSize', + maxVarNameLength: 'maxVariableNameSize', + maxVarSize: 'maxVariableSize', + }, + settings, + ); + settings.maxReceivedHeaders = UNLIMITED; + if (live.protectedHeaders !== undefined) settings.protectedHeaders = members(live.protectedHeaders); + if (live.allowedNotifyUris !== undefined) settings.validNotificationUris = members(live.allowedNotifyUris); + if (typeof live.defaultSubject === 'string') settings.vacationDefaultSubject = live.defaultSubject; + if (typeof live.defaultSubjectPrefix === 'string') settings.vacationSubjectPrefix = live.defaultSubjectPrefix; + const vacation = seconds(live.defaultExpiryVacation); + if (vacation !== undefined) settings.defaultVacationExpiry = vacation; + const duplicate = seconds(live.defaultExpiryDuplicate); + if (duplicate !== undefined) settings.defaultDuplicateExpiry = duplicate; + return settings; + } + + const disabled = new Set(SYSTEM_DISABLED); + settings.capabilities = capabilities.filter((name) => !disabled.has(name)); + if (typeof live.noCapabilityCheck === 'boolean') settings.noCapabilityCheck = live.noCapabilityCheck; + // The system compiler's fixed limits, and sieve-rs's defaults for the + // ones the server leaves alone. + Object.assign(settings, { + maxScriptSize: 1024 * 1024, + maxStringSize: 52428800, + maxVariableNameSize: 100, + maxNestedBlocks: 50, + maxNestedTests: 50, + maxNestedForeverypart: 10, + maxMatchVariables: 30, + maxLocalVariables: 8192, + maxHeaderSize: 10240, + maxIncludes: 10, + validNotificationUris: ['mailto'], + protectedHeaders: ['Original-Subject', 'Original-From'], + }); + numbers( + live, + { + maxCpuCycles: 'cpuLimit', + maxNestedIncludes: 'maxNestedIncludes', + maxOutMessages: 'maxOutMessages', + maxReceivedHeaders: 'maxReceivedHeaders', + maxRedirects: 'maxRedirects', + maxVarSize: 'maxVariableSize', + }, + settings, + ); + const duplicate = seconds(live.duplicateExpiry); + if (duplicate !== undefined) settings.defaultDuplicateExpiry = duplicate; + return settings; +} + +/** + * Reads the interpreter's settings from the server. Empty, so the + * playground keeps its own defaults, when they can't be read (the + * administrator may not be allowed to see them). + */ +export async function fetchPlaygroundSettings(interpreter: SieveInterpreter): Promise { + const capabilities = (useSchemaStore.getState().schema?.enums?.SieveCapability ?? []).map((variant) => variant.name); + if (capabilities.length === 0) return {}; + try { + const object = OBJECT[interpreter]; + const [response] = await jmapGet(object, getAccountId(object), ['singleton']); + const live = ((response?.[1] as { list?: Settings[] } | undefined)?.list ?? [])[0]; + return live ? playgroundSettings(interpreter, live, capabilities) : {}; + } catch { + return {}; + } +} diff --git a/src/lib/sievepad.test.ts b/src/lib/sievepad.test.ts index b2195a9..e7cbf7c 100644 --- a/src/lib/sievepad.test.ts +++ b/src/lib/sievepad.test.ts @@ -41,6 +41,11 @@ describe('sievepadLink', () => { }); }); + it('carries the interpreter settings it is given', async () => { + const link = await sievepadLink('Filters', 'keep;', { cpuLimit: 5000, capabilities: ['fileinto'] }); + expect((decode(link) as { settings: unknown }).settings).toEqual({ cpuLimit: 5000, capabilities: ['fileinto'] }); + }); + it('truncates long workspace names', async () => { const link = await sievepadLink('x'.repeat(200), 'keep;'); expect((decode(link) as { name: string }).name).toHaveLength(80); diff --git a/src/lib/sievepad.ts b/src/lib/sievepad.ts index b829cd9..35f2f84 100644 --- a/src/lib/sievepad.ts +++ b/src/lib/sievepad.ts @@ -7,6 +7,8 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ +import { fetchPlaygroundSettings, type SieveInterpreter } from './sieveLimits'; + // inbuxa: the Sieve playground ships with the console (sieve-playground.ts) // and runs the server's own interpreter, so the script never leaves the // console's origin. @@ -37,25 +39,32 @@ function toBase64Url(bytes: Uint8Array): string { return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } -export async function sievepadLink(name: string, source: string, base = playgroundUrl()): Promise { +export async function sievepadLink( + name: string, + source: string, + settings: Record = {}, + base = playgroundUrl(), +): Promise { const json = JSON.stringify({ v: SIEVEPAD_FORMAT_VERSION, name: name.slice(0, SIEVEPAD_MAX_NAME_LENGTH), scripts: [{ name: SIEVEPAD_MAIN_SCRIPT, source: source.replace(/\r\n/g, '\n') }], messages: [], - settings: {}, + settings, }); const stream = new Blob([new TextEncoder().encode(json)]).stream().pipeThrough(new CompressionStream('deflate-raw')); const packed = new Uint8Array(await new Response(stream).arrayBuffer()); return `${base}#w=${toBase64Url(packed)}`; } -export async function openInSievepad(name: string, source: string): Promise { +// inbuxa: the script runs under the limits of the interpreter that runs it +// on the server (sieveLimits.ts). +export async function openInSievepad(name: string, source: string, interpreter: SieveInterpreter): Promise { const tab = window.open('about:blank', '_blank'); if (tab) tab.opener = null; let link: string; try { - link = await sievepadLink(name, source); + link = await sievepadLink(name, source, await fetchPlaygroundSettings(interpreter)); } catch (err) { tab?.close(); throw err;