('');
@@ -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;