diff --git a/app/(sandbox)/plugin-sandbox-privileged/page.tsx b/app/(sandbox)/plugin-sandbox-privileged/page.tsx
new file mode 100644
index 00000000..0f3d431d
--- /dev/null
+++ b/app/(sandbox)/plugin-sandbox-privileged/page.tsx
@@ -0,0 +1,15 @@
+import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
+
+// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
+// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
+// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
+// approval) is enforced host-side before this route is ever framed; the page
+// itself carries no extra privilege.
+//
+// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
+// Next's injected hydration/chunk scripts.
+export const dynamic = 'force-dynamic';
+
+export default function PrivilegedPluginSandboxPage() {
+ return ;
+}
diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts
index 06562701..a62c4bf8 100644
--- a/app/api/admin/plugins/route.ts
+++ b/app/api/admin/plugins/route.ts
@@ -183,6 +183,7 @@ export async function POST(request: NextRequest) {
author: manifest.author as string,
description: (manifest.description as string) || '',
type: manifest.type as string,
+ ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
permissions: (manifest.permissions as string[]) || [],
entrypoint: manifest.entrypoint as string,
enabled: true,
diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts
index c6789a16..3cb3a63f 100644
--- a/app/api/plugins/route.ts
+++ b/app/api/plugins/route.ts
@@ -37,6 +37,9 @@ export async function GET() {
author: p.author,
description: p.description,
type: p.type,
+ // Requested execution tier; clients gate the same-origin privileged
+ // sandbox on this (plus signature + approval + consent).
+ tier: p.tier,
permissions: p.permissions,
entrypoint: p.entrypoint,
// Policy is the canonical source for force-enable. The per-plugin field
diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx
index 6156f2a0..b2b1e5b2 100644
--- a/components/plugins/plugin-iframe-slot.tsx
+++ b/components/plugins/plugin-iframe-slot.tsx
@@ -52,6 +52,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
slot,
code: active.code,
locale,
+ tier: active.tier,
extraProps: extraProps ?? {},
hostContainer: wrapperRef.current,
onResize: (h) => setHeight(h),
diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts
index 0913adf5..1ec02385 100644
--- a/lib/admin/plugin-registry.ts
+++ b/lib/admin/plugin-registry.ts
@@ -47,6 +47,9 @@ export interface ServerPlugin {
author: string;
description: string;
type: string;
+ /** Requested execution tier ('untrusted' | 'privileged'). Privileged plugins
+ * run in a same-origin sandbox and require admin approval + consent. */
+ tier?: string;
permissions: string[];
entrypoint: string;
enabled: boolean;
diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts
index 7f9bfd7e..53330b9d 100644
--- a/lib/plugin-hooks.ts
+++ b/lib/plugin-hooks.ts
@@ -249,6 +249,16 @@ export const emailHooks = {
// recipients change. Handler receives a DraftView snapshot. Use for AI
// assistants, grammar checkers, etc.
onDraftChange: new HookBus(),
+ // Intercept hook - fires at the very TOP of the composer send path, before
+ // the host builds and submits the message. Handler receives a ComposeSend
+ // request (draft fields, recipients, identityId, attachments, and the user's
+ // sign/encrypt intent) and may TAKE OVER sending entirely: build a raw MIME
+ // message, sign/encrypt it, and submit it via `api.jmap.sendRaw`. Returning
+ // false signals "I handled the send" and the host SKIPS its default
+ // submission. Returning anything else (incl. undefined) lets the host send
+ // normally. This is the send-takeover hook used by the S/MIME plugin to
+ // replace the former native sign+encrypt+sendRaw pipeline.
+ onComposeSend: new HookBus(),
};
// §7.2 Calendar Hooks
@@ -520,6 +530,18 @@ export const renderHooks = {
// Handlers return a new (or extended) badges array.
// Rendered by the email list row component next to the subject line.
onEmailListItemRender: new HookBus(),
+ // Transform hook - runs when an email is opened, BEFORE the viewer computes
+ // the body it will render. Initial value: RenderableBody { html, text,
+ // attachments, handledBy? }. Second argument: MessageContext { id,
+ // bodyStructure, attachments, blobId, contentType, from }. A handler may
+ // inspect the message (e.g. detect S/MIME), fetch the raw blob via
+ // `api.jmap.fetchBlob`, decrypt/verify in-frame, and return a REPLACED body
+ // with `handledBy` set plus optional `verification` status. Return undefined
+ // (or the unchanged value) to pass through. The host still runs the returned
+ // HTML through its sanitizer — plugin output is not trusted blindly. This is
+ // the render-takeover hook used by the S/MIME plugin to replace the former
+ // native detect/decrypt/verify path in the viewer.
+ onRenderEmailBody: new HookBus(),
};
// ─── Aggregate: remove all handlers for a plugin across all buses ───
diff --git a/lib/plugin-sandbox/consent.ts b/lib/plugin-sandbox/consent.ts
index 706799e7..97e6a1d9 100644
--- a/lib/plugin-sandbox/consent.ts
+++ b/lib/plugin-sandbox/consent.ts
@@ -57,6 +57,10 @@ const PERMISSION_LABELS: Record = {
'email:read': { title: 'Read your email', body: 'Access subjects, senders, recipients, body previews, and message bodies of your messages.' },
'email:write': { title: 'Modify your email', body: 'Move, delete, flag, archive, or change keywords on your messages.' },
'email:send': { title: 'Send mail and transform drafts', body: 'Compose and send messages, and modify content right before delivery.' },
+ 'crypto:full': { title: 'Full cryptographic access (high risk)', body: 'Runs with full cryptographic access in a privileged, same-origin context. It can read your message bodies and private keys, store key material, and sign/encrypt on your behalf. Only enable plugins you fully trust — this is comparable to a full-access browser extension.' },
+ 'email:raw-send': { title: 'Send raw messages', body: 'Submit fully-formed (e.g. signed or encrypted) messages on your behalf.' },
+ 'email:blob-read': { title: 'Read raw message content', body: 'Fetch the raw bytes of your messages and attachments (needed to decrypt and verify them).' },
+ 'email:render-takeover': { title: 'Replace rendered email content', body: 'Replace the displayed content of an opened message (e.g. to show decrypted text and a signature-verification badge).' },
'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' },
'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' },
'contacts:read': { title: 'Read your contacts', body: 'Access your address book entries.' },
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index 168c65ac..1f6c0cce 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -6,9 +6,21 @@ import type { InstalledPlugin, Permission } from '../plugin-types';
import { IMPLICIT_PERMISSIONS } from '../plugin-types';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
import { apiFetch } from '../browser-navigation';
import { awaitDialog } from './host-dialog';
+/**
+ * Methods only callable from the privileged (same-origin) tier. These expose
+ * raw message bytes and raw submission, which an untrusted null-origin plugin
+ * must never reach. Enforced in `dispatchApiCall` IN ADDITION to the per-method
+ * permission gate.
+ */
+const PRIVILEGED_ONLY_METHODS = new Set([
+ 'jmap.fetchBlob',
+ 'jmap.sendRaw',
+]);
+
const PERM_PER_METHOD: Record = {
// storage is unscoped by the manifest - implicit.
'storage.get': null,
@@ -23,6 +35,9 @@ const PERM_PER_METHOD: Record = {
// http
'http.post': 'http:post',
'http.fetch': 'http:fetch',
+ // jmap (privileged-tier only; see PRIVILEGED_ONLY_METHODS)
+ 'jmap.fetchBlob': 'email:blob-read',
+ 'jmap.sendRaw': 'email:raw-send',
// admin
'admin.getConfig': 'admin:config',
'admin.getAllConfig': 'admin:config',
@@ -201,6 +216,53 @@ async function doHttpFetch(plugin: InstalledPlugin, rawUrl: string, init?: Plugi
};
}
+// ─── jmap (privileged tier) ───────────────────────────────────
+
+/**
+ * Fetch the raw bytes of a blob by id, using the host's authenticated JMAP
+ * client. The plugin decides WHICH blobId to fetch (e.g. a pkcs7-mime part, or
+ * the full RFC822 message blob) and runs its own detection; the host only
+ * exposes the byte-fetch primitive. Returns a Uint8Array (structured-cloneable
+ * across the postMessage boundary).
+ */
+async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: string }): Promise {
+ if (typeof blobId !== 'string' || !blobId) throw new Error('jmap.fetchBlob: blobId required');
+ const { client } = useAuthStore.getState();
+ if (!client) throw new Error('jmap.fetchBlob: no active session');
+ const buf = await client.fetchBlobArrayBuffer(blobId, opts?.name, opts?.type);
+ return new Uint8Array(buf);
+}
+
+/**
+ * Submit a fully-formed raw RFC822 message (e.g. one a plugin has signed and/or
+ * encrypted) via the host's raw-send path, which also files it into Sent. The
+ * plugin passes raw bytes; the host wraps them in a Blob.
+ */
+async function doJmapSendRaw(
+ rawBytes: ArrayBuffer | ArrayBufferView,
+ identityId: string,
+ opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
+): Promise {
+ if (typeof identityId !== 'string' || !identityId) throw new Error('jmap.sendRaw: identityId required');
+ const { client } = useAuthStore.getState();
+ if (!client) throw new Error('jmap.sendRaw: no active session');
+ const view = rawBytes instanceof ArrayBuffer
+ ? new Uint8Array(rawBytes)
+ : new Uint8Array(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength);
+ // Copy into a fresh ArrayBuffer-backed array so the Blob part is definitely
+ // ArrayBuffer (not SharedArrayBuffer) — also detaches from the caller's view.
+ const copy = new Uint8Array(view.byteLength);
+ copy.set(view);
+ const blob = new Blob([copy.buffer], { type: 'message/rfc822' });
+ return useEmailStore.getState().sendRawEmail(
+ client,
+ blob,
+ identityId,
+ opts?.delayedUntil,
+ opts?.envelopeRecipients,
+ );
+}
+
// ─── admin config (same as before) ────────────────────────────
async function adminGetAll(pluginId: string): Promise> {
@@ -234,7 +296,15 @@ export async function dispatchApiCall(
plugin: InstalledPlugin,
method: string,
args: unknown[],
+ opts?: { privileged?: boolean },
): Promise {
+ // Tier gate: privileged-only methods are refused for untrusted (null-origin)
+ // instances even if the permission is somehow present. Defence-in-depth on
+ // top of the load-time tier resolution.
+ if (PRIVILEGED_ONLY_METHODS.has(method) && !opts?.privileged) {
+ throw new Error(`Method "${method}" requires the privileged plugin tier`);
+ }
+
// Permission gate
const requiredPerm = PERM_PER_METHOD[method];
if (requiredPerm !== undefined && requiredPerm !== null) {
@@ -259,6 +329,13 @@ export async function dispatchApiCall(
case 'http.post': return doHttpPost(plugin, args[0] as string, args[1]);
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
+ case 'jmap.fetchBlob': return doJmapFetchBlob(args[0] as string, args[1] as { name?: string; type?: string } | undefined);
+ case 'jmap.sendRaw': return doJmapSendRaw(
+ args[0] as ArrayBuffer | ArrayBufferView,
+ args[1] as string,
+ args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
+ );
+
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
case 'admin.getAllConfig': return adminGetAll(plugin.id);
case 'admin.setConfig': await adminSet(plugin.id, args[0] as string, args[1]); return undefined;
diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts
index 15cb31d9..cd0cf51b 100644
--- a/lib/plugin-sandbox/host-bridge.ts
+++ b/lib/plugin-sandbox/host-bridge.ts
@@ -7,9 +7,9 @@
// `event.source === iframe.contentWindow`. The iframe's runtime pins the
// parent on the first inbound message.
-import type { InstalledPlugin, SlotName } from '../plugin-types';
+import type { InstalledPlugin, SlotName, PluginTier } from '../plugin-types';
import { dispatchApiCall } from './host-api';
-import { SANDBOX_PATH } from './protocol';
+import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH } from './protocol';
import { withBasePath } from '../browser-navigation';
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
import type {
@@ -55,6 +55,8 @@ export interface BackgroundOptions {
plugin: InstalledPlugin;
code: string;
locale: string;
+ /** Resolved execution tier (from `resolvePluginTier`). */
+ tier: PluginTier;
/** Where the hidden iframe should attach. Defaults to document.body. */
hostContainer?: HTMLElement;
}
@@ -64,6 +66,8 @@ export interface SlotOptions {
slot: SlotName;
code: string;
locale: string;
+ /** Resolved execution tier (from `resolvePluginTier`). */
+ tier: PluginTier;
extraProps: Record;
/** Container element the visible slot iframe is mounted into. */
hostContainer: HTMLElement;
@@ -83,6 +87,8 @@ export class SandboxInstance {
readonly iframe: HTMLIFrameElement;
readonly pluginId: string;
readonly mode: 'background' | 'slot';
+ /** True for the same-origin privileged tier; gates the origin assertion. */
+ readonly privileged: boolean;
readyPromise: Promise;
initPromise: Promise;
@@ -106,6 +112,7 @@ export class SandboxInstance {
) {
this.pluginId = plugin.id;
this.mode = initPayload.mode;
+ this.privileged = initPayload.tier === 'privileged';
// Slot iframes get `extraProps`; encode any function values now so the
// structured-clone send doesn't drop them.
@@ -120,12 +127,15 @@ export class SandboxInstance {
});
this.iframe = document.createElement('iframe');
- // Dev-only: Next's HMR/dev runtime refuses requests from the opaque
- // ("null") origin a strict sandbox produces, so the iframe never
- // hydrates and `sandbox-ready` is never posted. Add allow-same-origin
- // in dev so the iframe shares the host's origin and HMR works.
- // Production keeps the strict opaque-origin sandbox.
- const sandboxFlags = process.env.NODE_ENV === 'development'
+ // Privileged tier: same-origin in BOTH dev and prod so the iframe gets real
+ // `crypto.subtle` + IndexedDB and can run its own bundled crypto libs. The
+ // postMessage RPC membrane still applies; the trust gate is enforced
+ // host-side (signature + admin approval) BEFORE this instance is created.
+ // Untrusted tier: null-origin in prod; dev adds allow-same-origin only
+ // because Next's HMR/dev runtime refuses requests from the opaque ("null")
+ // origin a strict sandbox produces (the iframe would never hydrate and
+ // `sandbox-ready` would never post).
+ const sandboxFlags = this.privileged || process.env.NODE_ENV === 'development'
? 'allow-scripts allow-same-origin'
: 'allow-scripts';
this.iframe.setAttribute('sandbox', sandboxFlags);
@@ -148,7 +158,9 @@ export class SandboxInstance {
// Prefix with the mount path so the sandbox route resolves under a
// subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare
// "/plugin-sandbox" would hit the origin root and 404, breaking plugins.
- this.iframe.src = withBasePath(SANDBOX_PATH);
+ // Privileged plugins load the same-origin route so the CSP/allow-same-origin
+ // pairing is consistent.
+ this.iframe.src = withBasePath(this.privileged ? SANDBOX_PRIVILEGED_PATH : SANDBOX_PATH);
this.listener = (ev) => this.onMessage(ev);
window.addEventListener('message', this.listener);
@@ -174,6 +186,11 @@ export class SandboxInstance {
private onMessage(ev: MessageEvent): void {
if (this.destroyed) return;
if (ev.source !== this.iframe.contentWindow) return;
+ // Privileged iframes are same-origin, so we can additionally pin the origin
+ // (defence-in-depth on top of the contentWindow check). Untrusted iframes
+ // are null-origin (event.origin === "null") in prod and can't be pinned
+ // this way, so the contentWindow check above is the sole gate for them.
+ if (this.privileged && ev.origin !== window.location.origin) return;
const msg = ev.data as SandboxToHost;
if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
@@ -194,7 +211,7 @@ export class SandboxInstance {
const { id, method, args } = msg;
void (async () => {
try {
- const result = await dispatchApiCall(this.plugin, method, args ?? []);
+ const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged });
this.send({ type: 'api-response', id, ok: true, result });
} catch (err) {
this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) });
@@ -318,6 +335,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan
const payload: InitPayload = {
mode: 'background',
pluginId: opts.plugin.id,
+ tier: opts.tier,
manifest: {
id: opts.plugin.id,
version: opts.plugin.version,
@@ -341,6 +359,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
const payload: InitPayload = {
mode: 'slot',
pluginId: opts.plugin.id,
+ tier: opts.tier,
slot: opts.slot,
code: opts.code,
manifest: {
diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts
index 48eda3ae..013cd09b 100644
--- a/lib/plugin-sandbox/loader.ts
+++ b/lib/plugin-sandbox/loader.ts
@@ -14,6 +14,7 @@ import {
} from '../plugin-hooks';
import { verifyBundle } from './bundle-integrity';
import { createBackgroundInstance } from './host-bridge';
+import { resolvePluginTier } from './tier';
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry';
import { cancelPluginDialogs } from './host-api';
import { registerShortcuts } from './shortcuts';
@@ -93,11 +94,22 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise | null = null;
try {
+ // Decide the execution tier BEFORE creating any iframe. A refused privileged
+ // request is a hard error (never silently downgraded to null-origin).
+ const resolution = resolvePluginTier(plugin);
+ if (resolution.tier === null) {
+ storeAccessor?.setPluginStatus(plugin.id, 'error', resolution.error);
+ console.error(`[plugin-sandbox] "${plugin.id}" tier refused: ${resolution.error}`);
+ return;
+ }
+ const tier = resolution.tier;
+
const code = await getBundleCode(plugin);
background = createBackgroundInstance({
plugin,
code,
locale: currentLocale,
+ tier,
});
// Wait for the background runtime to evaluate the bundle, register hooks,
@@ -138,6 +150,7 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise) => callApi('http.post', [path, body]),
fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]),
},
+ // Privileged-tier only (same-origin plugins). Calls throw for untrusted
+ // plugins (the host refuses the method) — these power crypto plugins that
+ // need raw message bytes and raw submission.
+ jmap: {
+ /** Fetch a blob's raw bytes by id. Resolves to a Uint8Array. */
+ fetchBlob: (blobId: string, opts?: { name?: string; type?: string }) =>
+ callApi('jmap.fetchBlob', [blobId, opts]) as Promise,
+ /** Submit a fully-formed raw RFC822 message (already signed/encrypted). */
+ sendRaw: (
+ rawBytes: ArrayBuffer | ArrayBufferView,
+ identityId: string,
+ opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
+ ) => callApi('jmap.sendRaw', [rawBytes, identityId, opts]),
+ },
toast: {
success: (m: string) => { void callApi('toast.success', [m]); },
error: (m: string) => { void callApi('toast.error', [m]); },
diff --git a/lib/plugin-sandbox/tier.ts b/lib/plugin-sandbox/tier.ts
new file mode 100644
index 00000000..2861e2b9
--- /dev/null
+++ b/lib/plugin-sandbox/tier.ts
@@ -0,0 +1,64 @@
+// Single source of truth for which execution tier a plugin runs in.
+//
+// The decision is security-critical: granting 'privileged' creates a
+// same-origin iframe (full WebCrypto + IndexedDB + access to the host origin),
+// so it must NEVER be granted to an unsigned or unapproved bundle. This helper
+// is called by BOTH the loader (load gate, before the same-origin iframe is
+// created) and the plugin store (enable gate), so the rules live in one place.
+//
+// A plugin that *requests* privileged but fails any gate is REFUSED (returns
+// `{ tier: null, error }`), never silently downgraded — a crypto plugin cannot
+// run in a null-origin sandbox, and a silent downgrade would mask tampering.
+
+import type { InstalledPlugin, PluginTier } from '../plugin-types';
+
+export type TierResolution =
+ | { tier: PluginTier; error?: undefined }
+ | { tier: null; error: string };
+
+/**
+ * Resolves the execution tier for a plugin. Returns `{ tier }` on success or
+ * `{ tier: null, error }` when a requested tier cannot be granted (the caller
+ * should put the plugin into an error state and NOT create an iframe).
+ *
+ * Privileged tier gates (ALL required):
+ * 1. Manifest declares the umbrella high-risk permission `crypto:full`.
+ * 2. Signed bundle: only bundles delivered through the admin/server channel
+ * are Ed25519-signed (verified at download time — see `verifySignature`
+ * usage in the plugin store). Self-uploaded bundles are unsigned and can
+ * therefore never reach the privileged tier. `managed` is the signal that
+ * the bundle came through that signed channel.
+ * 3. Admin approval pins operator trust in this specific bundle.
+ * 4. Explicit high-risk consent for `crypto:full` (granted via the consent
+ * dialog at enable time; admin-managed plugins are pre-approved).
+ */
+export function resolvePluginTier(plugin: InstalledPlugin): TierResolution {
+ if (plugin.tier !== 'privileged') {
+ return { tier: 'untrusted' };
+ }
+
+ // 1. Must declare the umbrella high-risk permission.
+ if (!plugin.permissions.includes('crypto:full')) {
+ return { tier: null, error: 'Privileged tier requires the "crypto:full" permission' };
+ }
+
+ // 2 + 3. Trust root: signed (managed) bundle AND admin approval. A bundle
+ // uploaded by the user directly carries no signature, so it cannot be
+ // privileged regardless of what its manifest claims.
+ if (!plugin.managed) {
+ return { tier: null, error: 'Privileged tier requires a signed bundle delivered through the admin channel' };
+ }
+ if (!(plugin.adminApproved || plugin.managed)) {
+ return { tier: null, error: 'Privileged tier requires administrator approval' };
+ }
+
+ // 4. Explicit high-risk consent. Admin-managed plugins are pre-approved by
+ // the operator and skip the per-user prompt (mirrors the existing consent
+ // gate in the plugin store); otherwise the user must have granted crypto:full.
+ const consented = plugin.managed || (plugin.grantedPermissions ?? []).includes('crypto:full');
+ if (!consented) {
+ return { tier: null, error: 'Privileged tier requires explicit consent for "crypto:full"' };
+ }
+
+ return { tier: 'privileged' };
+}
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index a7728746..ca2fea27 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -7,6 +7,18 @@ export type MaybePromise = T | Promise;
export type PluginType = 'ui-extension' | 'sidebar-app' | 'hook' | 'theme';
export type PluginStatus = 'installed' | 'enabled' | 'running' | 'disabled' | 'error';
+/**
+ * Execution tier a plugin runs in.
+ * - 'untrusted' (default): null-origin sandbox iframe. No `crypto.subtle`,
+ * IndexedDB, or localStorage in-frame; all capabilities go through the host
+ * RPC. This is the only tier most plugins ever need.
+ * - 'privileged': same-origin sandbox iframe (full WebCrypto + IndexedDB) so a
+ * plugin can bundle its own crypto libs (e.g. pkijs for S/MIME, openpgp for
+ * PGP). Because same-origin == full host access, entering this tier is gated
+ * by a signed bundle + admin approval + high-risk consent — see
+ * `lib/plugin-sandbox/tier.ts` `resolvePluginTier`.
+ */
+export type PluginTier = 'untrusted' | 'privileged';
export type ThemeVariant = 'light' | 'dark';
// ─── Manifests ───────────────────────────────────────────────
@@ -97,6 +109,13 @@ export interface PluginManifest {
author: string;
description: string;
type: Exclude;
+ /**
+ * Execution tier the plugin requests. Defaults to 'untrusted' when omitted.
+ * Declaring 'privileged' opts into the same-origin tier and requires the
+ * `crypto:full` permission, a signed bundle, and admin approval (enforced by
+ * `resolvePluginTier`). Most plugins should omit this.
+ */
+ tier?: PluginTier;
permissions: string[];
entrypoint: string;
minAppVersion?: string;
@@ -208,6 +227,9 @@ export interface InstalledPlugin {
author: string;
description: string;
type: Exclude;
+ /** Execution tier carried over from the manifest at install time. Defaults
+ * to 'untrusted'. See `PluginTier` and `resolvePluginTier`. */
+ tier?: PluginTier;
permissions: string[];
entrypoint: string;
enabled: boolean;
@@ -852,6 +874,19 @@ export interface PluginI18n {
export const ALL_PERMISSIONS = [
'email:read', 'email:write', 'email:send',
+ // ─── Privileged-tier capabilities (require tier: 'privileged') ───
+ // Umbrella high-risk permission gating same-origin crypto execution. A
+ // plugin holding this runs with full cryptographic access and can read
+ // message bodies and private keys; only granted to a signed, admin-approved
+ // privileged bundle after explicit high-risk consent.
+ 'crypto:full',
+ // Submit a fully-formed raw RFC822 message via JMAP (used after a plugin
+ // signs/encrypts an outgoing message itself).
+ 'email:raw-send',
+ // Fetch a message blob's raw bytes by blobId (for decrypt/verify).
+ 'email:blob-read',
+ // Replace the rendered body of an opened email (render-takeover).
+ 'email:render-takeover',
'calendar:read', 'calendar:write',
'contacts:read', 'contacts:write',
'files:read', 'files:write',
diff --git a/proxy.ts b/proxy.ts
index 5e97fbb5..c108e2f9 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -75,10 +75,17 @@ export async function proxy(request: NextRequest) {
const nonce = crypto.randomUUID();
const isDev = process.env.NODE_ENV === "development";
// The plugin-sandbox iframe document needs `'unsafe-eval'` to run plugin
- // bundles via `new Function`. It is null-origin (sandbox="allow-scripts"),
- // so the relaxation is scoped strictly to that document and never reaches
- // the main app, plus it must be embeddable from `'self'`.
- const isSandboxPath = pathname === "/plugin-sandbox" || pathname.startsWith("/plugin-sandbox/");
+ // bundles via `new Function`. The untrusted route is null-origin
+ // (sandbox="allow-scripts"); the privileged route is same-origin
+ // (allow-same-origin) so a vetted plugin gets real WebCrypto + IndexedDB.
+ // Both get the SAME CSP relaxations (unsafe-eval, frame-ancestors 'self');
+ // the privileged route's extra power comes from the iframe sandbox flag the
+ // host sets, gated by signature + admin approval, NOT from a wider CSP.
+ const isSandboxPath =
+ pathname === "/plugin-sandbox" ||
+ pathname.startsWith("/plugin-sandbox/") ||
+ pathname === "/plugin-sandbox-privileged" ||
+ pathname.startsWith("/plugin-sandbox-privileged/");
const scriptSrc = isSandboxPath
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts
index ad8d0604..2e98df1d 100644
--- a/stores/plugin-store.ts
+++ b/stores/plugin-store.ts
@@ -3,7 +3,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
-import type { InstalledPlugin, PluginStatus } from '@/lib/plugin-types';
+import type { InstalledPlugin, PluginStatus, PluginTier } from '@/lib/plugin-types';
import { pluginStorage } from '@/lib/plugin-storage';
import { extractPlugin } from '@/lib/plugin-validator';
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader';
@@ -76,6 +76,7 @@ export const usePluginStore = create()(
author: manifest.author,
description: manifest.description,
type: manifest.type,
+ ...(manifest.tier ? { tier: manifest.tier } : {}),
permissions: manifest.permissions,
entrypoint: manifest.entrypoint,
enabled: false, // Start disabled, user must enable
@@ -324,6 +325,8 @@ interface ServerPluginInfo {
author: string;
description: string;
type: string;
+ /** Requested execution tier (privileged plugins run same-origin). */
+ tier?: PluginTier;
permissions: string[];
entrypoint: string;
forceEnabled: boolean;
@@ -357,6 +360,7 @@ function serverMeta(sp: ServerPluginInfo) {
description: sp.description,
permissions: sp.permissions,
entrypoint: sp.entrypoint,
+ ...(sp.tier ? { tier: sp.tier } : {}),
managed: true as const,
forceEnabled: sp.forceEnabled,
bundleHash: sp.bundleHash,