Files
SRCmail/specifications/addon-plugin-theme-concept.md
T

36 KiB
Raw Blame History

Addons, Plugins & Themes — Architecture Concept

Status: Draft Concept
Date: 2026-03-13


Table of Contents

  1. Overview
  2. Terminology
  3. Manifest Format
  4. Themes
  5. Plugins
  6. Addon Lifecycle
  7. Extension Points (Hooks & Slots)
  8. Security & Sandboxing
  9. Storage & Distribution
  10. Settings Integration
  11. API Surface
  12. Migration Path

1. Overview

This document describes a system that allows the Bulwark Webmail application to be extended through themes (visual customization) and plugins (functional extensions). Together, these are called addons.

Design Goals

  • Safe by default — addons cannot break core functionality or access data beyond their declared scope.
  • Zero-config for users — install, enable, done. No code changes to the host app.
  • Declarative where possible — prefer JSON/CSS-based customization over imperative code.
  • Aligned with existing architecture — builds on Zustand stores, React context/providers, CSS variables, and the Next.js App Router patterns already in use.
  • Incrementally adoptable — the core app can ship without any addons; the addon system is a layer on top.

Rebuild Requirements

Addon Type Source Rebuild Needed?
Theme Bundled (/addons/themes/) Yes — included at build time
Theme URL (remote) No — CSS loaded via <link> at runtime
Plugin Bundled (/addons/plugins/) Yes — included at build time
Plugin URL (remote) No — JS loaded at runtime (see §6.3)
Plugin Local file (dev mode) No — loaded at runtime

Bundled addons are compiled into the app's static assets during next build. Adding, removing, or updating a bundled addon requires a rebuild and redeploy. URL-based and local addons are fully runtime-loaded — users can install, enable, disable, and uninstall them without any rebuild.

Non-Goals (for v1)

  • Server-side plugin execution (all addons run client-side).
  • A public addon marketplace (addons are self-hosted or bundled).
  • Modifying JMAP protocol behavior (addons consume JMAP data, not intercept it).

2. Terminology

Term Definition
Addon Any installable extension — umbrella term for themes and plugins.
Theme An addon that only customizes visual appearance (colors, fonts, spacing, density). Ships as CSS + a manifest. Contains no executable code.
Plugin An addon that adds or modifies functionality. Ships as a JS/TS module + a manifest. May include a theme.
Slot A named insertion point in the UI where plugins can render components.
Hook Point A named event or state transition where plugins can run logic.
Manifest A addon.json file that declares metadata, permissions, and extension points.

3. Manifest Format

Every addon has an addon.json at its root:

{
  // ── Identity ──
  "id": "com.example.my-addon", // Reverse-domain unique ID
  "name": "My Addon",
  "version": "1.0.0", // Semver
  "description": "A brief description.",
  "author": {
    "name": "Jane Doe",
    "url": "https://example.com",
  },
  "license": "MIT",
  "homepage": "https://example.com/my-addon",

  // ── Compatibility ──
  "engine": {
    "webmail": ">=1.0.0", // Required host app version range
  },

  // ── Type ──
  "type": "plugin", // "theme" | "plugin"

  // ── Entry Points (plugins only) ──
  "main": "dist/index.js", // Plugin entry module
  "styles": "dist/styles.css", // Optional supplementary CSS

  // ── Theme Definition (themes, or plugins that include a theme) ──
  "theme": {
    "variables": "theme.css", // CSS file with variable overrides
    "presets": ["light", "dark"], // Which base modes it provides
    "preview": "preview.png", // Screenshot for settings UI
  },

  // ── Permissions (plugins only) ──
  "permissions": [
    "emails:read", // Read email data from store
    "emails:write", // Modify email data (move, flag, etc.)
    "contacts:read",
    "calendar:read",
    "settings:read",
    "settings:write",
    "notifications", // Show toasts / browser notifications
    "compose:toolbar", // Add buttons to composer toolbar
    "sidebar:section", // Add sections to the sidebar
    "viewer:action", // Add actions to email viewer toolbar
    "navigation:tab", // Add a top-level navigation tab
    "context-menu:email", // Extend email context menu
    "keyboard-shortcuts", // Register keyboard shortcuts
    "external-fetch", // Fetch external URLs (declared origins)
  ],

  // ── External Origins (if external-fetch permission is declared) ──
  "allowedOrigins": ["https://api.example.com"],

  // ── Slots (declares which UI slots the plugin uses) ──
  "slots": ["sidebar.bottom", "compose.toolbar", "viewer.actions"],

  // ── Settings Schema (plugin-specific preferences) ──
  "settings": {
    "apiKey": {
      "type": "string",
      "label": "API Key",
      "description": "Your API key for the service.",
      "secret": true,
    },
    "enabled": {
      "type": "boolean",
      "label": "Enable integration",
      "default": true,
    },
  },

  // ── i18n ──
  "locales": "locales/", // Directory with {locale}.json files
}

4. Themes

Themes are the simplest addon type — pure CSS, no executable code.

4.1 How Themes Work

The app already uses CSS custom properties (variables) for all colors, defined in globals.css under :root and .dark. A theme overrides these variables:

/* theme.css — "Nord" theme example */

:root[data-theme="com.example.nord"] {
  --color-background: #eceff4;
  --color-foreground: #2e3440;
  --color-primary: #5e81ac;
  --color-primary-foreground: #eceff4;
  --color-border: #d8dee9;
  --color-sidebar-bg: #e5e9f0;
  --color-sidebar-hover: #d8dee9;
  --color-muted: #4c566a;
  --color-accent: #88c0d0;
  --color-destructive: #bf616a;

  /* Extended variables for advanced customization */
  --font-family-base: "Inter", sans-serif;
  --font-family-mono: "JetBrains Mono", monospace;
  --radius-base: 8px;
  --spacing-density: 1; /* 0.8 = compact, 1 = normal, 1.2 = comfortable */
  --shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.08);
}

:root[data-theme="com.example.nord"].dark {
  --color-background: #2e3440;
  --color-foreground: #eceff4;
  --color-primary: #88c0d0;
  --color-border: #3b4252;
  --color-sidebar-bg: #3b4252;
  --color-sidebar-hover: #434c5e;
}

4.2 Theme Application

User selects theme in Settings → Appearance
  → ThemeStore sets `activeTheme: "com.example.nord"`
  → <html data-theme="com.example.nord" class="dark|light">
  → Theme CSS is loaded via a <link> tag with the theme's CSS file
  → CSS specificity ensures theme variables override defaults

4.3 Theme Capabilities

Capability Mechanism
Colors Override --color-* CSS variables
Typography Override --font-family-* variables
Spacing/density Override --spacing-density multiplier
Border radius Override --radius-* variables
Shadows Override --shadow-* variables
Dark mode variant Provide .dark overrides
Tag/label colors Override --tag-color-* palette
Custom CSS Additional rules scoped under [data-theme="..."]

4.4 Theme Constraints

  • Themes cannot add or remove DOM elements.
  • Themes cannot execute JavaScript.
  • Themes cannot override layout structure (flexbox directions, grid templates).
  • Theme CSS is scoped by [data-theme] attribute — removing the attribute instantly reverts to defaults.
  • A maximum CSS file size is enforced (e.g., 100 KB) to prevent abuse.

5. Plugins

Plugins are JavaScript modules that interact with the app through a controlled API.

5.1 Plugin Entry Point

A plugin exports a single activate function and optionally a deactivate function:

// index.ts — Plugin entry point
import type { PluginContext } from "@bulwarkmail/addon-api";

export function activate(ctx: PluginContext) {
  // Register a sidebar section
  ctx.slots.register("sidebar.bottom", {
    component: MySidebarWidget,
    priority: 10,
  });

  // Register a composer toolbar button
  ctx.slots.register("compose.toolbar", {
    component: EncryptButton,
    priority: 50,
  });

  // Listen to store changes
  ctx.hooks.on("email:selected", (email) => {
    // React to email selection
  });

  // Register a keyboard shortcut
  ctx.shortcuts.register({
    key: "g t",
    description: "Open translation panel",
    action: () => ctx.panels.open("translate"),
  });

  // Add a context menu item
  ctx.contextMenu.register("email", {
    label: ctx.i18n.t("translateEmail"),
    icon: "Languages",
    action: (emailId) => {
      /* ... */
    },
  });
}

export function deactivate(ctx: PluginContext) {
  // Cleanup — called when the plugin is disabled or uninstalled.
  // All slot registrations and event subscriptions are
  // automatically cleaned up, so this is only needed
  // for external resource cleanup.
}

5.2 PluginContext API

The PluginContext object is the plugin's only interface to the host app. It is scoped and sandboxed based on the declared permissions:

interface PluginContext {
  /** Plugin metadata from manifest */
  manifest: AddonManifest;

  /** UI slot registration */
  slots: {
    register(slotId: string, registration: SlotRegistration): Disposable;
  };

  /** Event hooks */
  hooks: {
    on(event: HookEvent, handler: Function): Disposable;
    once(event: HookEvent, handler: Function): Disposable;
  };

  /** Store access (read-only or read-write based on permissions) */
  stores: {
    emails: PluginEmailStore; // If emails:read or emails:write
    contacts: PluginContactStore; // If contacts:read
    calendar: PluginCalendarStore; // If calendar:read
    settings: PluginSettingsStore; // If settings:read or settings:write
  };

  /** Plugin-specific settings (defined in manifest "settings" schema) */
  config: {
    get<T>(key: string): T;
    set(key: string, value: unknown): void;
    onChange(key: string, handler: (value: unknown) => void): Disposable;
  };

  /** Toast notifications */
  notifications: {
    success(message: string): void;
    error(message: string): void;
    info(message: string): void;
  };

  /** i18n — scoped to plugin's locale files */
  i18n: {
    t(key: string, params?: Record<string, string>): string;
    locale: string;
  };

  /** Keyboard shortcuts */
  shortcuts: {
    register(shortcut: ShortcutDefinition): Disposable;
  };

  /** Context menu extensions */
  contextMenu: {
    register(target: ContextMenuTarget, item: ContextMenuItem): Disposable;
  };

  /** Panel API — open side panels or modals */
  panels: {
    open(panelId: string, props?: Record<string, unknown>): void;
    close(panelId: string): void;
    register(panelId: string, component: React.ComponentType): Disposable;
  };

  /** Scoped fetch — only allowed origins from manifest */
  fetch(url: string, init?: RequestInit): Promise<Response>;
}

5.3 Disposable Pattern

All registrations return a Disposable object. On plugin deactivation, all disposables are automatically cleaned up:

interface Disposable {
  dispose(): void;
}

6. Addon Lifecycle

┌──────────────────────────────────────────────────────────┐
│                    Addon Lifecycle                        │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  ┌─────────┐   install    ┌───────────┐   enable         │
│  │  Store   │────────────▶│ Installed │──────────┐       │
│  │  / URL   │             │ (disabled) │          │       │
│  └─────────┘             └───────────┘          ▼       │
│                               ▲           ┌──────────┐  │
│                        disable│           │  Active  │  │
│                               │           │(running) │  │
│                               └───────────┤          │  │
│                                           └──────────┘  │
│                               │                ▲        │
│                        uninstall          update│        │
│                               │                │        │
│                               ▼           ┌────┴─────┐  │
│                          ┌────────┐       │ Updating │  │
│                          │Removed │       └──────────┘  │
│                          └────────┘                     │
│                                                          │
└──────────────────────────────────────────────────────────┘

6.1 Loading Sequence

  1. Boot: App starts, AddonManager reads the addon registry from localStorage (list of installed addons + enabled state).
  2. Resolve: For each enabled addon, load its manifest and verify compatibility (engine.webmail).
  3. Load Themes: Inject theme CSS <link> for the active theme.
  4. Load Plugins: Dynamically import each plugin's main entry point.
  5. Activate: Call activate(ctx) for each plugin, passing a scoped PluginContext.
  6. Ready: Emit app:ready hook — plugins can now interact with stores.

6.3 Runtime Loading Strategy

Next.js import() only resolves modules known at build time. To load plugins from arbitrary URLs at runtime without a rebuild, the addon system uses a script-based module loader:

// lib/addon-loader.ts

async function loadRemotePlugin(url: string): Promise<PluginModule> {
  // 1. Fetch the plugin's JS bundle as text
  const response = await fetch(url);
  if (!response.ok)
    throw new Error(`Failed to fetch plugin: ${response.status}`);
  const code = await response.text();

  // 2. Validate size limit (500 KB default)
  if (code.length > MAX_PLUGIN_SIZE) {
    throw new Error(`Plugin exceeds size limit`);
  }

  // 3. Create a scoped module environment
  //    The plugin receives a controlled `require` that only resolves
  //    allowed shared dependencies (React, Lucide icons, date-fns).
  const module = { exports: {} as PluginModule };
  const scopedRequire = createScopedRequire(SHARED_DEPS);
  const factory = new Function("module", "exports", "require", code);
  factory(module, module.exports, scopedRequire);

  // 4. Validate the module exports the expected interface
  if (typeof module.exports.activate !== "function") {
    throw new Error(`Plugin does not export an activate() function`);
  }

  return module.exports;
}

// Shared dependencies exposed to plugins — avoids bundling duplicates
const SHARED_DEPS: Record<string, unknown> = {
  react: React,
  "react/jsx-runtime": jsxRuntime,
  "lucide-react": lucideIcons,
  "date-fns": dateFns,
  sonner: sonner,
};

Bundled addons skip this loader — they are statically imported at build time via the generated addon-registry.json.

Theme CSS (any source) is loaded by injecting a <link rel="stylesheet"> element — no special loader needed.

6.2 Addon Manager Store

A new Zustand store manages addon state:

interface AddonManagerState {
  /** Registry of all installed addons */
  addons: Record<string, InstalledAddon>;

  /** Currently active theme ID (null = default) */
  activeTheme: string | null;

  /** Actions */
  installAddon(source: AddonSource): Promise<void>;
  uninstallAddon(id: string): void;
  enableAddon(id: string): void;
  disableAddon(id: string): void;
  setActiveTheme(id: string | null): void;
  getAddon(id: string): InstalledAddon | undefined;
  getEnabledPlugins(): InstalledAddon[];
}

interface InstalledAddon {
  manifest: AddonManifest;
  enabled: boolean;
  installedAt: string; // ISO timestamp
  source: AddonSource; // Where it was loaded from
  runtimeState: "inactive" | "active" | "error";
  error?: string; // Last activation error
}

type AddonSource =
  | { type: "bundled" } // Shipped with the app
  | { type: "url"; url: string } // Loaded from a URL
  | { type: "local"; path: string }; // Development: local file

7. Extension Points (Hooks & Slots)

7.1 UI Slots

Slots are named insertion points scattered across the UI. The host app renders a <Slot> component at each point; plugins register components into slots.

// Host app — in sidebar.tsx
import { Slot } from "@/components/addons/slot";

function Sidebar() {
  return (
    <aside>
      {/* ... existing sidebar content ... */}
      <Slot name="sidebar.bottom" />
    </aside>
  );
}
// Slot component implementation
function Slot({ name }: { name: string }) {
  const registrations = useAddonSlot(name);
  if (registrations.length === 0) return null;

  return (
    <>
      {registrations
        .sort((a, b) => a.priority - b.priority)
        .map((reg) => (
          <AddonErrorBoundary key={reg.addonId} addonId={reg.addonId}>
            <reg.component />
          </AddonErrorBoundary>
        ))}
    </>
  );
}

Available Slots

Slot Name Location Use Case
sidebar.top Top of sidebar, below compose button Quick-access widgets
sidebar.bottom Bottom of sidebar, above storage quota Extra navigation, widgets
navigation.tabs Navigation rail, below contacts icon New top-level views
compose.toolbar Composer toolbar (formatting bar) Encrypt, translate, AI assist buttons
compose.footer Below composer body, above send button Send-time options (delay, schedule)
viewer.actions Email viewer toolbar Custom actions (translate, summarize)
viewer.header Above email body in viewer Banners, warnings, metadata
viewer.footer Below email body in viewer Related content, suggestions
list.toolbar Above email list Additional filters, bulk actions
settings.sections Settings page, below existing sections Plugin settings panels
calendar.toolbar Calendar view toolbar Calendar-specific actions
contacts.toolbar Contacts view toolbar Contact-specific actions

7.2 Hook Events

Plugins can listen to app events and state transitions:

Email Hooks

Event Payload Description
email:selected { emailId, email } User selected an email
email:opened { emailId, email } Email viewer rendered
email:compose:open { mode, replyTo? } Composer opened
email:compose:before-send { draft } Before sending — can modify draft
email:compose:sent { emailId } Email sent successfully
email:moved { emailId, from, to } Email moved between mailboxes
email:deleted { emailId } Email deleted
email:flagged { emailId, flags } Email flags changed

Calendar Hooks

Event Payload Description
calendar:event:created { event } New event created
calendar:event:updated { event, changes } Event modified
calendar:event:deleted { eventId } Event deleted
calendar:view:changed { view, date } Calendar view switched

Contact Hooks

Event Payload Description
contact:selected { contactId } Contact selected
contact:created { contact } New contact created
contact:updated { contact } Contact modified

App Hooks

Event Payload Description
app:ready {} App fully loaded
app:theme:changed { theme } Theme switched
app:locale:changed { locale } Language changed
app:navigation { from, to } User navigated between views

8. Security & Sandboxing

8.1 Permission Model

Plugins declare required permissions in their manifest. On installation, the user sees a permission prompt:

"My Translation Plugin" requests:
  ✉️  Read your emails
  🔔  Show notifications
  🌐  Connect to https://api.translate.example.com

  [Allow]  [Cancel]

Permissions are enforced at the PluginContext level — if a plugin didn't declare emails:read, ctx.stores.emails is undefined.

8.2 Sandboxing Strategy

Layer Mechanism
Store access PluginContext exposes only permitted store slices. Write access returns proxied objects — mutations are validated before applying.
DOM access Plugin components render inside an <AddonErrorBoundary>. They receive a scoped React tree — no direct document manipulation.
Network ctx.fetch() is a controlled wrapper. Requests are only allowed to origins listed in allowedOrigins. All other fetch / XMLHttpRequest calls from plugin code are blocked via CSP headers.
Storage Plugins use ctx.config (backed by a namespaced key in localStorage). No direct localStorage / sessionStorage access.
Error isolation Each plugin slot is wrapped in an AddonErrorBoundary. A crashing plugin is caught and disabled without affecting the rest of the app.
Resource limits Plugin CSS is limited to 100 KB. Plugin JS bundles are limited to 500 KB (configurable).

8.3 Content Security Policy

Theme CSS is sanitized to disallow:

  • url() references to external domains (only data URIs and same-origin).
  • @import statements.
  • expression() or behavior: (legacy IE attack vectors).

8.4 Error Boundary

function AddonErrorBoundary({ addonId, children }) {
  return (
    <ErrorBoundary
      fallback={<AddonCrashedNotice addonId={addonId} />}
      onError={(error) => {
        console.error(`[Addon: ${addonId}] Crashed:`, error);
        addonManager.reportError(addonId, error);
        // Auto-disable after 3 crashes in 5 minutes
      }}
    >
      {children}
    </ErrorBoundary>
  );
}

9. Storage & Distribution

9.1 Addon Formats

Format Description Use Case
Bundled Shipped inside the app's /addons/ directory Default themes, first-party plugins
URL Loaded from a remote URL at runtime Self-hosted or third-party addons
Local file Loaded from a local path (dev mode only) Plugin development

9.2 Addon Bundle Structure

my-addon/
├── addon.json          # Manifest (required)
├── dist/
│   ├── index.js        # Plugin entry (plugins only)
│   └── styles.css      # Additional styles (optional)
├── theme.css           # Theme variables (themes only)
├── preview.png         # Theme preview image (optional)
└── locales/
    ├── en.json         # English strings
    ├── fr.json         # French strings
    └── ...

9.3 Built-in Addon Directory

addons/
├── themes/
│   ├── nord/
│   │   ├── addon.json
│   │   ├── theme.css
│   │   └── preview.png
│   ├── dracula/
│   ├── solarized/
│   ├── catppuccin/
│   └── high-contrast/
└── plugins/
    └── (none bundled by default)

9.4 Installation Flow

From URL:

  1. User pastes addon URL into Settings → Addons → "Install from URL".
  2. App fetches {url}/addon.json, validates schema and compatibility.
  3. Manifest is stored in addon registry (localStorage).
  4. On next activation, the addon's assets are fetched and cached.

Bundled:

  1. Addons in /addons/ are auto-discovered at build time.
  2. A generated addon-registry.json maps addon IDs to their local paths.
  3. Bundled addons appear pre-installed (but can be disabled).

10. Settings Integration

10.1 Addon Settings Page

A new page at /settings/addons integrates into the existing settings layout:

Settings
├── Appearance
├── Language & Region
├── Email
├── Composer
├── Calendar
├── Privacy & Security
├── Keyboard Shortcuts
├── Addons              ← NEW
│   ├── Themes
│   │   ├── Default (active)
│   │   ├── Nord
│   │   ├── Dracula
│   │   └── [Install Theme...]
│   ├── Plugins
│   │   ├── Translation Plugin (enabled) [Settings] [Disable]
│   │   ├── PGP Encryption (disabled) [Enable] [Uninstall]
│   │   └── [Install Plugin...]
│   └── Developer
│       └── [Load from local path...]

10.2 Plugin-Specific Settings

Plugins declare their settings schema in addon.json. The app auto-generates a settings UI:

// In addon.json
"settings": {
  "provider": {
    "type": "select",
    "label": "Translation Provider",
    "options": [
      { "value": "deepl", "label": "DeepL" },
      { "value": "google", "label": "Google Translate" }
    ],
    "default": "deepl"
  },
  "targetLanguage": {
    "type": "select",
    "label": "Default Target Language",
    "options": "locales",              // Special: populated from app locales
    "default": "en"
  },
  "autoTranslate": {
    "type": "boolean",
    "label": "Auto-translate foreign emails",
    "default": false
  }
}

Supported setting types: string, boolean, number, select, multiselect, color, secret (masked input).

10.3 Theme Selector

The existing Appearance settings page gains a theme gallery:

Appearance
├── Theme: [Default ▾]    ← dropdown with installed themes
│   Preview: [████████████████]  ← live color preview strip
├── Mode: Light / Dark / System
├── Font Size: Small / Medium / Large
└── ...

When a theme is selected, the app:

  1. Sets data-theme attribute on <html>.
  2. Loads the theme's CSS file.
  3. Persists the choice in ThemeStore.

11. API Surface

11.1 Core Registry (AddonRegistry)

class AddonRegistry {
  /** Register a slot for plugin component injection */
  defineSlot(name: string, options?: SlotOptions): void;

  /** Get all registrations for a slot */
  getSlotRegistrations(name: string): SlotRegistration[];

  /** Subscribe to slot changes (for reactive rendering) */
  onSlotChange(name: string, cb: () => void): Disposable;

  /** Emit a hook event to all listening plugins */
  emitHook(event: string, payload: unknown): void;

  /** Emit a hook event that plugins can modify (pipeline) */
  emitHookPipeline<T>(event: string, value: T): T;
}

11.2 Hook Pipeline (Interceptors)

Some hooks allow plugins to transform data flowing through them. For example, email:compose:before-send lets plugins modify the draft before it's sent:

// Plugin: auto-add disclaimer
ctx.hooks.on("email:compose:before-send", (draft) => {
  return {
    ...draft,
    htmlBody: draft.htmlBody + "<p>Sent from Bulwark Webmail</p>",
  };
});

Pipeline hooks execute in priority order. If any handler throws, the pipeline is aborted and the action is cancelled (with a notification to the user).

11.3 React Hooks for Addon Developers

// Available inside plugin components:

/** Access the plugin's scoped context */
usePluginContext(): PluginContext;

/** Access plugin-specific settings (reactive) */
usePluginConfig<T>(key: string): [T, (value: T) => void];

/** Access plugin's i18n */
usePluginI18n(): { t: (key: string, params?: Record<string, string>) => string };

/** Access host app theme info */
useHostTheme(): { mode: "light" | "dark"; resolvedMode: "light" | "dark" };

12. Migration Path

Phase 1: Foundation

  • Define the complete addon.json schema with JSON Schema validation.
  • Create the AddonManagerStore (Zustand store for managing installed addons).
  • Extend ThemeStore with activeTheme and data-theme attribute management.
  • Implement CSS variable injection for themes.
  • Add 35 bundled themes (Nord, Dracula, Solarized, Catppuccin, High Contrast).
  • Add the theme selector to Settings → Appearance.

Phase 2: Plugin Infrastructure

  • Implement the <Slot> component and AddonErrorBoundary.
  • Add <Slot> insertion points to the 12 defined locations in the UI.
  • Build the PluginContext factory with permission-gated store access.
  • Implement the hook event system (emitHook, emitHookPipeline).
  • Create the Settings → Addons page with install/enable/disable/uninstall UI.
  • Implement auto-generated settings UI from plugin settings schema.

Phase 3: Developer Experience

  • Create @bulwarkmail/addon-api — TypeScript type definitions package.
  • Create create-bulwark-addon CLI scaffolding tool.
  • Write addon developer documentation with examples.
  • Build a sample plugin (e.g., email translation) as a reference.
  • Add dev mode: hot-reload addons from local filesystem.

Phase 4: Hardening

  • Security audit of the sandboxing layer.
  • CSP header configuration for addon CSS/JS.
  • Rate limiting for hook events (prevent infinite loops).
  • Performance budgets: measure and enforce bundle size + render time limits.
  • Auto-disable addons that crash repeatedly.

Appendix: Example Addons

A. Theme: "Nord"

nord-theme/
├── addon.json
├── theme.css
└── preview.png

addon.json:

{
  "id": "org.nordtheme.bulwark-webmail",
  "name": "Nord",
  "version": "1.0.0",
  "type": "theme",
  "description": "An arctic, north-bluish color palette.",
  "author": { "name": "Arctic Ice Studio" },
  "license": "MIT",
  "engine": { "webmail": ">=1.0.0" },
  "theme": {
    "variables": "theme.css",
    "presets": ["light", "dark"],
    "preview": "preview.png"
  }
}

B. Plugin: "Email Translator"

email-translator/
├── addon.json
├── dist/
│   └── index.js
└── locales/
    ├── en.json
    └── fr.json

addon.json:

{
  "id": "com.example.email-translator",
  "name": "Email Translator",
  "version": "1.0.0",
  "type": "plugin",
  "description": "Translate emails with one click.",
  "author": { "name": "JMAP Community" },
  "license": "MIT",
  "engine": { "webmail": ">=1.0.0" },
  "main": "dist/index.js",
  "permissions": [
    "emails:read",
    "notifications",
    "viewer:action",
    "external-fetch"
  ],
  "allowedOrigins": ["https://api.deepl.com"],
  "slots": ["viewer.actions"],
  "settings": {
    "apiKey": {
      "type": "secret",
      "label": "DeepL API Key"
    },
    "targetLanguage": {
      "type": "select",
      "label": "Target Language",
      "options": "locales",
      "default": "en"
    }
  },
  "locales": "locales/"
}

C. Plugin: "Send Later"

Adds a "Schedule Send" button to the composer:

export function activate(ctx: PluginContext) {
  ctx.slots.register("compose.footer", {
    component: ScheduleSendPicker,
    priority: 10,
  });

  ctx.hooks.on("email:compose:before-send", (draft) => {
    const scheduledTime = ctx.config.get<string>("pendingSchedule");
    if (scheduledTime) {
      // Store the scheduled time — the host app handles deferred sending
      return { ...draft, deliverAt: scheduledTime };
    }
    return draft;
  });
}

Open Questions

  1. Should plugins be able to define new routes (pages)? Adding full pages (e.g., /addons/my-plugin/dashboard) would require deeper Next.js integration. Could use a navigation:tab slot that renders a full-pane view instead.

  2. Web Worker isolation? Running plugin JS in a Web Worker would provide stronger isolation but prevents direct React rendering. A message-passing bridge is possible but adds complexity. Probably not worth it for v1.

  3. Server-side plugins? Some use cases (email filtering, webhook integrations) need server execution. This is out of scope for v1 but could be explored as Sieve filter generation or JMAP push notification handlers.

  4. Addon signing? For URL-installed addons, a signature verification system would prevent tampering. Worth considering for v2.

  5. Shared dependencies? Should plugins be able to declare peer dependencies on the host app's packages (React, date-fns, Lucide icons)? This would reduce bundle sizes but creates coupling. Recommend providing these as globals via the plugin runtime. (Addressed in §6.3 — shared deps are exposed via a scoped require.)

  6. Admin-controlled addon allowlist? In multi-user deployments, should the server admin be able to restrict which addons can be installed (e.g., via an environment variable ALLOWED_ADDON_IDS)? This would prevent users from loading untrusted plugins in managed environments.