Merge branch 'dev'
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user