┌────────────────────────────┐
│    Building an LLM Mail    │
│Assistant Plugin: ES Module │
│      Contracts & Hook      │
│       Architectures        │
│ 2026-09-05                 │
│                            │
├────────────────────────────┤
│ << Back to Blog            │
└────────────────────────────┘
╔══════════════════════════════════════╗
║Building an LLM Mail Assistant Plugin:║
║      ES Module Contracts & Hook      ║
║            Architectures             ║
║ 2026-09-05                           ║
║                                      ║
╠══════════════════════════════════════╣
║ << Back to Blog                      ║
╚══════════════════════════════════════╝
╔══════════════════════════════════════════════════════════╗
║Building an LLM Mail Assistant Plugin: ES Module Contracts║
║                   & Hook Architectures                   ║
║ 2026-09-05                                               ║
║                                                          ║
╠══════════════════════════════════════════════════════════╣
║ << Back to Blog                                          ║
╚══════════════════════════════════════════════════════════╝
╔══════════════════════════════════════════════════════════════════════════════╗
║      Building an LLM Mail Assistant Plugin: ES Module Contracts & Hook       ║
║                                Architectures                                 ║
║ 2026-09-05                                                                   ║
║                                                                              ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ << Back to Blog                                                              ║
╚══════════════════════════════════════════════════════════════════════════════╝

Building an LLM Mail Assistant Plugin: ES Module Contracts & Hook Architectures

Table of Contents

  1. The Context / The Problem
  2. The Deep-Dive / Root Cause Analysis
  3. The Implementation / Architecture
  4. Lessons Learned & Best Practices
  5. References

The Context / The Problem

Integrating generative AI capabilities into legacy or modern webmail applications often results in brittle, unmaintainable frontend hacks. When adding automated email thread summarization, smart reply generation, and priority triage to our self-hosted Bulwark webmail client, naive monkey-patching of the DOM and network interceptors created endless regressions with upstream release cycles.

Bulwark renders a high-performance Single Page Application (SPA) driven by Preact and JMAP API bindings. Early attempts to inject AI assistance via userscripts or iframe embeds suffered from severe authentication desyncs: DOM elements were detached during inbox filtering, and streaming token responses caused rendering lag that locked the browser's main thread.

We needed a clean, isolated plugin architecture governed by explicit ECMAScript Module (ESM) contracts and lifecycle hooks, allowing secure client-side communication with local LLM inference engines (such as Ollama or vLLM) without compromising user privacy or webmail stability.


The Deep-Dive / Root Cause Analysis

Analyzing webmail extension models highlighted three critical engineering pitfalls in client-side AI integration:

1. Main-Thread Stalls on Streaming LLM Token Ingestion

Large language models generate responses as Server-Sent Events (SSE) or WebSockets chunk streams. Directly triggering Preact state re-renders on every 2-character token chunk flooded the virtual DOM reconciliation loop with hundreds of updates per second, dropping UI frame rates below 15 FPS.

2. Context Window & Data Leakage Risks

Summarizing long email chains requires ingesting previous replies while stripping noisy boilerplate: email signatures, legal disclaimers, SPF/DKIM headers, and automated unsubscribe links. Sending raw MIME text wastes prompt context tokens and risks leaking sensitive PII to external inference endpoints.


The Implementation / Architecture

We built an extensible Plugin SDK for Bulwark based on modern ES Module contracts and a sandboxed hook pipeline (BulwarkPluginAPI).

1. The Plugin Contract Interface

Plugins register lifecycle hooks for message inspection, UI action bar insertion, and draft composition:

export interface MailAssistantPlugin {
  id: string;
  name: string;
  version: string;
  
  // Lifecycle hooks
  onInit(api: PluginContext): Promise<void>;
  onMessageRender(message: MailMessage, container: HTMLElement): void;
  onComposeOpen(composer: DraftComposer): void;
}

export interface PluginContext {
  registerButton(slot: "message_header" | "composer_toolbar", config: ActionButton): void;
  sanitizeThread(rawHtml: string): string;
  streamInference(prompt: string, onChunk: (token: string) => void): Promise<string>;
}

2. Client-Side LLM Triage & Summarizer

The plugin sanitizes thread contents, throttles DOM updates via requestAnimationFrame, and streams local inference through a private WireGuard endpoint:

// llm-assistant.js - ES Module Plugin
export default class LLMAssistantPlugin {
  constructor() {
    this.id = "org.filmtek.llm-assistant";
    this.name = "Local LLM Assistant";
  }

  async onInit(context) {
    context.registerButton("message_header", {
      icon: "sparkles",
      title: "Summarize Thread",
      onClick: async (message) => {
        const cleanBody = context.sanitizeThread(message.bodyHtml);
        const prompt = `You are a concise executive assistant. Summarize the following email thread in 3 bullet points, then list any required action items:\n\n${cleanBody}`;
        
        const summaryCard = document.createElement("div");
        summaryCard.className = "p-4 my-2 rounded bg-surface-2 border border-accent text-sm";
        message.headerElement.appendChild(summaryCard);

        let buffer = "";
        let pendingFrame = null;

        await context.streamInference(prompt, (token) => {
          buffer += token;
          if (!pendingFrame) {
            pendingFrame = requestAnimationFrame(() => {
              summaryCard.innerHTML = renderMarkdown(buffer);
              pendingFrame = null;
            });
          }
        });
      },
    });
  }
}

3. Local Privacy-Preserving Inference Gateway

To preserve user privacy, inference calls are routed exclusively to a self-hosted vLLM instance serving Llama 3 8B over our internal mesh network (http://llm-gateway.internal:8000/v1/chat/completions), ensuring zero email data ever traverses public third-party APIs.


Lessons Learned & Best Practices

  1. Decouple Extension Points from DOM Selectors: Pinned hook points (registerButton) prevent plugin breakage when upstream updates alter CSS classes or component layouts.
  2. Buffer Streamed Token Renders: Using requestAnimationFrame to batch UI updates during streaming text generation reduced CPU utilization during summarization by 72%.
  3. Always Clean MIME Garbage Before Prompting: Pre-processing email threads to strip signatures and quoted reply headers reduced token consumption by 55%, dramatically cutting latency and inference cost.

References