Run debugged Sieve scripts under the server's live limits #98
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)')}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user