Run debugged Sieve scripts under the server's live limits #98

Merged
jcoffey-dev merged 1 commits from sieve-live-limits into main 2026-10-09 08:43:23 +00:00
7 changed files with 327 additions and 13 deletions

No files matched your search

+2
View File
@@ -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
+35 -6
View File
@@ -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 <BlobField value={value} onChange={onChange} readOnly={readOnly} sieveScriptName={sieveScriptName} />;
return (
<BlobField
value={value}
onChange={onChange}
readOnly={readOnly}
sieveScriptName={sieveScriptName}
sieveInterpreter={sieveInterpreter}
/>
);
case 'objectId':
return (
<ObjectIdField
@@ -281,7 +303,11 @@ export function FieldWidget(props: FieldWidgetProps) {
</div>
{widget}
{sieveScriptName !== undefined && ft.type === 'string' && (
<SievepadButton scriptName={sieveScriptName} source={typeof value === 'string' ? value : ''} />
<SievepadButton
scriptName={sieveScriptName}
source={typeof value === 'string' ? value : ''}
interpreter={sieveInterpreter}
/>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
@@ -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<string>('');
@@ -1102,7 +1129,9 @@ function BlobField({ value, onChange, readOnly, sieveScriptName }: BlobFieldProp
rows={8}
className="font-mono text-xs"
/>
{sieveScriptName !== undefined && <SievepadButton scriptName={sieveScriptName} source={content} />}
{sieveScriptName !== undefined && (
<SievepadButton scriptName={sieveScriptName} source={content} interpreter={sieveInterpreter} />
)}
{modified && (
<p className="text-xs text-muted-foreground">
{t('field.contentModified', 'Content modified (will be saved as a new blob)')}
+6 -3
View File
@@ -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' });
});
};
+95
View File
@@ -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,
});
});
});
+171
View File
@@ -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<SieveInterpreter, string> = {
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<string, unknown>;
/** 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<string, unknown>)
.filter(([, present]) => present)
.map(([key]) => key);
}
return [];
}
/** Copies the numbers that are set, under the playground's names. */
function numbers(from: Settings, names: Record<string, string>, 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<Settings> {
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 {};
}
}
+5
View File
@@ -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);
+13 -4
View File
@@ -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<string> {
export async function sievepadLink(
name: string,
source: string,
settings: Record<string, unknown> = {},
base = playgroundUrl(),
): Promise<string> {
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<void> {
// 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<void> {
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;