Merge branch 'main' into feature/scheduled-send
# Conflicts: # app/(main)/[locale]/page.tsx # components/layout/sidebar.tsx # stores/email-store.ts # stores/settings-store.ts
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { plainTextToComposerBody } from "../email-composer-utils";
|
||||
import {
|
||||
plainTextToComposerBody,
|
||||
rewriteCidImagesForEditor,
|
||||
replaceInlineImagePlaceholders,
|
||||
INLINE_IMAGE_PLACEHOLDER,
|
||||
} from "../email-composer-utils";
|
||||
|
||||
describe("plainTextToComposerBody", () => {
|
||||
it("returns an empty string for empty input", () => {
|
||||
@@ -24,3 +29,86 @@ describe("plainTextToComposerBody", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteCidImagesForEditor", () => {
|
||||
it("returns input unchanged when no cid: refs are present", () => {
|
||||
const html = '<p>hi</p><img src="https://example.com/x.png">';
|
||||
expect(rewriteCidImagesForEditor(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(rewriteCidImagesForEditor("")).toBe("");
|
||||
});
|
||||
|
||||
it("rewrites a cid: src to placeholder + data-cid", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="cid:abc@x" alt="logo">'
|
||||
);
|
||||
expect(out).toContain('data-cid="abc@x"');
|
||||
expect(out).toContain(`src="${INLINE_IMAGE_PLACEHOLDER}"`);
|
||||
expect(out).toContain('alt="logo"');
|
||||
expect(out).not.toContain('src="cid:');
|
||||
});
|
||||
|
||||
it("preserves an existing data-cid attribute", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="cid:abc" data-cid="kept">'
|
||||
);
|
||||
expect(out).toContain('data-cid="kept"');
|
||||
expect(out).not.toContain('data-cid="abc"');
|
||||
});
|
||||
|
||||
it("leaves non-cid images alone", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="https://example.com/x.png"><img src="cid:y">'
|
||||
);
|
||||
expect(out).toContain('src="https://example.com/x.png"');
|
||||
expect(out).toContain('data-cid="y"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceInlineImagePlaceholders", () => {
|
||||
it("returns input unchanged when the map is empty", () => {
|
||||
const html = '<img src="..." data-cid="x">';
|
||||
expect(replaceInlineImagePlaceholders(html, new Map())).toBe(html);
|
||||
});
|
||||
|
||||
it("swaps the placeholder src to the data URL for matching cids", () => {
|
||||
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="abc">`;
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="data:image/png;base64,AAAA"');
|
||||
expect(out).toContain('data-cid="abc"');
|
||||
});
|
||||
|
||||
it("also rewrites raw cid: src refs that lack a placeholder", () => {
|
||||
const html = '<img src="cid:abc" data-cid="abc">';
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="data:image/png;base64,AAAA"');
|
||||
});
|
||||
|
||||
it("does not overwrite images the user has re-pointed away from the cid", () => {
|
||||
const html =
|
||||
'<img src="https://example.com/other.png" data-cid="abc">';
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="https://example.com/other.png"');
|
||||
expect(out).not.toContain("data:image/png;base64,AAAA");
|
||||
});
|
||||
|
||||
it("leaves unknown cids untouched", () => {
|
||||
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="missing">`;
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('impersonationReplayCache', () => {
|
||||
it('prunes expired jtis on next consume', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
|
||||
// Far in the future — pruning should clear the old entry.
|
||||
// Far in the future - pruning should clear the old entry.
|
||||
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
|
||||
// Re-using the old jti is allowed after pruning (security irrelevant since
|
||||
// the token would fail signature/exp validation upstream).
|
||||
|
||||
@@ -33,7 +33,8 @@ function buildMailboxPathMap(tree: MailboxNode[]): Map<string, string> {
|
||||
const pathMap = new Map<string, string>();
|
||||
const walk = (nodes: MailboxNode[], parentPath = '') => {
|
||||
for (const node of nodes) {
|
||||
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||
const segment = node.role === 'inbox' ? 'INBOX' : node.name;
|
||||
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
|
||||
pathMap.set(node.id, fullPath);
|
||||
if (node.children.length > 0) walk(node.children, fullPath);
|
||||
}
|
||||
@@ -47,7 +48,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
const mailboxes = [makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' })];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('inbox')).toBe('Inbox');
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
});
|
||||
|
||||
it('should produce correct path for a single-level subfolder', () => {
|
||||
@@ -57,7 +58,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('sub1')).toBe('Inbox/Projects');
|
||||
expect(paths.get('sub1')).toBe('INBOX/Projects');
|
||||
});
|
||||
|
||||
it('should produce correct path for deeply nested subfolders', () => {
|
||||
@@ -68,7 +69,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('sub2')).toBe('Inbox/Test/Test2');
|
||||
expect(paths.get('sub2')).toBe('INBOX/Test/Test2');
|
||||
});
|
||||
|
||||
it('should handle multiple root-level folders', () => {
|
||||
@@ -79,7 +80,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('inbox')).toBe('Inbox');
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
expect(paths.get('archive')).toBe('Archive');
|
||||
expect(paths.get('sub1')).toBe('Archive/Work');
|
||||
});
|
||||
@@ -99,9 +100,25 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
expect(paths.has(node.id)).toBe(true);
|
||||
}
|
||||
|
||||
expect(paths.get('inbox')).toBe('Inbox');
|
||||
expect(paths.get('sub1')).toBe('Inbox/Projects');
|
||||
expect(paths.get('sub2')).toBe('Inbox/Projects/Active');
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
expect(paths.get('sub1')).toBe('INBOX/Projects');
|
||||
expect(paths.get('sub2')).toBe('INBOX/Projects/Active');
|
||||
});
|
||||
|
||||
it('uses canonical INBOX even when JMAP returns a localized inbox name', () => {
|
||||
// Stalwart returns localized display names for the inbox based on the
|
||||
// user's locale (e.g. "Entrada" for pt-BR). Sieve fileinto must still
|
||||
// target the IMAP-canonical "INBOX" so the message is filed correctly.
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', name: 'Entrada', role: 'inbox' }),
|
||||
makeMailbox({ id: 'host', name: 'Host', parentId: 'inbox' }),
|
||||
makeMailbox({ id: 'eveo', name: 'EVEO', parentId: 'host' }),
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
expect(paths.get('host')).toBe('INBOX/Host');
|
||||
expect(paths.get('eveo')).toBe('INBOX/Host/EVEO');
|
||||
});
|
||||
|
||||
it('should preserve depth info in flattened tree', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// run.
|
||||
//
|
||||
// Each entry has one of three states: 'pending' (user installed, waiting for
|
||||
// admin), 'approved' (admin signed off), 'denied' (admin refused — kept so we
|
||||
// admin), 'approved' (admin signed off), 'denied' (admin refused - kept so we
|
||||
// don't keep asking).
|
||||
|
||||
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||
|
||||
@@ -155,7 +155,7 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
||||
|
||||
// Hash from the exact bytes the bundle endpoint will serve so the client's
|
||||
// verifyBundle check passes. For src/ sources that means running esbuild
|
||||
// here too — slightly more work per manifest list, but unavoidable since
|
||||
// here too - slightly more work per manifest list, but unavoidable since
|
||||
// the source hash wouldn't match the served bundle.
|
||||
let bundleHash: string;
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// The keypair lives at `data/admin/plugin-signing.key` (PEM-encoded
|
||||
// PKCS#8 private, mode 0600) and is generated lazily on first use. Operators
|
||||
// who want to pin the key out-of-band can drop a pre-generated PEM at that
|
||||
// path before first boot — the loader just reads what's there.
|
||||
// path before first boot - the loader just reads what's there.
|
||||
|
||||
import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign, KeyObject } from 'node:crypto';
|
||||
import { readFile, writeFile, chmod } from 'node:fs/promises';
|
||||
|
||||
@@ -128,11 +128,16 @@ export interface AuditEntry {
|
||||
/** Config keys that map to environment variables */
|
||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum' | 'json'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
||||
appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' },
|
||||
appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' },
|
||||
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
||||
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
|
||||
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
|
||||
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
|
||||
pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' },
|
||||
pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||||
pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||||
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
|
||||
appLogoDarkUrl: { envVar: 'APP_LOGO_DARK_URL', type: 'url', defaultValue: '' },
|
||||
loginLogoLightUrl: { envVar: 'LOGIN_LOGO_LIGHT_URL', type: 'url', defaultValue: '/branding/Bulwark_Logo_Color.svg' },
|
||||
@@ -159,6 +164,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
|
||||
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
||||
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
|
||||
};
|
||||
|
||||
/** Keys that should never be exposed to the client config endpoint */
|
||||
|
||||
@@ -21,3 +21,59 @@ export function plainTextToComposerBody(text: string): string {
|
||||
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Transparent 1x1 GIF used as a stand-in src while the real inline image is
|
||||
// being fetched from JMAP. Browsers cannot render `cid:` URLs directly, so
|
||||
// without this swap the editor would show a broken-image icon (issue #163).
|
||||
export const INLINE_IMAGE_PLACEHOLDER =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
|
||||
/**
|
||||
* Rewrites `<img src="cid:xxx">` references into `<img src="<placeholder>" data-cid="xxx">`
|
||||
* so TipTap can render the editor (the original cid: URL would 404) while still
|
||||
* carrying the cid through edits. The placeholder is swapped to the actual
|
||||
* image data once the corresponding inline blob has been fetched.
|
||||
*/
|
||||
export function rewriteCidImagesForEditor(html: string): string {
|
||||
if (!html || html.indexOf("cid:") === -1) return html;
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
||||
let touched = false;
|
||||
doc.querySelectorAll("img").forEach((img) => {
|
||||
const src = img.getAttribute("src") || "";
|
||||
if (!/^cid:/i.test(src)) return;
|
||||
const cid = src.slice(4);
|
||||
if (!cid) return;
|
||||
if (!img.getAttribute("data-cid")) {
|
||||
img.setAttribute("data-cid", cid);
|
||||
}
|
||||
img.setAttribute("src", INLINE_IMAGE_PLACEHOLDER);
|
||||
touched = true;
|
||||
});
|
||||
return touched ? doc.body.innerHTML : html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the placeholder src on `<img data-cid="...">` elements with the
|
||||
* resolved data URL once the inline blob has been fetched. Leaves images
|
||||
* whose src has been edited away from the placeholder/cid alone.
|
||||
*/
|
||||
export function replaceInlineImagePlaceholders(
|
||||
html: string,
|
||||
cidToDataUrl: Map<string, string>
|
||||
): string {
|
||||
if (!html || cidToDataUrl.size === 0) return html;
|
||||
if (html.indexOf("data-cid") === -1) return html;
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
||||
let changed = false;
|
||||
doc.querySelectorAll("img[data-cid]").forEach((img) => {
|
||||
const cid = img.getAttribute("data-cid");
|
||||
if (!cid) return;
|
||||
const dataUrl = cidToDataUrl.get(cid);
|
||||
if (!dataUrl) return;
|
||||
const currentSrc = img.getAttribute("src") || "";
|
||||
if (currentSrc !== INLINE_IMAGE_PLACEHOLDER && !/^cid:/i.test(currentSrc)) return;
|
||||
img.setAttribute("src", dataUrl);
|
||||
changed = true;
|
||||
});
|
||||
return changed ? doc.body.innerHTML : html;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export function verifyImpersonationJwt(
|
||||
}
|
||||
const [headerB64, payloadB64, sigB64] = parts;
|
||||
|
||||
// Header — reject anything but HS256 BEFORE attempting signature verification.
|
||||
// Header - reject anything but HS256 BEFORE attempting signature verification.
|
||||
const header = parseSegment(headerB64) as Record<string, unknown>;
|
||||
if (header.alg !== 'HS256') {
|
||||
throw new ImpersonationJwtError('alg', `Unsupported alg '${String(header.alg)}'`);
|
||||
@@ -91,7 +91,7 @@ export function verifyImpersonationJwt(
|
||||
throw new ImpersonationJwtError('alg', `Unsupported typ '${String(header.typ)}'`);
|
||||
}
|
||||
|
||||
// Signature — constant-time compare.
|
||||
// Signature - constant-time compare.
|
||||
const expected = createHmac('sha256', secret).update(`${headerB64}.${payloadB64}`).digest();
|
||||
const provided = base64UrlDecode(sigB64);
|
||||
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
||||
@@ -109,7 +109,7 @@ export function verifyImpersonationJwt(
|
||||
const jti = assertString(payload.jti, 'jti');
|
||||
const mailbox = assertString(payload.mailbox, 'mailbox');
|
||||
|
||||
// Mailbox MUST NOT contain '%' or ':' — those would inject into the
|
||||
// Mailbox MUST NOT contain '%' or ':' - those would inject into the
|
||||
// master-user auth header.
|
||||
if (mailbox.includes('%') || mailbox.includes(':')) {
|
||||
throw new ImpersonationJwtError('mailbox', "mailbox must not contain '%' or ':'");
|
||||
@@ -126,7 +126,7 @@ export function verifyImpersonationJwt(
|
||||
if (iat - CLOCK_SKEW_SEC > nowSec) {
|
||||
throw new ImpersonationJwtError('iat', 'Token issued in the future');
|
||||
}
|
||||
// Hard ceiling on lifetime — refuse long-lived handoff tokens even if the
|
||||
// Hard ceiling on lifetime - refuse long-lived handoff tokens even if the
|
||||
// signer asked for one.
|
||||
if (exp - iat > MAX_TOKEN_LIFETIME_SEC) {
|
||||
throw new ImpersonationJwtError('lifetime', `Token lifetime exceeds ${MAX_TOKEN_LIFETIME_SEC}s ceiling`);
|
||||
@@ -158,7 +158,7 @@ class ReplayCache {
|
||||
this.prune(now);
|
||||
if (this.entries.has(jti)) return false;
|
||||
if (this.entries.size >= REPLAY_CACHE_MAX) {
|
||||
// Evict the oldest entry — Map preserves insertion order.
|
||||
// Evict the oldest entry - Map preserves insertion order.
|
||||
const first = this.entries.keys().next().value;
|
||||
if (first !== undefined) this.entries.delete(first);
|
||||
}
|
||||
@@ -171,7 +171,7 @@ class ReplayCache {
|
||||
if (exp + CLOCK_SKEW_SEC < now) {
|
||||
this.entries.delete(jti);
|
||||
} else {
|
||||
// Insertion order means later entries are no older than this one — but
|
||||
// Insertion order means later entries are no older than this one - but
|
||||
// exp isn't strictly monotonic with insertion, so we can't break here.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface ImpersonationConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null when impersonation is not configured — the route MUST surface
|
||||
* Returns null when impersonation is not configured - the route MUST surface
|
||||
* that as a 404 so an unconfigured deployment doesn't expose the endpoint.
|
||||
*
|
||||
* Required env:
|
||||
@@ -38,7 +38,7 @@ export function readImpersonationConfig(): ImpersonationConfig | null {
|
||||
* legacy env fallbacks. Returns null if none is configured.
|
||||
*
|
||||
* The impersonation flow is server-to-server (no user input), so we never
|
||||
* accept a custom endpoint — only admin-configured URLs.
|
||||
* accept a custom endpoint - only admin-configured URLs.
|
||||
*/
|
||||
export async function resolveImpersonationServerUrl(): Promise<string | null> {
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
@@ -185,6 +185,12 @@ export interface Identity {
|
||||
textSignature?: string;
|
||||
htmlSignature?: string;
|
||||
mayDelete: boolean;
|
||||
// See `Calendar.localAccountId` - set when the Pro shell aggregates
|
||||
// identities from multiple connected accounts so we can route sends
|
||||
// back through the owning JMAP client. `accountName` is the
|
||||
// user-facing label for the dropdown's optgroup.
|
||||
localAccountId?: string;
|
||||
accountName?: string;
|
||||
}
|
||||
|
||||
// RFC 9553 JSContact / RFC 9610 JMAP for Contacts
|
||||
@@ -198,6 +204,9 @@ export interface ContactCard {
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
// Local account-store ID - set when the Pro shell aggregates contacts
|
||||
// from multiple connected accounts. See `Calendar.localAccountId`.
|
||||
localAccountId?: string;
|
||||
language?: string;
|
||||
name?: ContactName;
|
||||
nicknames?: Record<string, ContactNickname>;
|
||||
@@ -398,6 +407,8 @@ export interface AddressBook {
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
// See `Calendar.localAccountId` - same purpose for address books.
|
||||
localAccountId?: string;
|
||||
}
|
||||
|
||||
export interface AddressBookRights {
|
||||
@@ -472,6 +483,11 @@ export interface Calendar {
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
// Local account-store ID (per JMAP server connection). Populated when the
|
||||
// Pro shell aggregates calendars from multiple connected accounts so we
|
||||
// can route mutations to the right client. Distinct from `accountId`
|
||||
// which is the JMAP server's own account UUID.
|
||||
localAccountId?: string;
|
||||
}
|
||||
|
||||
export interface CalendarRights {
|
||||
@@ -493,6 +509,8 @@ export interface CalendarEvent {
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
// See `Calendar.localAccountId` - same purpose for events.
|
||||
localAccountId?: string;
|
||||
isDraft: boolean;
|
||||
isOrigin: boolean;
|
||||
utcStart: string | null;
|
||||
|
||||
@@ -19,7 +19,7 @@ import { all as allActive, get as getActive } from './plugin-sandbox/registry';
|
||||
* Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__`
|
||||
* so blob-imported plugin code could resolve `react`. With the sandbox model
|
||||
* plugins receive React injected as a function argument inside their iframe
|
||||
* runtime — there is nothing to expose on the host window.
|
||||
* runtime - there is nothing to expose on the host window.
|
||||
*
|
||||
* Kept as a no-op for callers that still invoke it during app bootstrap.
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// a bundle the loader verifies the signature; mismatch refuses the load.
|
||||
//
|
||||
// User-installed plugins (uploaded via the file picker, no server hop) have
|
||||
// no signature — verification is skipped for those, since the user is
|
||||
// no signature - verification is skipped for those, since the user is
|
||||
// installing their own code. Verification kicks in for server-managed
|
||||
// bundles only (the `managed: true` flag on `InstalledPlugin`).
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||
'admin.getAllConfig': 'admin:config',
|
||||
'admin.setConfig': 'admin:config',
|
||||
'admin.deleteConfig': 'admin:config',
|
||||
// ui — any plugin can ask the host to render a modal or open a URL.
|
||||
// ui - any plugin can ask the host to render a modal or open a URL.
|
||||
'ui.confirm': null,
|
||||
'ui.alert': null,
|
||||
'ui.openExternalUrl': null,
|
||||
@@ -289,7 +289,7 @@ export async function dispatchApiCall(
|
||||
}
|
||||
case 'ui.openExternalUrl': {
|
||||
const url = String(args[0] ?? '');
|
||||
// Only http(s) — the sandbox should not be able to navigate the host
|
||||
// Only http(s) - the sandbox should not be able to navigate the host
|
||||
// anywhere internal, nor open javascript:/data:/file: schemes.
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(url); } catch { throw new Error('ui.openExternalUrl: invalid URL'); }
|
||||
|
||||
@@ -39,7 +39,7 @@ function encodeCallbacks(
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => encodeCallbacks(v, table, depth + 1));
|
||||
}
|
||||
// Plain object — copy own enumerable keys.
|
||||
// Plain object - copy own enumerable keys.
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = encodeCallbacks(v, table, depth + 1);
|
||||
@@ -118,7 +118,15 @@ export class SandboxInstance {
|
||||
});
|
||||
|
||||
this.iframe = document.createElement('iframe');
|
||||
this.iframe.setAttribute('sandbox', 'allow-scripts');
|
||||
// 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'
|
||||
? 'allow-scripts allow-same-origin'
|
||||
: 'allow-scripts';
|
||||
this.iframe.setAttribute('sandbox', sandboxFlags);
|
||||
this.iframe.setAttribute('referrerpolicy', 'no-referrer');
|
||||
this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`;
|
||||
this.iframe.style.border = 'none';
|
||||
@@ -153,7 +161,7 @@ export class SandboxInstance {
|
||||
|
||||
private send(msg: HostToSandbox): void {
|
||||
// targetOrigin '*' is required because the iframe is opaque-origin. The
|
||||
// payload contains no host secrets — bundle code and manifest fields the
|
||||
// payload contains no host secrets - bundle code and manifest fields the
|
||||
// plugin already owns.
|
||||
this.iframe.contentWindow?.postMessage(msg, '*');
|
||||
}
|
||||
@@ -228,6 +236,10 @@ export class SandboxInstance {
|
||||
}
|
||||
|
||||
case 'slot-resize':
|
||||
// The iframe has no intrinsic height - sync it to the content height
|
||||
// the sandbox reported, otherwise the wrapper reserves space but the
|
||||
// iframe stays at 0px and the slot appears blank.
|
||||
this.iframe.style.height = `${msg.height}px`;
|
||||
this.slotResizeCb?.(msg.height);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,20 +65,44 @@ async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
|
||||
|
||||
// ─── Load ─────────────────────────────────────────────────────
|
||||
|
||||
// Bound on how long the sandbox iframe may take to send back init-done.
|
||||
// Without this a single misbehaving plugin can hang the whole load loop.
|
||||
// 30s accommodates Next.js dev-mode per-iframe compile + SSR + hydrate on
|
||||
// slower machines, while still catching truly stuck plugins.
|
||||
const INIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${ms}ms`));
|
||||
}, ms);
|
||||
promise.then(
|
||||
(v) => { clearTimeout(timer); resolve(v); },
|
||||
(e) => { clearTimeout(timer); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
let background: ReturnType<typeof createBackgroundInstance> | null = null;
|
||||
try {
|
||||
const code = await getBundleCode(plugin);
|
||||
const background = createBackgroundInstance({
|
||||
background = createBackgroundInstance({
|
||||
plugin,
|
||||
code,
|
||||
locale: currentLocale,
|
||||
});
|
||||
|
||||
// Wait for the background runtime to evaluate the bundle, register hooks,
|
||||
// and enumerate slots.
|
||||
const info = await background.initPromise;
|
||||
// and enumerate slots. Bounded so a stuck iframe doesn't hang activation.
|
||||
const bg = background;
|
||||
const info = await withTimeout(
|
||||
bg.initPromise,
|
||||
INIT_TIMEOUT_MS,
|
||||
`[plugin-sandbox] "${plugin.id}" init`,
|
||||
);
|
||||
|
||||
// Wire hook proxies: every hookName the plugin registered gets a HookBus
|
||||
// entry whose handler dispatches into the sandbox. `shortcut:<id>` hooks
|
||||
@@ -93,7 +117,7 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
}
|
||||
const proxy = async (...args: unknown[]) => {
|
||||
try {
|
||||
return await background.invokeHook(hookName, args);
|
||||
return await bg.invokeHook(hookName, args);
|
||||
} catch (err) {
|
||||
pluginErrorTracker.record(plugin.id, err);
|
||||
throw err;
|
||||
@@ -103,13 +127,13 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
}
|
||||
|
||||
// Install plugin-declared keyboard shortcuts.
|
||||
const shortcutDispose = registerShortcuts(background, info.shortcuts ?? []);
|
||||
const shortcutDispose = registerShortcuts(bg, info.shortcuts ?? []);
|
||||
hookDisposables.push({ dispose: shortcutDispose });
|
||||
|
||||
registerActive({
|
||||
plugin,
|
||||
code,
|
||||
background,
|
||||
background: bg,
|
||||
slotOffers: info.slots,
|
||||
hookDisposables,
|
||||
});
|
||||
@@ -120,6 +144,11 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
const msg = (err as Error).message ?? String(err);
|
||||
storeAccessor?.setPluginStatus(plugin.id, 'error', msg);
|
||||
console.error(`[plugin-sandbox] Failed to load "${plugin.id}":`, err);
|
||||
// Tear down the hung/failed iframe so it can't keep posting messages or
|
||||
// occupy DOM and resources after we've given up on it.
|
||||
if (background) {
|
||||
try { background.destroy(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// Shared message-protocol types for host ↔ sandbox postMessage RPC.
|
||||
//
|
||||
// The sandbox iframe is null-origin (`sandbox="allow-scripts"`), so postMessage
|
||||
// events arrive with `event.origin === "null"`. The host pins messages by the
|
||||
// iframe's `contentWindow` reference instead. All values crossing the boundary
|
||||
// must be structured-cloneable: no functions, no DOM nodes, no class instances.
|
||||
// In production the sandbox iframe is null-origin (`sandbox="allow-scripts"`),
|
||||
// so postMessage events arrive with `event.origin === "null"`. In development
|
||||
// the iframe also gets `allow-same-origin` so Next's HMR/dev runtime works;
|
||||
// `event.origin` is then the host's actual origin. The host pins messages by
|
||||
// the iframe's `contentWindow` reference in either case. All values crossing
|
||||
// the boundary must be structured-cloneable: no functions, no DOM nodes, no
|
||||
// class instances.
|
||||
|
||||
import type { SlotName } from '../plugin-types';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// 5. In slot mode: look up `slots[slot].component`, render it into the
|
||||
// iframe body, push height back via ResizeObserver.
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import * as ReactJSXRuntime from 'react/jsx-runtime';
|
||||
@@ -54,6 +54,9 @@ let pluginExports: PluginExports | null = null;
|
||||
let mode: 'background' | 'slot' | null = null;
|
||||
let slotName: SlotName | null = null;
|
||||
let bootDone = false;
|
||||
// Guards the initial sandbox-ready post against React strict mode's double
|
||||
// useEffect invocation; the parent only needs to be pinged once per iframe.
|
||||
let readyPosted = false;
|
||||
|
||||
const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
|
||||
const pendingCallbacks = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
|
||||
@@ -178,7 +181,7 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
/**
|
||||
* Resolve a bundler-emitted `require(name)` call inside the sandbox. Plugin
|
||||
* bundlers should be configured to externalise React; the runtime provides
|
||||
* those modules here. Anything else is refused — the sandbox has no Node-
|
||||
* those modules here. Anything else is refused - the sandbox has no Node-
|
||||
* compatible module resolution and we don't want plugins probing globals.
|
||||
*
|
||||
* The host injects the per-plugin API as `@plugin-host`, so plugin code can
|
||||
@@ -334,7 +337,7 @@ function bootSlot(payload: SlotInit): void {
|
||||
sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] });
|
||||
}
|
||||
|
||||
// Populated by bootSlot — receives `props-update` messages.
|
||||
// Populated by bootSlot - receives `props-update` messages.
|
||||
let slotPropsUpdater: ((next: Record<string, unknown>) => void) | null = null;
|
||||
|
||||
async function handleInit(payload: InitPayload): Promise<void> {
|
||||
@@ -436,13 +439,13 @@ function handleHostMessage(ev: MessageEvent): void {
|
||||
// ─── React entry ─────────────────────────────────────────────
|
||||
|
||||
export function SandboxRuntime(): React.JSX.Element {
|
||||
const inited = useRef(false);
|
||||
useEffect(() => {
|
||||
if (inited.current) return;
|
||||
inited.current = true;
|
||||
window.addEventListener('message', handleHostMessage);
|
||||
// Initial ping. We don't know parent origin yet, so '*' is required.
|
||||
if (window.parent && window.parent !== window) {
|
||||
// Guard at module scope so React strict mode's double-invoke doesn't
|
||||
// re-post (and so a re-post can't race with the parent's init reply).
|
||||
if (!readyPosted && window.parent && window.parent !== window) {
|
||||
readyPosted = true;
|
||||
window.parent.postMessage({ type: 'sandbox-ready' } satisfies SandboxToHost, '*');
|
||||
}
|
||||
return () => {
|
||||
|
||||
+16
-2
@@ -1,4 +1,5 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { verifySetupToken } from './token';
|
||||
|
||||
export const SETUP_COOKIE = 'bulwark_setup_token';
|
||||
@@ -21,13 +22,26 @@ export async function authenticateWizardRequest(): Promise<boolean> {
|
||||
return verifySetupToken(token);
|
||||
}
|
||||
|
||||
export function buildSessionCookieAttributes() {
|
||||
export function buildSessionCookieAttributes(request?: NextRequest) {
|
||||
// Match Secure to the actual request protocol. Browsers drop Secure cookies
|
||||
// on plain HTTP, so unconditionally setting Secure in production breaks
|
||||
// setup over HTTP - the operator gets "Wizard session required" on every
|
||||
// step. The wizard surfaces a cleartext-credentials warning in the UI when
|
||||
// HTTPS isn't in use.
|
||||
return {
|
||||
name: SETUP_COOKIE,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: request ? isHttpsRequest(request) : process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
function isHttpsRequest(request: NextRequest): boolean {
|
||||
const forwarded = request.headers.get('x-forwarded-proto');
|
||||
if (forwarded) {
|
||||
return forwarded.split(',')[0]!.trim().toLowerCase() === 'https';
|
||||
}
|
||||
return request.nextUrl.protocol === 'https:';
|
||||
}
|
||||
|
||||
@@ -117,6 +117,99 @@ export async function fetchUnifiedEmails(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a text search across every account that has a mailbox for the given
|
||||
* unified role, merging and sorting the results by receivedAt descending. The
|
||||
* fan-out / error-collection shape mirrors `fetchUnifiedEmails` so the caller
|
||||
* sees consistent behavior between browse and search.
|
||||
*/
|
||||
export async function searchUnifiedEmails(
|
||||
accounts: UnifiedAccountClient[],
|
||||
role: UnifiedMailboxRole,
|
||||
query: string,
|
||||
limit: number,
|
||||
position: number,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
|
||||
return account.client.searchEmails(query, mailbox.id, undefined, limit, position);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `searchUnifiedEmails`, but uses the JMAP advanced filter shape. The
|
||||
* caller supplies a `filterFor(mailboxId)` factory because each account's role
|
||||
* mailbox has a different id and the filter must include the right
|
||||
* `inMailbox` clause per request.
|
||||
*/
|
||||
export async function advancedSearchUnifiedEmails(
|
||||
accounts: UnifiedAccountClient[],
|
||||
role: UnifiedMailboxRole,
|
||||
filterFor: (mailboxId: string) => Record<string, unknown>,
|
||||
limit: number,
|
||||
position: number,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
|
||||
return account.client.advancedSearchEmails(filterFor(mailbox.id), undefined, limit, position);
|
||||
});
|
||||
}
|
||||
|
||||
async function fanOutUnifiedQuery(
|
||||
accounts: UnifiedAccountClient[],
|
||||
role: UnifiedMailboxRole,
|
||||
run: (
|
||||
account: UnifiedAccountClient,
|
||||
mailbox: Mailbox,
|
||||
) => Promise<{ emails: Email[]; total: number; hasMore: boolean }>,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
const errors = new Map<string, string>();
|
||||
|
||||
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 run(account, mailbox);
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
+8
-8
@@ -57,7 +57,7 @@ function unfoldLines(vcf: string): string {
|
||||
.replace(/\n[ \t]/g, "");
|
||||
}
|
||||
|
||||
// RFC 6868 parameter value encoding — used inside parameter values only.
|
||||
// RFC 6868 parameter value encoding - used inside parameter values only.
|
||||
// Caret-encoded sequences: ^n → LF, ^^ → ^, ^' → DQUOTE.
|
||||
function decodeParamValue(s: string): string {
|
||||
let out = "";
|
||||
@@ -301,7 +301,7 @@ export function parseVCard(vcfString: string): ContactCard[] {
|
||||
function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
const id = `import-${generateUUID()}`;
|
||||
const card: ContactCard = { id, addressBookIds: {} };
|
||||
// Deferred BIRTHPLACE/DEATHPLACE values — attach to anniversary at end,
|
||||
// Deferred BIRTHPLACE/DEATHPLACE values - attach to anniversary at end,
|
||||
// because the BDAY/DEATHDATE entry may appear in any order.
|
||||
let birthPlace: string | undefined;
|
||||
let deathPlace: string | undefined;
|
||||
@@ -465,7 +465,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
mediaType: mime,
|
||||
};
|
||||
} else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) {
|
||||
// vCard 4.0 URI value (data URI or URL) — no ENCODING param.
|
||||
// vCard 4.0 URI value (data URI or URL) - no ENCODING param.
|
||||
card.media[`m${idx}`] = {
|
||||
kind: "photo",
|
||||
uri: val,
|
||||
@@ -760,7 +760,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
}
|
||||
|
||||
case "ORG-DIRECTORY": {
|
||||
// RFC 6715 §2.4 — directory URI for the contact's organization.
|
||||
// RFC 6715 §2.4 - directory URI for the contact's organization.
|
||||
if (!card.directories) card.directories = {};
|
||||
const idx = Object.keys(card.directories).length;
|
||||
card.directories[`d${idx}`] = {
|
||||
@@ -789,14 +789,14 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
break;
|
||||
|
||||
case "GRAMGENDER": {
|
||||
// RFC 9554 §3.4 — grammatical gender (animate/common/feminine/masculine/neuter).
|
||||
// RFC 9554 §3.4 - grammatical gender (animate/common/feminine/masculine/neuter).
|
||||
if (!card.speakToAs) card.speakToAs = {};
|
||||
card.speakToAs.grammaticalGender = val.toLowerCase();
|
||||
break;
|
||||
}
|
||||
|
||||
case "PRONOUNS": {
|
||||
// RFC 9554 §3.5 — free-form pronouns. May appear multiple times.
|
||||
// RFC 9554 §3.5 - free-form pronouns. May appear multiple times.
|
||||
if (!card.speakToAs) card.speakToAs = {};
|
||||
if (!card.speakToAs.pronouns) card.speakToAs.pronouns = {};
|
||||
const pkey = `p${Object.keys(card.speakToAs.pronouns).length}`;
|
||||
@@ -1058,7 +1058,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
}
|
||||
|
||||
if (contact.personalInfo) {
|
||||
// RFC 6715 — emit EXPERTISE / HOBBY / INTEREST with LEVEL.
|
||||
// RFC 6715 - emit EXPERTISE / HOBBY / INTEREST with LEVEL.
|
||||
const levelOut: Record<string, Record<string, string>> = {
|
||||
expertise: { high: "expert", medium: "average", low: "beginner" },
|
||||
hobby: { high: "high", medium: "medium", low: "low" },
|
||||
@@ -1167,7 +1167,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
}
|
||||
|
||||
if (contact.created) {
|
||||
// RFC 9554 §3.1 — CREATED is a timestamp; emit as-is for round-trip.
|
||||
// RFC 9554 §3.1 - CREATED is a timestamp; emit as-is for round-trip.
|
||||
lines.push(`CREATED:${contact.created}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Lenient semver comparison for the marketplace's `minAppVersion` gate.
|
||||
*
|
||||
* Parses "major.minor.patch" (any segment may be missing — treated as 0)
|
||||
* Parses "major.minor.patch" (any segment may be missing - treated as 0)
|
||||
* and ignores pre-release / build metadata. Returns negative, zero or
|
||||
* positive in the same shape as Array.prototype.sort comparators.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user