Sievepad integration

This commit is contained in:
Maurus Decimus
2026-09-15 09:27:02 +02:00
parent b0b8e4e090
commit dc462b137f
9 changed files with 271 additions and 12 deletions
+47
View File
@@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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);
});
});
+77
View File
@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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<string, string> = {
'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<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: {},
});
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> {
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;
}
}