Files
SRCmail/lib/plugin-types.ts
T

888 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Plugin & Theme system types
// ─── Common ──────────────────────────────────────────────────
export type Disposable = { dispose: () => void };
export type MaybePromise<T> = T | Promise<T>;
export type PluginType = 'ui-extension' | 'sidebar-app' | 'hook' | 'theme';
export type PluginStatus = 'installed' | 'enabled' | 'running' | 'disabled' | 'error';
export type ThemeVariant = 'light' | 'dark';
// ─── Manifests ───────────────────────────────────────────────
/**
* Advanced theme fields ("Theme API v2"). All optional and additive - a
* legacy theme that ships only `:root`/`.dark` CSS continues to work.
*
* When `apiVersion >= 2` (or any of `tokens`/`extends`/`derive`/`density`/
* `radii`/`typography` is present), the theme compiler runs at install time
* and produces a single CSS string from the structured fields, optionally
* concatenated with a hand-written `theme.css` for fine-grained overrides.
*/
export interface ThemeTokenSet {
/** Tokens applied regardless of variant (emitted into `:root`). */
common?: Record<string, string>;
/** Tokens applied in light mode (emitted into `:root`). */
light?: Record<string, string>;
/** Tokens applied in dark mode (emitted into `.dark`). */
dark?: Record<string, string>;
}
export type ThemeDensity = 'compact' | 'normal' | 'touch';
export interface ThemeRadii {
sm?: string;
md?: string;
lg?: string;
xl?: string;
full?: string;
}
export interface ThemeTypography {
fontSans?: string;
fontMono?: string;
fontDisplay?: string;
baseFontSize?: string;
}
export interface ThemeManifest {
id: string;
name: string;
version: string;
author: string;
description: string;
type: 'theme';
/** @deprecated kept as alias for `banner` so existing themes still work. */
preview?: string;
/**
* Path inside the source repo (relative to manifest.json) to a square
* brand icon shown in marketplace cards and the host's theme picker.
*/
icon?: string;
/**
* Path to a wide promo image shown as the hero on the theme detail
* page. PNG/JPG/WebP, ≤512 KB.
*/
banner?: string;
/**
* Up to 6 screenshot paths shown in the gallery on the detail page.
* Themes typically use this to show light + dark variants.
*/
screenshots?: string[];
variants: ThemeVariant[];
minAppVersion?: string;
// ─── Advanced (Theme API v2) ─────────────────────────────────
/** Theme API version. Defaults to 1 (raw-CSS only). */
apiVersion?: 1 | 2;
/** Inherit tokens/CSS from another installed (or built-in) theme by id. */
extends?: string;
/** Structured colour tokens - compiled into CSS at install time. */
tokens?: ThemeTokenSet;
/** When true, missing standard tokens are derived (e.g. *-foreground from contrast). */
derive?: boolean;
/** Default UI density preset (compact / normal / touch). */
density?: ThemeDensity;
/** Border-radius scale, emitted as `--radius-*` vars. */
radii?: ThemeRadii;
/** Font stacks + base size, emitted as `--font-*` vars. */
typography?: ThemeTypography;
}
export interface PluginManifest {
id: string;
name: string;
version: string;
author: string;
description: string;
type: Exclude<PluginType, 'theme'>;
permissions: string[];
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>>;
/**
* External origins this plugin may embed in iframes (e.g. for YouTube,
* Vimeo, Jitsi). Each entry is a single CSP origin like
* "https://www.youtube-nocookie.com"
* "https://*.example.com:8443"
* Validated at install time and merged into the host CSP `frame-src`.
*/
frameOrigins?: string[];
/**
* External HTTPS origins this plugin may make `api.http.fetch()` requests
* to. Same syntax as `frameOrigins`. Validated at install time. Each
* `api.http.fetch` call's URL must resolve to one of these origins (exact
* host or a `*.host` wildcard match).
*
* Use for plugins that talk directly to a third-party service (e.g.
* Nextcloud, Slack) instead of going through a same-origin /api/* route.
* The remote host must serve CORS headers permitting the webmail origin.
*/
httpOrigins?: string[];
/**
* Same-origin `/api/*` paths this plugin may target via `api.http.post()`.
* Each entry is a path prefix; a call to `api.http.post('/api/X', ...)` is
* accepted iff `'/api/X'` exactly equals an entry OR an entry ends in
* `/` and `'/api/X'` starts with it. With no entry (or an empty array),
* the plugin may not call `api.http.post` even with the `http:post`
* permission. Validated at install time.
*/
apiPostPaths?: string[];
// ─── Marketplace media (NOT shipped in the runtime zip) ──────
/**
* Path inside the source repo (relative to manifest.json) to a square
* brand icon. PNG/SVG/WebP, ≤256 KB, 128×128 or larger recommended.
* The extension directory ingests this from git and serves it on
* marketplace cards and the host's plugin admin UI.
*/
icon?: string;
/**
* Path to a wide promo image (16:9 recommended), shown as the hero on
* the extension detail page. PNG/JPG/WebP, ≤512 KB.
*/
banner?: string;
/**
* Up to 6 screenshot paths shown in the gallery on the detail page.
* Each ≤512 KB; total ≤2 MB. Order is preserved.
*/
screenshots?: string[];
}
export interface SettingFieldSchema {
type: 'boolean' | 'string' | 'number' | 'select';
label: string;
description?: string;
default: unknown;
options?: string[];
min?: number;
max?: number;
}
// ─── Installed Items ─────────────────────────────────────────
export interface InstalledTheme {
id: string;
name: string;
version: string;
author: string;
description: string;
preview?: string; // data: URI or blob URL
css: string; // compiled CSS text - what gets injected
/**
* Optional "skin" CSS shipped by Theme API v2 themes that need to restyle
* actual UI components (toolbars, lists, buttons, etc.) - not just colour
* tokens. Injected into a separate `<style>` tag so it can be stripped
* cleanly when the theme is deactivated. Stored in IndexedDB with the same
* lifecycle as `css` to keep localStorage small.
*/
skin?: string;
variants: ThemeVariant[];
enabled: boolean;
builtIn: boolean;
managed?: boolean;
forceEnabled?: boolean;
// ─── Advanced (Theme API v2) ─ carried over from the manifest ─
apiVersion?: 1 | 2;
extends?: string;
tokens?: ThemeTokenSet;
derive?: boolean;
density?: ThemeDensity;
radii?: ThemeRadii;
typography?: ThemeTypography;
}
export interface InstalledPlugin {
id: string;
name: string;
version: string;
author: string;
description: string;
type: Exclude<PluginType, 'theme'>;
permissions: string[];
entrypoint: string;
enabled: boolean;
status: PluginStatus;
error?: string;
// True when plugin was delivered from server-side admin registry.
managed?: boolean;
// True when plugin is admin-enforced and cannot be disabled locally.
forceEnabled?: boolean;
// True when plugin has been approved by an admin. Unapproved plugins cannot be enabled.
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>>;
/**
* Content hash of the installed bundle, mirrored from the server. Used to
* detect re-uploads of the same version so clients re-download the JS.
*/
bundleHash?: string;
/**
* Validated allowlist of external HTTPS origins this plugin may target via
* `api.http.fetch()`. Carried over from the manifest at install time.
*/
httpOrigins?: string[];
/**
* Validated allowlist of same-origin `/api/*` paths this plugin may target
* via `api.http.post()`. Carried over from the manifest at install time.
*/
apiPostPaths?: string[];
/**
* Permissions the user has explicitly granted. Populated by the in-app
* consent dialog the first time the plugin is enabled. The host API gate
* checks this set in addition to `permissions`, so an unapproved permission
* cannot be exercised even if it appears in the manifest. Managed plugins
* skip the consent prompt (admin pre-approval).
*/
grantedPermissions?: string[];
}
// ─── UI Slots ────────────────────────────────────────────────
export type SlotName =
| 'toolbar-actions'
| 'app-top-banner'
| 'email-banner'
| 'email-footer'
| 'composer-toolbar'
| 'composer-sidebar'
| 'composer-sidebar-right'
| 'sidebar-widget'
| 'email-detail-sidebar'
| 'settings-section'
| 'context-menu-email'
| 'navigation-rail-bottom'
| 'calendar-event-actions'
| 'admin-plugin-page';
export interface SlotRegistration {
pluginId: string;
component: React.ComponentType<Record<string, unknown>>;
order: number;
}
// ─── Plugin API Types ────────────────────────────────────────
export interface ToolbarAction {
id: string;
label: string;
icon?: string;
onClick: () => void;
order?: number;
}
export interface BannerFactory {
shouldShow: (email: EmailReadView) => boolean;
render: React.ComponentType<{ email: EmailReadView }>;
}
export interface SettingsSection {
id: string;
label: string;
icon?: string;
render: React.ComponentType;
}
export interface ComposerAction {
id: string;
label: string;
icon?: string;
onClick: () => void;
order?: number;
}
export interface SidebarWidget {
id: string;
label: string;
render: React.ComponentType;
order?: number;
/**
* For composer sidebars, choose which side of the New Message dialog the
* panel renders on. Defaults to `'left'` for backwards compatibility.
* Ignored by other sidebar slots.
*/
side?: 'left' | 'right';
}
export interface ContextMenuItem {
id: string;
label: string;
icon?: string;
onClick: (emailIds: string[]) => void;
order?: number;
}
export interface AdminPageSection {
id: string;
label: string;
icon?: string;
render: React.ComponentType;
}
export interface CalendarEventAction {
id: string;
label: string;
icon?: string;
onClick: (eventData: CalendarEventFormView, helpers: { setVirtualLocation: (url: string) => void }) => void;
order?: number;
}
export interface CalendarEventFormView {
title: string;
description: string;
start: string;
end: string;
isAllDay: boolean;
location: string;
virtualLocation: string;
calendarId: string;
}
export interface KeyboardShortcut {
id: string;
keys: string;
label: string;
category: string;
handler: () => void;
}
// ─── Read-Only View Types ────────────────────────────────────
// Projected views exposed to plugins - no direct store references
export interface EmailReadView {
id: string;
threadId: string;
mailboxIds: string[];
from: { name: string; email: string }[];
to: { name: string; email: string }[];
cc: { name: string; email: string }[];
subject: string;
receivedAt: string;
isRead: boolean;
isFlagged: boolean;
hasAttachment: boolean;
preview: string;
keywords: string[];
/**
* Parsed Authentication-Results header (SPF, DKIM, DMARC, reverse-DNS).
* Absent on stores that didn't parse the header (e.g. bodies not yet
* fetched). Mirrors the structured shape exposed by the host.
*/
auth?: {
spf?: { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror'; domain?: string };
dkim?: { result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror'; domain?: string; selector?: string };
dmarc?: { result: 'pass' | 'fail' | 'none'; policy?: 'reject' | 'quarantine' | 'none'; domain?: string };
iprev?: { result: 'pass' | 'fail'; ip?: string };
};
}
export interface DraftView {
to: string[];
cc: string[];
bcc: string[];
subject: string;
htmlBody: string;
textBody: string;
identityId: string;
inReplyTo?: string;
attachments: { name: string; type: string; size: number }[];
}
export interface MailboxView {
id: string;
name: string;
role: string | null;
totalEmails: number;
unreadEmails: number;
parentId: string | null;
}
export interface CalendarEventView {
id: string;
calendarId: string;
title: string;
description: string;
start: string;
end: string;
isAllDay: boolean;
location: string;
status: string;
recurrenceRule?: string;
}
export interface CalendarView {
id: string;
name: string;
color: string;
isVisible: boolean;
isDefault: boolean;
}
export interface ContactView {
id: string;
addressBookId: string;
firstName: string;
lastName: string;
emails: string[];
phones: string[];
company: string;
notes: string;
}
export interface AddressBookView {
id: string;
name: string;
isDefault: boolean;
}
export interface ContactGroupView {
id: string;
name: string;
memberCount: number;
}
export interface FileResourceView {
id: string;
name: string;
type: 'file' | 'directory';
size: number;
mimeType: string;
path: string;
modified: string;
}
export interface IdentityView {
id: string;
name: string;
email: string;
replyTo: string | null;
bcc: string | null;
htmlSignature: string;
textSignature: string;
}
export interface TaskView {
id: string;
title: string;
description: string;
isComplete: boolean;
dueDate: string | null;
priority: string;
calendarId: string;
}
export interface TemplateView {
id: string;
name: string;
subject: string;
htmlBody: string;
textBody: string;
}
export interface FilterRuleView {
id: string;
name: string;
isActive: boolean;
conditions: unknown[];
actions: unknown[];
}
export interface KeywordView {
id: string;
name: string;
color: string;
}
export interface QuotaView {
used: number;
total: number;
percentUsed: number;
}
export interface CalendarAlertView {
id: string;
eventId: string;
eventTitle: string;
triggerTime: string;
}
export interface SearchFilters {
from?: string;
to?: string;
subject?: string;
hasAttachment?: boolean;
after?: string;
before?: string;
inMailbox?: string;
}
export interface NewEmailNotification {
emailId: string;
from: { name: string; email: string };
subject: string;
preview: string;
}
export interface VacationView {
isEnabled: boolean;
subject: string;
htmlBody: string;
textBody: string;
fromDate: string | null;
toDate: string | null;
}
export interface KeyboardEventView {
key: string;
code: string;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
metaKey: boolean;
}
export interface AppConfigView {
appName: string;
demoMode: boolean;
stalwartFeaturesEnabled: boolean;
oauthEnabled: boolean;
}
export interface FileInfo {
name: string;
size: number;
type: string;
}
export interface ComposerContext {
mode: 'new' | 'reply' | 'reply-all' | 'forward';
inReplyToId?: string;
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;
}
/**
* Passed to onTransformOutgoingEmail handlers as a transform value.
* Handlers receive the email about to be sent and return a (possibly mutated)
* copy. Use to inject signatures, rewrite links, strip tracking pixels from
* forwards, encrypt the body, etc. Return undefined to pass through unchanged.
*/
export interface OutgoingEmail {
to: string[];
cc: string[];
bcc: string[];
subject: string;
htmlBody: string;
textBody: string;
identityId: string;
/** Sender email derived from the active identity (incl. sub-address tag, when set) */
fromEmail?: string;
attachments: { name: string; type: string; size: number }[];
/** Original message id when this is a reply or forward */
inReplyTo?: string;
/** Free-form custom headers added by the composer or earlier handlers */
headers?: Record<string, string>;
}
/**
* Passed to onBeforeReply / onBeforeReplyAll / onBeforeForward intercept hooks.
* Return false to cancel the operation before the composer opens.
*/
export interface ReplyContext {
originalEmailId: string;
originalEmail: EmailReadView;
mode: 'reply' | 'reply-all' | 'forward';
}
/**
* Second argument to onBuildQuoteHeader transform handlers. Describes the
* original message and how the host plans to render the quote header so
* plugins can produce a replacement block (e.g. an Outlook-style
* From/Sent/To/Cc/Subject section).
*/
export interface QuoteHeaderContext {
mode: 'reply' | 'replyAll' | 'forward';
/** Recipients of the new outgoing message (already resolved by the host). */
newTo: string[];
newCc: string[];
/** Original message metadata. */
from: { name?: string; email: string } | null;
to: { name?: string; email: string }[];
cc: { name?: string; email: string }[];
subject: string;
/** Pre-formatted date string the host already produced (locale-aware). */
date: string;
/** Raw ISO datetime, in case the plugin wants to reformat. */
receivedAt?: string;
/** Active UI locale (BCP-47), useful for Intl.DateTimeFormat in plugins. */
locale: string;
}
/**
* Initial value for the onBuildQuoteHeader transform hook. Plugins return a
* replacement; returning undefined falls through to the next handler or the
* default. The composer splices `html` into HTML drafts and `text` into
* plain-text drafts.
*
* For HTML, returning a header that includes its own surrounding wrapper
* (`<div>...</div>`) is fine; the composer does not add extra wrappers.
* For text, the host appends the quoted body after the header.
*/
export interface QuoteHeader {
html: string;
text: string;
/**
* When false, the composer skips its default blockquote wrapping around the
* quoted body (HTML mode only). Use this for the Outlook style where the
* quoted message is intended to follow the header without indentation.
* Defaults to true (preserve the existing blockquote wrapping).
*/
wrapInBlockquote?: boolean;
}
/**
* Describes an attachment crossing an attachment hook (upload, download, preview).
*/
export interface AttachmentInfo {
name: string;
type: string;
size: number;
/** JMAP blob id, when known (download / preview / after-upload) */
blobId?: string;
/** The email this attachment belongs to (download / preview) */
emailId?: string;
}
/**
* Initial value passed to the onAttachmentPreview transform hook. A handler
* may return a different `previewUrl` (e.g. a proxied/sanitised URL) or a
* React component descriptor identified by `customRenderer`. Return undefined
* to pass through.
*/
export interface AttachmentPreview {
previewUrl?: string;
/** Optional plugin-supplied renderer key. The host resolves the renderer. */
customRenderer?: string;
}
/**
* Passed to onBeforeExternalLink intercept handlers when the user clicks a
* link that would navigate away from the app (typically inside an email body).
* Return false to cancel the navigation. Mutate `href` to rewrite it.
*/
export interface ExternalLinkContext {
href: string;
/** Anchor target ('_blank', '_self', etc.) when set */
target?: string;
/** Email currently in view, when the click came from an email body */
emailId?: string;
}
/**
* Passed to onTextSelectionChange observer when the user selects text inside
* the app. Source identifies which surface produced the selection so plugins
* can scope themselves (e.g. translate-on-select only inside emails).
*/
export interface SelectionContext {
text: string;
source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other';
emailId?: string;
}
/**
* Returned by onCheckEventConflicts transform handlers. The form UI renders
* each warning as an inline notice next to the event time fields.
*/
export interface ConflictWarning {
/** Stable unique key per warning, used as React key */
key: string;
/** Short message - e.g. "Conflicts with: Team Standup" */
message: string;
severity?: 'info' | 'warning' | 'error';
}
/**
* Returned by onProvideSearchResults transform handlers. Plugins extend the
* initial array with their own results (CRM hits, Slack messages, etc.).
* The host renders these in a grouped section below native email results.
*/
export interface ExternalSearchResult {
/** Stable unique key */
key: string;
title: string;
snippet: string;
/** Plugin-handled action when the result row is clicked */
onClick: () => void;
/** Optional source label, e.g. "Slack", "Notion" */
source?: string;
}
/**
* Returned by onProvideRecipientSuggestions transform handlers. Lets plugins
* contribute non-contact suggestions (Slack handles, GitHub usernames, etc.)
* to the recipient autocomplete in the composer.
*/
export interface RecipientSuggestion {
name: string;
email: string;
/** Optional source label rendered as a small tag */
source?: string;
avatarUrl?: string;
}
/**
* Passed to router hooks (onNavigate, onRouteEnter, onRouteLeave).
* Paths are app-internal, e.g. "/mail/inbox", "/calendar".
*/
export interface RouteContext {
path: string;
/** Previous path (only on onNavigate) */
from?: 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 = [
'email:read', 'email:write', 'email:send',
'calendar:read', 'calendar:write',
'contacts:read', 'contacts:write',
'files:read', 'files:write',
'identity:read', 'identity:write',
'filters:read', 'filters:write',
'tasks:read', 'tasks:write',
'templates:read', 'templates:write',
'smime:read',
'vacation:read', 'vacation:write',
'settings:read', 'settings:write',
'security:read',
'auth:observe',
'http:post', 'http:fetch',
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:composer-sidebar',
'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
'ui:calendar-action', 'ui:admin-page',
'admin:config',
'app:lifecycle',
] as const;
export type Permission = (typeof ALL_PERMISSIONS)[number];
/** Permissions always granted regardless of manifest */
export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle'];
// ─── Validation ──────────────────────────────────────────────
export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
export const MAX_THEME_SIZE = 2 * 1024 * 1024; // 2 MB (was 1 MB; v2 themes may ship a skin.css)
/**
* Maximum size of an individual `skin.css` payload after extraction.
* Skins are component-level CSS, not images - anything bigger than this is
* almost certainly bundling assets the validator will refuse anyway.
*/
export const MAX_THEME_SKIN_BYTES = 256 * 1024; // 256 KB
export const ALLOWED_PLUGIN_FILES = new Set([
'.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp',
]);
export const DISALLOWED_CSS_PATTERNS = [
/@import\b/i,
/url\s*\(\s*['"]?https?:/i,
/url\s*\(\s*['"]?data:/i,
/expression\s*\(/i,
/javascript\s*:/i,
/-moz-binding/i,
/behavior\s*:/i,
];