Merge branch 'dev'

This commit is contained in:
Linus Rath
2026-04-16 18:51:01 +02:00
49 changed files with 3530 additions and 1002 deletions
+7 -2
View File
@@ -196,10 +196,15 @@ describe('sieve generator', () => {
expect(result.vacation?.isEnabled).toBe(true);
});
it('should mark as opaque when real filter rules exist alongside vacation', () => {
it('parses filter rules alongside vacation as external when no metadata is present', () => {
const script = `require ["vacation", "fileinto"];\n\nvacation "Away";\n\nif header :contains "From" "boss@example.com" {\n fileinto "Important";\n}\n`;
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
// New behavior: preserve both the vacation statement (as opaque) and
// the if-block (as a structured external rule) instead of dropping them.
expect(result.isOpaque).toBe(false);
const ifRule = result.rules.find(r => r.origin === 'external');
expect(ifRule?.conditions[0]).toMatchObject({ field: 'from', comparator: 'contains', value: 'boss@example.com' });
expect(ifRule?.actions[0]).toEqual({ type: 'move', value: 'Important' });
});
it('should handle Stalwart :mime format vacation script', () => {
+5
View File
@@ -39,6 +39,8 @@ export interface FilterAction {
value?: string;
}
export type FilterOrigin = 'bulwark' | 'external' | 'opaque';
export interface FilterRule {
id: string;
name: string;
@@ -47,6 +49,9 @@ export interface FilterRule {
conditions: FilterCondition[];
actions: FilterAction[];
stopProcessing: boolean;
origin?: FilterOrigin;
originLabel?: string;
rawBlock?: string;
}
export interface VacationSieveConfig {
+30
View File
@@ -39,6 +39,9 @@ export interface Email {
// S/MIME support
blobId?: string;
bodyStructure?: EmailBodyPart;
// Unified mailbox support — set when displaying emails from multiple accounts
accountId?: string;
accountLabel?: string;
}
export interface AuthenticationResults {
@@ -724,4 +727,31 @@ export interface FileNodeFilter {
parentId?: string | null;
name?: string;
type?: string;
}
// Unified mailbox virtual IDs and types
export const UNIFIED_INBOX = '__unified_inbox__';
export const UNIFIED_SENT = '__unified_sent__';
export const UNIFIED_DRAFTS = '__unified_drafts__';
export const UNIFIED_TRASH = '__unified_trash__';
export const UNIFIED_ARCHIVE = '__unified_archive__';
export const UNIFIED_JUNK = '__unified_junk__';
export type UnifiedMailboxRole = 'inbox' | 'sent' | 'drafts' | 'trash' | 'archive' | 'junk';
export const UNIFIED_MAILBOX_IDS: Record<UnifiedMailboxRole, string> = {
inbox: UNIFIED_INBOX,
sent: UNIFIED_SENT,
drafts: UNIFIED_DRAFTS,
trash: UNIFIED_TRASH,
archive: UNIFIED_ARCHIVE,
junk: UNIFIED_JUNK,
};
export const UNIFIED_ROLE_BY_ID: Record<string, UnifiedMailboxRole> = Object.fromEntries(
Object.entries(UNIFIED_MAILBOX_IDS).map(([role, id]) => [id, role as UnifiedMailboxRole])
) as Record<string, UnifiedMailboxRole>;
export function isUnifiedMailboxId(id: string): boolean {
return id in UNIFIED_ROLE_BY_ID;
}
+27 -2
View File
@@ -14,6 +14,7 @@ import type {
AdminPageSection,
CalendarEventAction,
SlotName,
PluginI18n,
} from './plugin-types';
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
import {
@@ -22,8 +23,9 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks,
sidebarAppHooks, avatarHooks, renderHooks,
} from './plugin-hooks';
import { createPluginI18n } from './plugin-i18n';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { apiFetch } from '@/lib/browser-navigation';
@@ -110,6 +112,8 @@ function createPluginLogger(pluginId: string) {
export interface PluginAPI {
plugin: { id: string; version: string; settings: Record<string, unknown> };
/** Localisation API — register translations and call t() to get strings */
i18n: PluginI18n;
ui: {
registerToolbarAction: (action: ToolbarAction) => Disposable;
registerEmailBanner: (factory: BannerFactory) => Disposable;
@@ -150,6 +154,8 @@ export interface PluginHooksAPI {
onEmailClose: (handler: () => void) => Disposable;
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept — receives ComposeOptions, may mutate fields, return false to cancel */
onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise<boolean | void>) => Disposable;
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -158,6 +164,10 @@ export interface PluginHooksAPI {
onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Emitted after emails are moved to the Archive mailbox */
onEmailArchive: (handler: (emailIds: string[]) => void) => Disposable;
/** Emitted after emails are moved out of the Archive mailbox */
onEmailUnarchive: (handler: (emailIds: string[]) => void) => Disposable;
onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -174,6 +184,8 @@ export interface PluginHooksAPI {
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept — receives MailtoContext, return false to prevent the system mail client */
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
// Calendar
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -216,6 +228,8 @@ export interface PluginHooksAPI {
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept — receives { file: FileResourceView, newName: string }, return false to cancel */
onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise<boolean | void>) => Disposable;
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -317,6 +331,9 @@ export interface PluginHooksAPI {
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Avatar
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
// Render — transform hook for email list row badges
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
}
// --- Permission mapping for hooks ----------------------------
@@ -325,14 +342,17 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
// Email
onEmailOpen: 'email:read', onEmailClose: 'email:read',
onEmailContentRender: 'email:read', onThreadExpand: 'email:read',
onComposerOpen: 'email:read', onDraftAutoSave: 'email:read',
onBeforeCompose: 'email:read', onComposerOpen: 'email:read',
onDraftAutoSave: 'email:read',
onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
onSearch: 'email:read', onSearchResults: 'email:read',
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
@@ -359,6 +379,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
onBeforeFileRename: 'files:write',
onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
// Auth
@@ -470,6 +491,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(sidebarAppHooks)),
// Avatar
...Object.fromEntries(Object.entries(avatarHooks)),
// Render
...Object.fromEntries(Object.entries(renderHooks)),
};
// --- Slot registration bridge --------------------------------
@@ -531,6 +554,8 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
settings: { ...plugin.settings },
},
i18n: createPluginI18n(plugin.id),
ui: {
registerToolbarAction: (action: ToolbarAction) => {
requirePermission(plugin, 'ui:toolbar');
+26 -1
View File
@@ -172,6 +172,10 @@ export const emailHooks = {
onEmailClose: new HookBus(),
onEmailContentRender: new HookBus(),
onThreadExpand: new HookBus(),
// Intercept hook — fires before the composer opens.
// Handlers receive ComposeOptions and may mutate fields in place.
// Return false to cancel opening the composer.
onBeforeCompose: new HookBus(),
onComposerOpen: new HookBus(),
onBeforeEmailSend: new HookBus(),
onAfterEmailSend: new HookBus(),
@@ -180,6 +184,10 @@ export const emailHooks = {
onAfterEmailDelete: new HookBus(),
onBeforeEmailMove: new HookBus(),
onAfterEmailMove: new HookBus(),
// Fired after one or more emails are archived to the Archive mailbox
onEmailArchive: new HookBus(),
// Fired after one or more emails are moved out of the Archive mailbox
onEmailUnarchive: new HookBus(),
onEmailReadStateChange: new HookBus(),
onEmailStarToggle: new HookBus(),
onEmailSpamToggle: new HookBus(),
@@ -196,6 +204,9 @@ export const emailHooks = {
onNewEmailReceived: new HookBus(),
onPushConnectionChange: new HookBus(),
onQuotaChange: new HookBus(),
// Intercept hook — fired when a mailto: link is clicked.
// Return false to prevent the browser from opening the system mail client.
onMailtoIntercept: new HookBus(),
};
// §7.2 Calendar Hooks
@@ -250,6 +261,10 @@ export const fileHooks = {
onDirectoryCreate: new HookBus(),
onBeforeFileDelete: new HookBus(),
onAfterFileDelete: new HookBus(),
// Intercept hook — fires before a file is renamed.
// Receives { file: FileResourceView, newName: string }.
// Return false to cancel the rename.
onBeforeFileRename: new HookBus(),
onFileRename: new HookBus(),
onFileMove: new HookBus(),
onFileCopy: new HookBus(),
@@ -406,6 +421,16 @@ export const avatarHooks = {
onAvatarResolve: new HookBus(),
};
// §7.22 Render Hooks
export const renderHooks = {
// Transform hook — runs for each visible email list row.
// Initial value: EmailListBadge[] (always starts as [])
// Second argument: { emailId: string; email: EmailReadView }
// Handlers return a new (or extended) badges array.
// Rendered by the email list row component next to the subject line.
onEmailListItemRender: new HookBus(),
};
// ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [
@@ -414,7 +439,7 @@ const allHookGroups = [
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
avatarHooks,
avatarHooks, renderHooks,
];
export function removeAllPluginHooks(pluginId: string): void {
+118
View File
@@ -0,0 +1,118 @@
// Plugin i18n registry — manages per-plugin translation tables
//
// Each plugin gets its own namespace keyed by:
// pluginId → locale → { messageKey → translated string }
//
// Resolution order when calling t(key):
// 1. Exact locale match ("fr-CA")
// 2. Language-prefix match ("fr" from "fr-CA")
// 3. English fallback ("en")
// 4. Raw key (plugin is never broken by missing strings)
//
// Interpolation uses {paramName} placeholders.
// ─── Registry ────────────────────────────────────────────────
/** pluginId → locale → key → translated string */
const registry = new Map<string, Map<string, Record<string, string>>>();
let currentLocale = 'en';
// ─── Locale sync (called by plugin-loader) ───────────────────
/** Keep the registry in sync with the app locale */
export function setPluginI18nLocale(locale: string): void {
currentLocale = locale;
}
export function getPluginI18nLocale(): string {
return currentLocale;
}
// ─── Cleanup ─────────────────────────────────────────────────
/** Remove all translations for a plugin (called on deactivation) */
export function clearPluginI18nTranslations(pluginId: string): void {
registry.delete(pluginId);
}
// ─── Helpers ─────────────────────────────────────────────────
function interpolate(template: string, params?: Record<string, string | number>): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
}
function resolve(pluginId: string, key: string): string | undefined {
const byLocale = registry.get(pluginId);
if (!byLocale) return undefined;
// 1. Exact locale (e.g. "fr-CA")
const exact = byLocale.get(currentLocale)?.[key];
if (exact !== undefined) return exact;
// 2. Language prefix (e.g. "fr" from "fr-CA")
const lang = currentLocale.split('-')[0];
if (lang !== currentLocale) {
const langMatch = byLocale.get(lang)?.[key];
if (langMatch !== undefined) return langMatch;
}
// 3. English fallback
return byLocale.get('en')?.[key];
}
// ─── Public API factory ──────────────────────────────────────
/**
* Build the i18n API object exposed as `api.i18n` inside each plugin.
*
* @example
* // In your plugin activate():
* api.i18n.addTranslations('en', { 'banner.title': 'Hello' });
* api.i18n.addTranslations('de', { 'banner.title': 'Hallo' });
*
* // Later, in any React component the plugin renders:
* const title = api.i18n.t('banner.title');
* const greeting = api.i18n.t('welcome', { name: 'Alice' }); // 'Hello, {name}!'
*/
export function createPluginI18n(pluginId: string) {
return {
/**
* Register translations for one locale.
* Multiple calls for the same locale are merged (last-write-wins on key collision).
*
* @param locale BCP-47 locale tag, e.g. "en", "de", "fr-CA"
* @param strings Key → translated string map. Use {paramName} for interpolation.
*/
addTranslations(locale: string, strings: Record<string, string>): void {
let byLocale = registry.get(pluginId);
if (!byLocale) {
byLocale = new Map<string, Record<string, string>>();
registry.set(pluginId, byLocale);
}
const existing = byLocale.get(locale) ?? {};
byLocale.set(locale, { ...existing, ...strings });
},
/**
* Translate a key using the current app locale.
* Falls back through: exact locale → language prefix → 'en' → raw key.
*
* @param key Translation key, e.g. `'banner.title'`
* @param params Optional interpolation values, e.g. `{ count: 3 }`
*/
t(key: string, params?: Record<string, string | number>): string {
const template = resolve(pluginId, key);
if (template !== undefined) return interpolate(template, params);
return key; // never throw — just return the key
},
/** The current app locale (e.g. "en", "de", "fr") */
getLocale(): string {
return currentLocale;
},
};
}
export type PluginI18nInstance = ReturnType<typeof createPluginI18n>;
+25 -1
View File
@@ -1,15 +1,18 @@
// Plugin Loader — loads and activates plugins via blob URL dynamic import
// Plugin Loader loads and activates plugins via blob URL dynamic import
import type { InstalledPlugin, Disposable } from './plugin-types';
import { pluginStorage } from './plugin-storage';
import { createPluginAPI, type PluginAPI } from './plugin-api';
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n';
import React from 'react';
import ReactDOM from 'react-dom';
import * as ReactJSX from 'react/jsx-runtime';
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
let localeSyncInitialised = false;
export function exposePluginExternals(): void {
if (typeof window === 'undefined') return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -18,6 +21,16 @@ export function exposePluginExternals(): void {
ReactDOM,
ReactJSX,
};
// Sync plugin i18n with the app locale (runs once per page load)
if (!localeSyncInitialised) {
localeSyncInitialised = true;
// Dynamic import avoids a circular dependency chain at module evaluation time
import('@/stores/locale-store').then(({ useLocaleStore }) => {
setPluginI18nLocale(useLocaleStore.getState().locale);
useLocaleStore.subscribe((state) => setPluginI18nLocale(state.locale));
}).catch(() => {/* locale sync is best-effort */});
}
}
// --- Active plugin tracking ----------------------------------
@@ -78,6 +91,14 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
// 4. Build sandboxed API
const api = createPluginAPI(plugin);
// 4b. Auto-register translations bundled in the manifest (plugin.locales)
// Plugins may still call api.i18n.addTranslations() in activate() to add more.
if (plugin.locales) {
for (const [locale, strings] of Object.entries(plugin.locales)) {
api.i18n.addTranslations(locale, strings);
}
}
// 5. Call activate
const disposable = await mod.activate(api);
@@ -119,6 +140,9 @@ export function deactivatePlugin(pluginId: string): void {
// Remove all hook subscriptions for this plugin
removeAllPluginHooks(pluginId);
// Clear cached translations (avoids memory leak on repeated enable/disable cycles)
clearPluginI18nTranslations(pluginId);
// Reset error tracker
pluginErrorTracker.reset(pluginId);
+89
View File
@@ -34,6 +34,13 @@ export interface PluginManifest {
entrypoint: string;
minAppVersion?: string;
settingsSchema?: Record<string, SettingFieldSchema>;
/**
* Bundled translations shipped inside the plugin ZIP.
* Keyed by BCP-47 locale tag ("en", "de", "fr-CA", …).
* The loader auto-registers these before calling activate(),
* so plugins can use api.i18n.t() without calling addTranslations() first.
*/
locales?: Record<string, Record<string, string>>;
}
export interface SettingFieldSchema {
@@ -83,6 +90,8 @@ export interface InstalledPlugin {
adminApproved?: boolean;
settingsSchema?: Record<string, SettingFieldSchema>;
settings: Record<string, unknown>;
/** Bundled translations, carried over from the manifest on install. */
locales?: Record<string, Record<string, string>>;
}
// ─── UI Slots ────────────────────────────────────────────────
@@ -388,6 +397,86 @@ export interface ComposerContext {
originalSubject?: string;
}
// ─── New hook context types ──────────────────────────────────
/**
* Passed to onBeforeCompose handlers.
* Handlers may mutate the object in place to pre-fill fields; returning false cancels the compose.
*/
export interface ComposeOptions {
to: string[];
cc: string[];
subject: string;
body: string;
mode: 'new' | 'reply' | 'reply-all' | 'forward';
}
/**
* A small visual indicator injected into an email list row via onEmailListItemRender.
*/
export interface EmailListBadge {
/** Stable unique key within the plugin — used as React key */
key: string;
/** Short label text displayed in the badge */
label: string;
/** CSS color value for the badge background, e.g. "#e74c3c" or "var(--color-warning)" */
color?: string;
/** Tooltip / aria-label */
title?: string;
}
/**
* Passed to onMailtoIntercept handlers.
* Return false to prevent the browser from opening the system mail client.
*/
export interface MailtoContext {
/** The raw href, e.g. "mailto:alice@example.com?subject=Hello" */
href: string;
/** Parsed list of recipient addresses */
to: string[];
subject?: string;
body?: string;
}
// ─── Plugin i18n API ─────────────────────────────────────────
/**
* Localisation API exposed as `api.i18n` inside every plugin.
*
* Plugins ship their own translation tables; the app locale is tracked
* automatically so `t()` always returns the right string without any
* extra setup from the plugin side.
*/
export interface PluginI18n {
/**
* Register translations for one locale.
* Multiple calls for the same locale are merged (last-write-wins per key).
*
* @param locale BCP-47 tag, e.g. "en", "de", "fr-CA"
* @param strings Key → translated string map. Use {paramName} for interpolation.
*
* @example
* api.i18n.addTranslations('en', { 'banner.title': 'Tracking blocked' });
* api.i18n.addTranslations('de', { 'banner.title': 'Tracking blockiert' });
*/
addTranslations(locale: string, strings: Record<string, string>): void;
/**
* Return the translated string for `key` using the current app locale,
* with optional {param} interpolation.
*
* Falls back: exact locale → language prefix → "en" → raw key.
*
* @example
* api.i18n.t('banner.title')
* api.i18n.t('items_found', { count: 3 }) // 'Found {count} items' → 'Found 3 items'
*/
t(key: string, params?: Record<string, string | number>): string;
/** The current app locale string (e.g. "en", "de", "fr") */
getLocale(): string;
}
// ─── Permission Reference ────────────────────────────────────
export const ALL_PERMISSIONS = [
+267
View File
@@ -0,0 +1,267 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { parseScript } from '../parser';
import { generateScript } from '../generator';
import type { FilterRule } from '@/lib/jmap/sieve-types';
function makeBulwarkRule(overrides: Partial<FilterRule> = {}): FilterRule {
return {
id: 'bw-1',
name: 'Bulwark Rule',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
actions: [{ type: 'move', value: 'Archive' }],
stopProcessing: false,
...overrides,
};
}
describe('external rule preservation (issue #201)', () => {
describe('parser — external rule recognition', () => {
it('parses a Roundcube-style rule with "# rule:[Name]" comment', () => {
const script = `require ["fileinto"];\n\n# rule:[Archive Newsletters]\nif header :contains "List-Id" "news" {\n fileinto "Newsletters";\n}\n`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toHaveLength(1);
const rule = result.rules[0];
expect(rule.origin).toBe('external');
expect(rule.originLabel).toBe('Roundcube');
expect(rule.name).toBe('Archive Newsletters');
expect(rule.conditions[0]).toMatchObject({
field: 'header',
comparator: 'contains',
value: 'news',
headerName: 'List-Id',
});
expect(rule.actions[0]).toEqual({ type: 'move', value: 'Newsletters' });
});
it('labels rules near a Nextcloud marker comment', () => {
const script = `require ["fileinto"];\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
const result = parseScript(script);
expect(result.rules[0].originLabel).toBe('Nextcloud');
});
it('falls back to "External" label when no known marker is present', () => {
const script = `require ["fileinto"];\n\nif header :contains "From" "boss@corp.com" {\n fileinto "Important";\n}\n`;
const result = parseScript(script);
expect(result.rules[0].originLabel).toBe('External');
});
it('parses anyof/allof conditions in external rules', () => {
const script = `require ["fileinto"];\n\nif anyof(header :contains "From" "a@x.com", header :contains "From" "b@x.com") {\n fileinto "VIP";\n}\n`;
const result = parseScript(script);
expect(result.rules[0].matchType).toBe('any');
expect(result.rules[0].conditions).toHaveLength(2);
});
it('parses negated conditions (not header :is)', () => {
const script = `if not header :is "From" "spam@x.com" {\n keep;\n}\n`;
const result = parseScript(script);
expect(result.rules[0].conditions[0]).toMatchObject({
field: 'from',
comparator: 'not_is',
value: 'spam@x.com',
});
});
it('marks unrecognized blocks as opaque but preserves their raw text', () => {
const script = `require ["relational"];\n\nif header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {\n keep;\n}\n`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
const rule = result.rules[0];
expect(rule.origin).toBe('opaque');
expect(rule.rawBlock).toContain('if header :value');
});
it('collects all external require tokens', () => {
const script = `require ["fileinto", "imap4flags", "body"];\n\nif header :is "Subject" "hi" { fileinto "A"; }\n`;
const result = parseScript(script);
expect(result.externalRequires).toEqual(expect.arrayContaining(['fileinto', 'imap4flags', 'body']));
});
});
describe('parser — mixed Bulwark + external', () => {
it('returns Bulwark rules from metadata and external rules from the rest', () => {
const bulwark = [makeBulwarkRule({ name: 'Bulwark A' })];
const bulwarkScript = generateScript(bulwark);
const mixedScript = `${bulwarkScript}\n# External appended by Nextcloud\nif header :contains "List-Id" "devs" {\n fileinto "Dev";\n}\n`;
const result = parseScript(mixedScript);
expect(result.isOpaque).toBe(false);
expect(result.rules.length).toBeGreaterThanOrEqual(2);
const bulwarkParsed = result.rules.filter(r => !r.origin || r.origin === 'bulwark');
const externalParsed = result.rules.filter(r => r.origin === 'external');
expect(bulwarkParsed).toHaveLength(1);
expect(bulwarkParsed[0].name).toBe('Bulwark A');
expect(externalParsed).toHaveLength(1);
expect(externalParsed[0].originLabel).toBe('Nextcloud');
});
it('does not return Bulwark-emitted if-blocks as external duplicates', () => {
const bulwark = [makeBulwarkRule({ name: 'My Bulwark Rule' })];
const script = generateScript(bulwark);
const result = parseScript(script);
// Only the metadata-sourced rule, no duplicate "external" entry.
expect(result.rules).toHaveLength(1);
expect(result.rules[0].origin).toBeUndefined();
});
});
describe('generator — external splice', () => {
it('appends external rawBlocks verbatim after Bulwark-managed output', () => {
const externalRule: FilterRule = {
id: 'ext-0',
name: 'External',
enabled: true,
matchType: 'all',
conditions: [{ field: 'header', comparator: 'contains', value: 'x', headerName: 'List-Id' }],
actions: [{ type: 'move', value: 'Lists' }],
stopProcessing: false,
origin: 'external',
originLabel: 'Nextcloud',
rawBlock: '# Nextcloud Mail\nif header :contains "List-Id" "x" {\n fileinto "Lists";\n}\n',
};
const rules: FilterRule[] = [makeBulwarkRule(), externalRule];
const script = generateScript(rules);
expect(script).toContain('# Rule: Bulwark Rule');
expect(script).toContain('# Nextcloud Mail');
expect(script).toContain('# --- External rules (managed outside Bulwark) ---');
const bulwarkIdx = script.indexOf('# Rule: Bulwark Rule');
const externalIdx = script.indexOf('# Nextcloud Mail');
expect(bulwarkIdx).toBeLessThan(externalIdx);
});
it('unions external requires into the top-level require line', () => {
const script = generateScript([makeBulwarkRule()], undefined, {
externalRequires: ['fileinto', 'imap4flags', 'body'],
});
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
expect(requireLine).toContain('"fileinto"');
expect(requireLine).toContain('"imap4flags"');
expect(requireLine).toContain('"body"');
});
it('strips origin/rawBlock/originLabel from Bulwark rules when writing metadata', () => {
const bulwarkWithJunk: FilterRule = {
...makeBulwarkRule(),
origin: 'bulwark',
originLabel: 'shouldnotbehere',
rawBlock: 'shouldnotbehere',
};
const script = generateScript([bulwarkWithJunk]);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
const metadata = JSON.parse(match![1]);
expect(metadata.rules[0]).not.toHaveProperty('origin');
expect(metadata.rules[0]).not.toHaveProperty('originLabel');
expect(metadata.rules[0]).not.toHaveProperty('rawBlock');
});
it('never writes external rules into metadata', () => {
const ext: FilterRule = {
id: 'ext-0',
name: 'Ext',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'is', value: 'x@y' }],
actions: [{ type: 'keep' }],
stopProcessing: false,
origin: 'external',
rawBlock: '# ext\nif header :is "From" "x@y" { keep; }',
};
const script = generateScript([makeBulwarkRule(), ext]);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
const metadata = JSON.parse(match![1]);
expect(metadata.rules).toHaveLength(1);
expect(metadata.rules[0].name).toBe('Bulwark Rule');
});
});
describe('fixture: mixed-origins.sieve', () => {
const fixture = readFileSync(
join(__dirname, 'fixtures', 'mixed-origins.sieve'),
'utf-8',
);
it('identifies Bulwark, Roundcube, Nextcloud, External, and opaque rules', () => {
const result = parseScript(fixture);
expect(result.isOpaque).toBe(false);
expect(result.vacation).toBeUndefined();
const byOrigin = {
bulwark: result.rules.filter(r => !r.origin || r.origin === 'bulwark'),
external: result.rules.filter(r => r.origin === 'external'),
opaque: result.rules.filter(r => r.origin === 'opaque'),
};
expect(byOrigin.bulwark).toHaveLength(2);
expect(byOrigin.external.length).toBeGreaterThanOrEqual(3);
expect(byOrigin.opaque).toHaveLength(1);
const labels = byOrigin.external.map(r => r.originLabel);
expect(labels).toContain('Roundcube');
expect(labels).toContain('Nextcloud');
expect(labels).toContain('External');
const opaqueRule = byOrigin.opaque[0];
expect(opaqueRule.rawBlock).toContain(':comparator "i;ascii-numeric"');
});
it('preserves unknown-Sieve content through save round-trip', () => {
const parsed = parseScript(fixture);
const regenerated = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
});
// The unparseable construct must appear verbatim in the regenerated script.
expect(regenerated).toContain(':comparator "i;ascii-numeric"');
// Require tokens from the external content are preserved.
expect(regenerated).toContain('"relational"');
// Bulwark rules are still present.
expect(regenerated).toContain('# Rule: Archive newsletters');
});
});
describe('round-trip', () => {
it('preserves external rules through parse → generate → parse', () => {
const initial = `require ["fileinto", "imap4flags"];\n\n# rule:[VIP]\nif header :contains "From" "boss@company.com" {\n fileinto "VIP";\n addflag "\\\\Flagged";\n}\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
const firstParse = parseScript(initial);
expect(firstParse.rules).toHaveLength(2);
const regenerated = generateScript(firstParse.rules, firstParse.vacation, {
externalRequires: firstParse.externalRequires,
});
const secondParse = parseScript(regenerated);
expect(secondParse.rules).toHaveLength(2);
const names = secondParse.rules.map(r => r.name).sort();
expect(names).toContain('VIP');
});
it('does not destroy external rules when Bulwark regenerates after an edit', () => {
const initial = `${generateScript([makeBulwarkRule({ name: 'Mine' })])}\n# rule:[Untouchable]\nif header :is "X-Spam" "yes" {\n discard;\n}\n`;
const parsed = parseScript(initial);
const externalBefore = parsed.rules.filter(r => r.origin === 'external');
expect(externalBefore).toHaveLength(1);
// Simulate a user edit — update the Bulwark rule name
const edited = parsed.rules.map(r => (r.origin === 'external' || r.origin === 'opaque' ? r : { ...r, name: 'Mine (edited)' }));
const regenerated = generateScript(edited, parsed.vacation, { externalRequires: parsed.externalRequires });
const reparsed = parseScript(regenerated);
const externalAfter = reparsed.rules.filter(r => r.origin === 'external');
expect(externalAfter).toHaveLength(1);
expect(externalAfter[0].name).toBe('Untouchable');
expect(externalAfter[0].conditions[0]).toMatchObject({ field: 'header', headerName: 'X-Spam' });
});
});
});
@@ -0,0 +1,44 @@
/* @metadata:begin
{"version":1,"rules":[{"id":"bw-news","name":"Archive newsletters","enabled":true,"matchType":"any","conditions":[{"field":"header","comparator":"contains","value":"unsubscribe","headerName":"List-Unsubscribe"},{"field":"from","comparator":"contains","value":"newsletter@"}],"actions":[{"type":"move","value":"Newsletters"}],"stopProcessing":false},{"id":"bw-vip","name":"Flag VIP senders","enabled":true,"matchType":"any","conditions":[{"field":"from","comparator":"is","value":"ceo@company.com"},{"field":"from","comparator":"is","value":"board@company.com"}],"actions":[{"type":"star"},{"type":"mark_read"}],"stopProcessing":false}]}
@metadata:end */
require ["body", "copy", "fileinto", "imap4flags", "relational"];
# Rule: Archive newsletters
if anyof(header :contains "List-Unsubscribe" "unsubscribe", header :contains "From" "newsletter@") {
fileinto "Newsletters";
}
# Rule: Flag VIP senders
if anyof(header :is "From" "ceo@company.com", header :is "From" "board@company.com") {
addflag "\\Flagged";
addflag "\\Seen";
}
# --- External rules (managed outside Bulwark) ---
# rule:[Finance — auto-file invoices]
if allof(header :contains "From" "billing@", header :contains "Subject" "invoice") {
fileinto :copy "Finance/Invoices";
keep;
}
# Nextcloud Mail - begin
# Filter installed by Nextcloud Mail app
if header :contains "Subject" "[Support]" {
fileinto "Support";
}
# Nextcloud Mail - end
# A handwritten rule without a tool-specific marker.
# Bulwark should recognize this as generic "External" and preserve it.
if not header :is "X-Spam-Status" "No" {
fileinto "Junk";
}
# A rule using a Sieve construct Bulwark's visual editor does not understand.
# It must survive round-trips verbatim, shown to the user as read-only.
if header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {
fileinto "LowPriority";
stop;
}
+2 -2
View File
@@ -194,9 +194,9 @@ describe('generateScript', () => {
expect(script).toContain('addflag "\\\\Flagged";');
});
it('generates add_label as addflag $Label', () => {
it('generates add_label as addflag $label:Label', () => {
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'Important' }] })]);
expect(script).toContain('addflag "$Important";');
expect(script).toContain('addflag "$label:Important";');
});
it('generates discard', () => {
+10 -5
View File
@@ -25,10 +25,14 @@ describe('parseScript', () => {
expect(result.rules).toEqual(rules);
});
it('returns isOpaque for missing metadata', () => {
it('parses external rules when no Bulwark metadata is present', () => {
const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }');
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
expect(result.isOpaque).toBe(false);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].origin).toBe('external');
expect(result.rules[0].conditions[0]).toMatchObject({ field: 'from', comparator: 'contains', value: 'x' });
expect(result.rules[0].actions[0]).toEqual({ type: 'move', value: 'Y' });
expect(result.externalRequires).toContain('fileinto');
});
it('returns isOpaque for corrupted JSON', () => {
@@ -90,9 +94,10 @@ describe('parseScript', () => {
expect(result.isOpaque).toBe(true);
});
it('returns isOpaque for empty string', () => {
it('treats an empty string as an empty, editable script (not opaque)', () => {
const result = parseScript('');
expect(result.isOpaque).toBe(true);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual([]);
});
describe('round-trip', () => {
+59 -9
View File
@@ -65,7 +65,7 @@ function generateActions(actions: FilterAction[]): string[] {
case 'star':
return 'addflag "\\\\Flagged";';
case 'add_label':
return `addflag "$${escapeString(action.value || '')}";`;
return `addflag "$label:${escapeString(action.value || '')}";`;
case 'discard':
return 'discard;';
case 'reject':
@@ -111,11 +111,47 @@ function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): s
}
}
return [...extensions].sort();
return [...extensions];
}
export function generateScript(rules: FilterRule[], vacation?: VacationSieveConfig): string {
const metadata: FilterMetadata = { version: 1, rules };
function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'originLabel' | 'rawBlock'> {
return {
id: r.id,
name: r.name,
enabled: r.enabled,
matchType: r.matchType,
conditions: r.conditions,
actions: r.actions,
stopProcessing: r.stopProcessing,
};
}
export interface GenerateOptions {
/**
* Require extensions used by external (non-Bulwark) rules that we must
* preserve in the top-level `require` directive. Duplicates with Bulwark's
* own requires are deduplicated.
*/
externalRequires?: string[];
}
export function generateScript(
rules: FilterRule[],
vacation?: VacationSieveConfig,
options: GenerateOptions = {},
): string {
// Partition rules by origin. Treat missing origin as 'bulwark' for back-compat.
const bulwarkRules: FilterRule[] = [];
const externalRules: FilterRule[] = [];
for (const r of rules) {
if (r.origin && r.origin !== 'bulwark') externalRules.push(r);
else bulwarkRules.push(r);
}
const metadata: FilterMetadata = {
version: 1,
rules: bulwarkRules.map(stripRuleForMetadata) as FilterRule[],
};
if (vacation?.isEnabled) {
metadata.vacation = vacation;
}
@@ -127,9 +163,12 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
lines.push('@metadata:end */');
lines.push('');
const requires = computeRequires(rules, vacation);
if (requires.length > 0) {
lines.push(`require [${requires.map(r => `"${r}"`).join(', ')}];`);
const bulwarkRequires = computeRequires(bulwarkRules, vacation);
const externalRequires = options.externalRequires ?? [];
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
if (allRequires.length > 0) {
lines.push(`require [${allRequires.map(r => `"${r}"`).join(', ')}];`);
}
if (vacation?.isEnabled) {
@@ -143,9 +182,9 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
lines.push(`vacation ${vacationParts.join(' ')};`);
}
const enabledRules = rules.filter(r => r.enabled);
const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled);
for (const rule of enabledRules) {
for (const rule of enabledBulwarkRules) {
if (rule.conditions.length === 0 || rule.actions.length === 0) {
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
continue;
@@ -182,6 +221,17 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
lines.push('}');
}
// Append preserved external rules verbatim. Each rawBlock already carries its
// own leading comments and trailing whitespace from the source script.
if (externalRules.length > 0) {
lines.push('');
lines.push('# --- External rules (managed outside Bulwark) ---');
for (const ext of externalRules) {
if (!ext.rawBlock) continue;
lines.push(ext.rawBlock.replace(/\s+$/, ''));
}
}
lines.push('');
return lines.join('\n');
}
+527 -38
View File
@@ -1,17 +1,33 @@
import type { FilterRule, FilterMetadata, VacationSieveConfig } from '@/lib/jmap/sieve-types';
import type {
FilterAction,
FilterCondition,
FilterComparator,
FilterConditionField,
FilterMetadata,
FilterRule,
VacationSieveConfig,
} from '@/lib/jmap/sieve-types';
import { debug } from '@/lib/debug';
export interface ParseResult {
rules: FilterRule[];
isOpaque: boolean;
vacation?: VacationSieveConfig;
externalRequires: string[];
}
const OPAQUE: ParseResult = { rules: [], isOpaque: true };
const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
const METADATA_BEGIN = '/* @metadata:begin';
const METADATA_END = '@metadata:end */';
const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
from: 'from',
to: 'to',
cc: 'cc',
subject: 'subject',
};
function isValidCondition(c: unknown): boolean {
if (!c || typeof c !== 'object') return false;
const cond = c as Record<string, unknown>;
@@ -42,83 +58,556 @@ function isValidRule(rule: unknown): rule is FilterRule {
/**
* Detect Stalwart-generated vacation-only scripts (no metadata).
* These contain `vacation` command but no other filter logic we need to preserve.
*/
function detectVacationOnlyScript(content: string): ParseResult | null {
// Must contain a vacation command
if (!/\bvacation\b/.test(content)) return null;
// Strip requires, comments, and whitespace to see if only vacation remains
const stripped = content
.replace(/^\s*require\s+\[[^\]]*\]\s*;/gm, '')
.replace(/#[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim();
// Strip quoted string *contents* before checking for structural keywords so that
// message body text like "if you need urgent help..." doesn't cause false rejection.
const structural = stripped.replace(/"(?:[^"\\]|\\.)*"/g, '""');
// Check there are no if/elsif/else filter blocks
if (/\b(?:if|elsif|else)\b/.test(structural)) return null;
// Must still have a vacation command after stripping boilerplate
if (!/\bvacation\b/.test(structural)) return null;
// Extract subject if present (:subject "...")
const subjectMatch = stripped.match(/:subject\s+"((?:[^"\\]|\\.)*)"/);
const subject = subjectMatch ? subjectMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : '';
const subject = subjectMatch ? unescapeSieveString(subjectMatch[1]) : '';
// Extract the body text. Stalwart uses :mime format where the body is a full MIME
// message. Extract the plain text after the Content-Transfer-Encoding header.
// Handle both LF and CRLF line endings.
let textBody = '';
const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\r\n]*\r?\n\r?\n([\s\S]*?)"[\s\S]*?;/);
if (mimeBodyMatch) {
textBody = mimeBodyMatch[1].trim();
} else {
// Plain format: last quoted string argument in the vacation statement
const allQuoted = [...stripped.matchAll(/"((?:[^"\\]|\\.)*)"/g)];
const last = allQuoted[allQuoted.length - 1];
if (last) {
textBody = last[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
if (last) textBody = unescapeSieveString(last[1]);
}
return {
rules: [],
isOpaque: false,
vacation: { isEnabled: true, subject, textBody },
externalRequires: [],
};
}
function unescapeSieveString(s: string): string {
return s.replace(/\\(.)/g, '$1');
}
function skipStringLit(s: string, i: number): number {
i++;
while (i < s.length) {
if (s[i] === '\\') { i += 2; continue; }
if (s[i] === '"') return i + 1;
i++;
}
return i;
}
function skipHashComment(s: string, i: number): number {
while (i < s.length && s[i] !== '\n') i++;
return i;
}
function skipBlockComment(s: string, i: number): number {
const end = s.indexOf('*/', i + 2);
return end === -1 ? s.length : end + 2;
}
function skipStatement(s: string, i: number): number {
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
if (c === ';') return i + 1;
i++;
}
return i;
}
function skipBalanced(s: string, i: number, open: string, close: string): number {
let depth = 0;
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
if (c === open) { depth++; i++; continue; }
if (c === close) {
depth--;
i++;
if (depth === 0) return i;
continue;
}
i++;
}
return i;
}
function skipIfStatement(s: string, i: number): number {
// positioned after 'if' keyword; skip through condition expression and body braces
while (i < s.length && s[i] !== '{') {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '(') { i = skipBalanced(s, i, '(', ')'); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
i++;
}
if (i >= s.length) return i;
return skipBalanced(s, i, '{', '}');
}
interface TopBlock {
kind: 'require' | 'if' | 'vacation' | 'other';
raw: string; // from start-of-leading-text to end of statement
statement: string; // the statement itself (no leading comments/whitespace)
startIdx: number;
endIdx: number;
}
function scanTopLevel(content: string): TopBlock[] {
const blocks: TopBlock[] = [];
let i = 0;
let segmentStart = 0;
const consume = (kind: TopBlock['kind'], stmtStart: number, stmtEnd: number) => {
blocks.push({
kind,
raw: content.slice(segmentStart, stmtEnd),
statement: content.slice(stmtStart, stmtEnd),
startIdx: segmentStart,
endIdx: stmtEnd,
});
segmentStart = stmtEnd;
};
while (i < content.length) {
// Skip whitespace
while (i < content.length && /\s/.test(content[i])) i++;
if (i >= content.length) break;
const c = content[i];
// Comments (stay attached to next block as leading text)
if (c === '#') { i = skipHashComment(content, i); continue; }
if (c === '/' && content[i + 1] === '*') { i = skipBlockComment(content, i); continue; }
// Identifier
const m = /^[a-zA-Z_][a-zA-Z0-9_]*/.exec(content.slice(i));
if (!m) { i++; continue; }
const ident = m[0];
const stmtStart = i;
i += ident.length;
if (ident === 'require') {
i = skipStatement(content, i);
consume('require', stmtStart, i);
} else if (ident === 'if') {
i = skipIfStatement(content, i);
consume('if', stmtStart, i);
} else if (ident === 'vacation') {
i = skipStatement(content, i);
consume('vacation', stmtStart, i);
} else {
i = skipStatement(content, i);
consume('other', stmtStart, i);
}
}
return blocks;
}
function extractRequireTokens(stmt: string): string[] {
const mList = /require\s+\[([\s\S]*?)\]\s*;/.exec(stmt);
if (mList) return [...mList[1].matchAll(/"([^"]+)"/g)].map(x => x[1]);
const mSingle = /require\s+"([^"]+)"\s*;/.exec(stmt);
return mSingle ? [mSingle[1]] : [];
}
/**
* Extract the last contiguous block of comments immediately preceding a
* statement — comments separated from the statement by a blank line are not
* considered its leading commentary (they likely belong to the previous
* block, e.g. a trailing "# Nextcloud Mail - end" marker).
*/
function lastCommentChunk(leading: string): string {
const parts = leading.split(/\r?\n\s*\r?\n/).map(s => s.trim()).filter(Boolean);
return parts.length ? parts[parts.length - 1] : '';
}
function detectOriginLabel(leading: string): string {
const chunk = lastCommentChunk(leading);
const lower = chunk.toLowerCase();
if (/rule:\s*\[/i.test(chunk) || /roundcube|managesieve/.test(lower)) return 'Roundcube';
if (/nextcloud/.test(lower)) return 'Nextcloud';
if (/horde|ingo/.test(lower)) return 'Horde';
if (/kolab/.test(lower)) return 'Kolab';
if (/dovecot/.test(lower)) return 'Dovecot';
if (/thunderbird/.test(lower)) return 'Thunderbird';
return 'External';
}
function extractName(leading: string, fallback: string): string {
// Roundcube: "# rule:[Name]"
const rc = leading.match(/#\s*rule:\s*\[([^\]]+)\]/i);
if (rc) return rc[1].trim();
// "# Rule: Name"
const rr = leading.match(/#\s*Rule:\s*(.+?)\s*$/mi);
if (rr) return rr[1].trim();
// Last non-empty trimmed comment line
const lines = leading.split('\n').map(l => l.replace(/^\s*#\s*/, '').trim()).filter(Boolean);
const last = lines[lines.length - 1];
if (last && last.length <= 80 && !/^\/\*|\*\/$/.test(last)) return last;
return fallback;
}
function splitTopLevelComma(s: string): string[] {
const parts: string[] = [];
let depth = 0;
let start = 0;
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '(' || c === '[' || c === '{') { depth++; i++; continue; }
if (c === ')' || c === ']' || c === '}') { depth--; i++; continue; }
if (c === ',' && depth === 0) {
parts.push(s.slice(start, i));
start = i + 1;
}
i++;
}
parts.push(s.slice(start));
return parts.map(p => p.trim()).filter(Boolean);
}
function splitStatements(body: string): string[] {
const stmts: string[] = [];
let start = 0;
let i = 0;
while (i < body.length) {
const c = body[i];
if (c === '"') { i = skipStringLit(body, i); continue; }
if (c === '#') { i = skipHashComment(body, i); continue; }
if (c === '/' && body[i + 1] === '*') { i = skipBlockComment(body, i); continue; }
if (c === ';') {
stmts.push(body.slice(start, i));
start = i + 1;
}
i++;
}
const tail = body.slice(start).trim();
if (tail) stmts.push(tail);
return stmts.map(s => s.trim()).filter(Boolean);
}
function normalizeHeaderName(name: string): { field: FilterConditionField; headerName?: string } {
const lc = name.toLowerCase();
if (FIELD_FROM_HEADER[lc]) return { field: FIELD_FROM_HEADER[lc] };
return { field: 'header', headerName: name };
}
function parseAtom(raw: string): FilterCondition | null {
let s = raw.trim();
let negated = false;
if (/^not\b/.test(s)) {
negated = true;
s = s.replace(/^not\s*/, '').trim();
if (s.startsWith('(') && s.endsWith(')')) {
s = s.slice(1, -1).trim();
}
}
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) {
const [, tag, headerName, rawValue] = m;
const value = unescapeSieveString(rawValue);
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
let comparator: FilterComparator;
if (tag === 'contains') {
comparator = negated ? 'not_contains' : 'contains';
} else if (tag === 'is') {
comparator = negated ? 'not_is' : 'is';
} else {
// :matches — distinguish starts_with / ends_with / matches
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
comparator = 'starts_with';
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
if (starPositions.length === 1 && starPositions[0] === 0) {
comparator = 'ends_with';
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
comparator = 'matches';
}
const cond: FilterCondition = { field, comparator, value };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) {
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) };
}
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
if (m) {
return { field: 'size', comparator: m[1] === 'over' ? 'greater_than' : 'less_than', value: m[2] };
}
return null;
}
function parseCondition(raw: string): { matchType: 'all' | 'any'; conditions: FilterCondition[] } | null {
const s = raw.trim();
if (!s) return null;
const allMatch = /^allof\s*\(([\s\S]*)\)$/.exec(s);
const anyMatch = /^anyof\s*\(([\s\S]*)\)$/.exec(s);
let matchType: 'all' | 'any' = 'all';
let inner: string;
if (allMatch) { matchType = 'all'; inner = allMatch[1]; }
else if (anyMatch) { matchType = 'any'; inner = anyMatch[1]; }
else inner = s;
const parts = splitTopLevelComma(inner);
const conditions: FilterCondition[] = [];
for (const part of parts) {
const atom = parseAtom(part);
if (!atom) return null;
conditions.push(atom);
}
return { matchType, conditions };
}
function parseAction(raw: string): FilterAction | null {
const s = raw.trim();
let m = /^fileinto\s+:copy\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'copy', value: unescapeSieveString(m[1]) };
m = /^fileinto\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'move', value: unescapeSieveString(m[1]) };
m = /^redirect\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'forward', value: unescapeSieveString(m[1]) };
m = /^addflag\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) {
const flag = unescapeSieveString(m[1]);
if (flag === '\\Seen') return { type: 'mark_read' };
if (flag === '\\Flagged') return { type: 'star' };
if (flag.startsWith('$label:')) return { type: 'add_label', value: flag.slice('$label:'.length) };
return null;
}
m = /^reject\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'reject', value: unescapeSieveString(m[1]) };
if (/^discard$/.test(s)) return { type: 'discard' };
if (/^keep$/.test(s)) return { type: 'keep' };
if (/^stop$/.test(s)) return { type: 'stop' };
return null;
}
function parseIfBlockToRule(block: TopBlock, idPrefix: string, index: number): FilterRule | null {
const stmt = block.statement;
const afterIf = stmt.replace(/^if\s+/, '');
const braceIdx = afterIf.indexOf('{');
const lastBraceIdx = afterIf.lastIndexOf('}');
if (braceIdx === -1 || lastBraceIdx === -1 || lastBraceIdx < braceIdx) return null;
const condStr = afterIf.slice(0, braceIdx).trim();
const bodyStr = afterIf.slice(braceIdx + 1, lastBraceIdx).trim();
const cond = parseCondition(condStr);
if (!cond || cond.conditions.length === 0) return null;
const actionStmts = splitStatements(bodyStr);
const actions: FilterAction[] = [];
for (const st of actionStmts) {
const a = parseAction(st);
if (!a) return null;
actions.push(a);
}
if (actions.length === 0) return null;
let stopProcessing = false;
if (actions.length > 0 && actions[actions.length - 1].type === 'stop') {
const hasNonStop = actions.some(a => a.type !== 'stop');
if (hasNonStop) {
stopProcessing = true;
actions.pop();
}
}
const leading = block.raw.slice(0, block.statement ? block.raw.length - block.statement.length : 0);
const originLabel = detectOriginLabel(leading);
const name = extractName(leading, `Rule ${index + 1}`);
return {
id: `${idPrefix}-${index}`,
name,
enabled: true,
matchType: cond.matchType,
conditions: cond.conditions,
actions,
stopProcessing,
origin: 'external',
originLabel,
rawBlock: block.raw,
};
}
function makeOpaqueRule(block: TopBlock, idPrefix: string, index: number): FilterRule {
const leading = block.raw.slice(0, block.raw.length - block.statement.length);
const originLabel = detectOriginLabel(leading);
const name = extractName(leading, `External rule ${index + 1}`);
return {
id: `${idPrefix}-${index}`,
name,
enabled: true,
matchType: 'all',
conditions: [],
actions: [],
stopProcessing: false,
origin: 'opaque',
originLabel,
rawBlock: block.raw,
};
}
function parseExternalRules(
content: string,
idPrefix: string,
): { rules: FilterRule[]; externalRequires: string[]; hasContent: boolean } {
const blocks = scanTopLevel(content);
const rules: FilterRule[] = [];
const externalRequires: string[] = [];
let index = 0;
let sawAnyStatement = false;
for (const block of blocks) {
if (block.kind === 'require') {
sawAnyStatement = true;
for (const tok of extractRequireTokens(block.statement)) {
if (!externalRequires.includes(tok)) externalRequires.push(tok);
}
continue;
}
if (block.kind === 'if') {
sawAnyStatement = true;
const rule = parseIfBlockToRule(block, idPrefix, index);
rules.push(rule ?? makeOpaqueRule(block, idPrefix, index));
index++;
continue;
}
// vacation/other: treat as opaque preserved block
sawAnyStatement = true;
rules.push(makeOpaqueRule(block, idPrefix, index));
index++;
}
return { rules, externalRequires, hasContent: sawAnyStatement };
}
export function parseScript(content: string): ParseResult {
const beginIdx = content.indexOf(METADATA_BEGIN);
if (beginIdx === -1) {
// No metadata — check if it's a Stalwart vacation-only script
return detectVacationOnlyScript(content) || OPAQUE;
if (beginIdx !== -1) {
const endIdx = content.indexOf(METADATA_END, beginIdx);
if (endIdx === -1) return OPAQUE;
const jsonStart = beginIdx + METADATA_BEGIN.length;
const jsonStr = content.slice(jsonStart, endIdx).trim();
let metadata: FilterMetadata;
try {
metadata = JSON.parse(jsonStr);
} catch (e) {
debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e);
return OPAQUE;
}
if (!metadata || metadata.version !== 1) return OPAQUE;
if (!Array.isArray(metadata.rules)) return OPAQUE;
for (const rule of metadata.rules) {
if (!isValidRule(rule)) return OPAQUE;
}
// Scan the portion AFTER the metadata block for external rules.
const afterMetadata = content.slice(endIdx + METADATA_END.length);
const external = parseExternalRules(afterMetadata, 'ext');
// Parsed bulwark rules intentionally omit an explicit `origin` field so
// round-trip equality with metadata-only callers holds. Absence of origin
// is treated as 'bulwark' everywhere downstream.
const bulwarkRules: FilterRule[] = metadata.rules;
// Exclude requires and the vacation line that we emit ourselves from externalRequires.
const externalRequires = external.externalRequires;
// Drop any external "rules" that are really the bulwark-managed if-blocks or vacation.
// Recognizable by the leading comment "# Rule: <name>" or "# Vacation auto-reply".
const filteredExternal = external.rules.filter(r => {
const raw = r.rawBlock || '';
if (/#\s*Rule:\s*/.test(raw) && r.origin === 'external') {
// If the name matches a bulwark rule name exactly, treat as bulwark-emitted
const match = raw.match(/#\s*Rule:\s*(.+?)\s*$/m);
const name = match ? match[1].trim() : '';
if (bulwarkRules.some(b => b.name === name)) return false;
}
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
return true;
});
return {
rules: [...bulwarkRules, ...filteredExternal],
isOpaque: false,
vacation: metadata.vacation,
externalRequires,
};
}
const endIdx = content.indexOf(METADATA_END, beginIdx);
if (endIdx === -1) return OPAQUE;
// No metadata — check vacation-only first
const vacationOnly = detectVacationOnlyScript(content);
if (vacationOnly) return vacationOnly;
const jsonStart = beginIdx + METADATA_BEGIN.length;
const jsonStr = content.slice(jsonStart, endIdx).trim();
// Try to parse the whole script as external rules.
const external = parseExternalRules(content, 'ext');
let metadata: FilterMetadata;
try {
metadata = JSON.parse(jsonStr);
} catch (e) {
debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e);
return OPAQUE;
if (!external.hasContent) {
// Entirely empty or whitespace/comments only — treat as empty, editable.
return { rules: [], isOpaque: false, externalRequires: [] };
}
if (!metadata || metadata.version !== 1) return OPAQUE;
if (!Array.isArray(metadata.rules)) return OPAQUE;
for (const rule of metadata.rules) {
if (!isValidRule(rule)) return OPAQUE;
// If at least one block parsed into a structured rule, expose them as external.
const anyParsed = external.rules.some(r => r.origin === 'external');
if (anyParsed || external.rules.length > 0) {
return { rules: external.rules, isOpaque: false, externalRequires: external.externalRequires };
}
return { rules: metadata.rules, isOpaque: false, vacation: metadata.vacation };
return OPAQUE;
}
+170
View File
@@ -0,0 +1,170 @@
import type { Email, Mailbox, UnifiedMailboxRole } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
export interface UnifiedAccountClient {
accountId: string;
accountLabel: string;
client: IJMAPClient;
mailboxes: Mailbox[];
}
export interface UnifiedFetchResult {
emails: Email[];
total: number;
hasMore: boolean;
errors: Map<string, string>; // accountId -> error message
}
export interface UnifiedMailboxCounts {
role: UnifiedMailboxRole;
unreadEmails: number;
totalEmails: number;
}
const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [
'inbox', 'sent', 'drafts', 'trash', 'archive', 'junk',
];
/**
* Finds the first mailbox matching the given role.
*/
export function findMailboxByRole(
mailboxes: Mailbox[],
role: UnifiedMailboxRole,
): Mailbox | undefined {
return mailboxes.find((m) => m.role === role);
}
/**
* Fetches emails from all accounts for a given unified role, merges and sorts
* them by receivedAt descending. Per-account failures are collected in the
* errors map while successful results are still returned.
*/
export async function fetchUnifiedEmails(
accounts: UnifiedAccountClient[],
role: UnifiedMailboxRole,
limit: number,
position: number,
): Promise<UnifiedFetchResult> {
const errors = new Map<string, string>();
// Build one fetch task per account, wrapping each in a catch so we can
// track per-account errors while still using Promise.allSettled.
type AccountResult = {
account: UnifiedAccountClient;
result: { emails: Email[]; total: number; hasMore: boolean };
} | null;
const promises = accounts.map(
async (account): Promise<AccountResult> => {
const mailbox = findMailboxByRole(account.mailboxes, role);
if (!mailbox) return null;
try {
const result = await account.client.getEmails(
mailbox.id,
undefined,
limit,
position,
);
return { account, result };
} catch (err) {
errors.set(
account.accountId,
err instanceof Error ? err.message : String(err),
);
return null;
}
},
);
const results = await Promise.allSettled(promises);
let mergedEmails: Email[] = [];
let totalSum = 0;
let anyHasMore = false;
for (const outcome of results) {
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
const { account, result } = outcome.value;
// Decorate each email with the source account info.
for (const email of result.emails) {
email.accountId = account.accountId;
email.accountLabel = account.accountLabel;
}
mergedEmails = mergedEmails.concat(result.emails);
totalSum += result.total;
if (result.hasMore) {
anyHasMore = true;
}
}
// Sort merged emails by receivedAt descending.
mergedEmails.sort((a, b) => {
const dateA = new Date(a.receivedAt).getTime();
const dateB = new Date(b.receivedAt).getTime();
return dateB - dateA;
});
return {
emails: mergedEmails,
total: totalSum,
hasMore: anyHasMore,
errors,
};
}
/**
* Aggregates unread and total email counts across all accounts for each
* unified mailbox role. Only includes roles that exist in at least one account.
*/
export function fetchUnifiedMailboxCounts(
accounts: UnifiedAccountClient[],
): UnifiedMailboxCounts[] {
const counts: UnifiedMailboxCounts[] = [];
for (const role of ALL_UNIFIED_ROLES) {
let unreadEmails = 0;
let totalEmails = 0;
let found = false;
for (const account of accounts) {
const mailbox = findMailboxByRole(account.mailboxes, role);
if (mailbox) {
found = true;
unreadEmails += mailbox.unreadEmails;
totalEmails += mailbox.totalEmails;
}
}
if (found) {
counts.push({ role, unreadEmails, totalEmails });
}
}
return counts;
}
/**
* Returns the list of unified roles that exist in at least one account's
* mailboxes.
*/
export function getUnifiedRoles(
accounts: UnifiedAccountClient[],
): UnifiedMailboxRole[] {
const roles: UnifiedMailboxRole[] = [];
for (const role of ALL_UNIFIED_ROLES) {
for (const account of accounts) {
if (findMailboxByRole(account.mailboxes, role)) {
roles.push(role);
break;
}
}
}
return roles;
}
+35 -1
View File
@@ -1,6 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Mailbox } from "./jmap/types";
import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types";
import type { UnifiedMailboxRole } from "./jmap/types";
import { debug } from "./debug";
export function cn(...inputs: ClassValue[]) {
@@ -380,6 +381,39 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
return rootMailboxes;
}
/**
* Builds virtual MailboxNode entries for unified mailbox roles with aggregated counts.
*/
export function buildUnifiedMailboxNodes(
counts: Array<{ role: UnifiedMailboxRole; unreadEmails: number; totalEmails: number }>,
): MailboxNode[] {
return counts.map((count) => ({
id: UNIFIED_MAILBOX_IDS[count.role],
name: count.role, // Display name is handled by i18n in the component
role: count.role,
parentId: undefined,
sortOrder: 0,
totalEmails: count.totalEmails,
unreadEmails: count.unreadEmails,
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
},
isSubscribed: true,
children: [],
depth: 0,
}));
}
// Flatten a mailbox tree for rendering with proper depth info
export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
const result: MailboxNode[] = [];