diff --git a/CHANGELOG.md b/CHANGELOG.md index b384063..efa6e75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [1.0.11] - 2026-09-15 + +### Added +- Sievepad integration. + +### Changed + +### Fixed + ## [1.0.10] - 2026-09-04 ### Added diff --git a/package.json b/package.json index b61f40c..2b5e84f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stalwart-webui", "private": true, - "version": "1.0.10", + "version": "1.0.11", "description": "Stalwart WebUI", "type": "module", "scripts": { diff --git a/src/components/common/EnterpriseUpsell.tsx b/src/components/common/EnterpriseUpsell.tsx index d5b8c14..513d941 100644 --- a/src/components/common/EnterpriseUpsell.tsx +++ b/src/components/common/EnterpriseUpsell.tsx @@ -26,12 +26,20 @@ export function EnterpriseUpsell({ open, onClose }: EnterpriseUpsellProps) { return ( !isOpen && onClose()}> - - + + {t('enterprise.trialTitle')} {t('enterprise.trialDescription')} - + + + {t('enterprise.whyNotFree')} + diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index 469425e..0acf12e 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -53,6 +53,7 @@ import { SECRET_MASK } from '@/lib/jmapUtils'; import { toast } from '@/hooks/use-toast'; import { logFormChange } from '@/lib/debug'; import { FieldWidget } from '@/components/forms/FieldWidget'; +import { isSieveScriptField } from '@/lib/sievepad'; import type { Field, Fields, Form, FormField, Schema } from '@/types/schema'; import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap'; @@ -740,6 +741,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { })(); const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition); + const scriptName = typeof formData.name === 'string' ? formData.name : ''; return (
@@ -810,6 +812,9 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { readOnly={fieldReadOnly} error={fieldError} schema={schema} + sieveScriptName={ + isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined + } /> ); diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index 0e1e351..e9b362e 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -24,6 +24,7 @@ import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { ExpressionEditor } from '@/components/expression/ExpressionEditor'; import { OtpAuthField } from '@/components/forms/OtpAuthField'; +import { SievepadButton } from '@/components/forms/SievepadButton'; import { bytesToHuman, humanToBytes, @@ -59,6 +60,7 @@ export interface FieldWidgetProps { readOnly: boolean; error?: string; schema: Schema; + sieveScriptName?: string; } function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optional' | null { @@ -79,7 +81,7 @@ function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optio export function FieldWidget(props: FieldWidgetProps) { const { t } = useTranslation(); - const { field, formField, value, onChange, readOnly, error, schema } = props; + const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName } = props; const ft = field.type; const edition = useEffectiveEdition(); @@ -132,7 +134,7 @@ export function FieldWidget(props: FieldWidgetProps) { /> ); case 'blobId': - return ; + return ; case 'objectId': return ( )} {widget} + {sieveScriptName !== undefined && ft.type === 'string' && ( + + )} {error &&

{error}

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

{t('field.contentModified', 'Content modified (will be saved as a new blob)')} @@ -1782,10 +1789,10 @@ function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems if (variants.length > 10) { const filtered = searchQuery ? variants.filter( - (v) => - v.label.toLowerCase().includes(searchQuery.toLowerCase()) || - v.name.toLowerCase().includes(searchQuery.toLowerCase()), - ) + (v) => + v.label.toLowerCase().includes(searchQuery.toLowerCase()) || + v.name.toLowerCase().includes(searchQuery.toLowerCase()), + ) : variants; return ( diff --git a/src/components/forms/SievepadButton.tsx b/src/components/forms/SievepadButton.tsx new file mode 100644 index 0000000..dd935da --- /dev/null +++ b/src/components/forms/SievepadButton.tsx @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Bug } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { toast } from '@/hooks/use-toast'; +import { dismissSievepadWarning, isSievepadWarningDismissed, openInSievepad } from '@/lib/sievepad'; + +interface SievepadButtonProps { + scriptName: string; + source: string; +} + +export function SievepadButton({ scriptName, source }: SievepadButtonProps) { + const { t } = useTranslation(); + const [warningOpen, setWarningOpen] = useState(false); + const [dontShowAgain, setDontShowAgain] = useState(false); + + const open = () => { + openInSievepad(scriptName || t('sievepad.defaultName', 'Sieve script'), source).catch(() => { + toast({ title: t('sievepad.failed', 'Failed to open Sievepad.'), variant: 'destructive' }); + }); + }; + + const handleClick = () => { + if (isSievepadWarningDismissed()) { + open(); + } else { + setDontShowAgain(false); + setWarningOpen(true); + } + }; + + const handleContinue = () => { + if (dontShowAgain) dismissSievepadWarning(); + setWarningOpen(false); + open(); + }; + + return ( + <> +

+ +
+ + + + {t('sievepad.warningTitle', 'Debug in Sievepad')} + + {t( + 'sievepad.warningDescription', + 'A new tab will open sievepad.com with a copy of this script. Sievepad compiles and runs the script entirely in your browser: nothing is uploaded to or stored on any server.', + )} + + +
+ setDontShowAgain(checked === true)} + /> + +
+ + + + +
+
+ + ); +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 46e0e64..ac01ded 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -136,7 +136,8 @@ "trialDescription": "Get access to advanced features including multi-tenancy, AI-powered spam filtering, alerts, and more.", "trialButton": "Start 30-Day Free Trial", "requestTrial": "Request a free trial to unlock this feature.", - "ossHidden": "This feature is not available in the open-source edition." + "ossHidden": "This feature is not available in the open-source edition.", + "whyNotFree": "Why is this not free?" }, "errorBoundary": { "title": "Something went wrong", @@ -340,6 +341,14 @@ "periodValue": "Period value" }, "sections": "Sections", + "sievepad": { + "debug": "Debug", + "defaultName": "Sieve script", + "dontShowAgain": "Don't show this again", + "failed": "Failed to open Sievepad.", + "warningDescription": "A new tab will open sievepad.com with a copy of this script. Sievepad compiles and runs the script entirely in your browser: nothing is uploaded to or stored on any server.", + "warningTitle": "Debug in Sievepad" + }, "toggleTheme": "Toggle theme", "tracing": { "addFilter": "Add filter", diff --git a/src/lib/sievepad.test.ts b/src/lib/sievepad.test.ts new file mode 100644 index 0000000..f95efd8 --- /dev/null +++ b/src/lib/sievepad.test.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { inflateRawSync } from 'node:zlib'; +import { describe, expect, it } from 'vitest'; + +import { SIEVEPAD_URL, isSieveScriptField, sievepadLink } from './sievepad'; + +function decode(link: string): unknown { + const token = new URLSearchParams(new URL(link).hash.slice(1)).get('w') ?? ''; + return JSON.parse(inflateRawSync(Buffer.from(token, 'base64url')).toString('utf8')); +} + +describe('sievepadLink', () => { + it('encodes the script as a single main entry', async () => { + const source = 'require "imap4flags";\r\naddflag "\\\\Seen";\r\n'; + const link = await sievepadLink('Filters é', source); + + expect(link.startsWith(`${SIEVEPAD_URL}#w=`)).toBe(true); + expect(link.slice(`${SIEVEPAD_URL}#w=`.length)).toMatch(/^[A-Za-z0-9_-]+$/); + expect(decode(link)).toEqual({ + v: 1, + name: 'Filters é', + scripts: [{ name: 'main', source: 'require "imap4flags";\naddflag "\\\\Seen";\n' }], + messages: [], + settings: {}, + }); + }); + + it('truncates long workspace names', async () => { + const link = await sievepadLink('x'.repeat(200), 'keep;'); + expect((decode(link) as { name: string }).name).toHaveLength(80); + }); +}); + +describe('isSieveScriptField', () => { + it('matches only the known script fields', () => { + expect(isSieveScriptField('x:SieveUserScript', 'contents')).toBe(true); + expect(isSieveScriptField('x:SieveSystemScript', 'contents')).toBe(true); + expect(isSieveScriptField('SieveScript', 'blobId')).toBe(true); + expect(isSieveScriptField('SieveScript', 'name')).toBe(false); + expect(isSieveScriptField('x:Domain', 'contents')).toBe(false); + }); +}); diff --git a/src/lib/sievepad.ts b/src/lib/sievepad.ts new file mode 100644 index 0000000..6b4fcd7 --- /dev/null +++ b/src/lib/sievepad.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +export const SIEVEPAD_URL = 'https://sievepad.com/'; + +const SIEVEPAD_FORMAT_VERSION = 1; +const SIEVEPAD_MAX_NAME_LENGTH = 80; +const SIEVEPAD_MAIN_SCRIPT = 'main'; +const BASE64_CHUNK_SIZE = 0x8000; +const WARNING_DISMISSED_KEY = 'stalwart-sievepad-warning-dismissed'; + +const SIEVE_SCRIPT_FIELDS: Record = { + 'x:SieveSystemScript': 'contents', + 'x:SieveUserScript': 'contents', + SieveScript: 'blobId', +}; + +export function isSieveScriptField(objectName: string, fieldName: string): boolean { + return SIEVE_SCRIPT_FIELDS[objectName] === fieldName; +} + +function toBase64Url(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i += BASE64_CHUNK_SIZE) { + binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_SIZE)); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export async function sievepadLink(name: string, source: string, base = SIEVEPAD_URL): 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: {}, + }); + 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 { + const tab = window.open('about:blank', '_blank'); + if (tab) tab.opener = null; + let link: string; + try { + link = await sievepadLink(name, source); + } catch (err) { + tab?.close(); + throw err; + } + if (tab) { + tab.location.replace(link); + } else { + window.open(link, '_blank', 'noopener,noreferrer'); + } +} + +export function isSievepadWarningDismissed(): boolean { + try { + return localStorage.getItem(WARNING_DISMISSED_KEY) === 'true'; + } catch { + return false; + } +} + +export function dismissSievepadWarning(): void { + try { + localStorage.setItem(WARNING_DISMISSED_KEY, 'true'); + } catch { + return; + } +}