diff --git a/.env.example b/.env.example index 065c9f924..52a883236 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com # /app/data/settings - mount a persistent volume there (see docker-compose.yml). # SETTINGS_DATA_DIR=./data/settings +# Directory for encrypted signature image assets (default: ./data/signatures). +# Used when inserting images into HTML signatures. Requires SESSION_SECRET. +# Mount a persistent volume in Docker (see docker-compose.yml). +# SIGNATURE_DATA_DIR=./data/signatures + # ============================================================================= # Admin Dashboard Data # ============================================================================= diff --git a/README.md b/README.md index e095fdec2..c61489253 100644 --- a/README.md +++ b/README.md @@ -188,9 +188,13 @@ SESSION_SECRET_FILE=/session-secret # path to a file containing the secret SETTINGS_SYNC_ENABLED=true SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker + +# Persistent signature images (embedded as CID inline parts when sending). +# Requires SESSION_SECRET. Mount as a volume in Docker. +SIGNATURE_DATA_DIR=./data/signatures ``` -Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`. +Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`. Signature images are stored separately from JMAP Identity signatures (which are size-limited on some servers) and are embedded into outgoing mail as inline MIME parts — they are not hosted as public URLs. diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 8d853a746..5376f85e2 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,13 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; -import { decryptSession } from '@/lib/auth/crypto'; -import { sessionCookieName } from '@/lib/auth/session-cookie'; -import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { configManager } from '@/lib/admin/config-manager'; import { hasSessionSecret } from '@/lib/auth/session-secret'; -import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; +import { verifyAccountIdentity } from '@/lib/auth/verify-account-identity'; function classifyError(error: unknown): { message: string; status: number } { const code = (error as NodeJS.ErrnoException).code; @@ -56,43 +52,6 @@ function isEnabled(): boolean { return flagOn && hasSessionSecret(); } -/** Strip trailing slashes so differently-formatted URLs still match. */ -function normalizeUrl(url: string): string { - return url.replace(/\/+$/, ''); -} - -/** - * Verify identity against session cookies across all account slots. - * With multi-account, the requesting account may be on any slot. - * Checks both basic-auth session cookies and stalwart auth context cookies - * (used by OAuth/SSO and TOTP-upgraded sessions). - * Returns true only if a matching cookie is found. - */ -async function verifyIdentity(username: string, serverUrl: string): Promise { - const cookieStore = await cookies(); - const normalizedServerUrl = normalizeUrl(serverUrl); - - for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { - // Check basic-auth session cookie - const token = cookieStore.get(sessionCookieName(slot))?.value; - if (token) { - const session = decryptSession(token); - if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) { - return true; - } - } - - // Check stalwart auth context cookie (set for all auth modes) - const ctx = readStalwartAuthContextFromStore(cookieStore, slot); - if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) { - return true; - } - } - - // No matching session found (or no cookies at all) → reject - return false; -} - export async function GET(request: NextRequest) { if (!isEnabled()) { return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 }); @@ -104,7 +63,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 }); } - if (!(await verifyIdentity(username, serverUrl))) { + if (!(await verifyAccountIdentity(username, serverUrl))) { return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); } @@ -135,7 +94,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Settings must be an object' }, { status: 400 }); } - if (!(await verifyIdentity(username, serverUrl))) { + if (!(await verifyAccountIdentity(username, serverUrl))) { return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); } @@ -188,7 +147,7 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } - if (!(await verifyIdentity(username, serverUrl))) { + if (!(await verifyAccountIdentity(username, serverUrl))) { return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); } diff --git a/app/api/signatures/assets/[id]/route.ts b/app/api/signatures/assets/[id]/route.ts new file mode 100644 index 000000000..698f2a9b8 --- /dev/null +++ b/app/api/signatures/assets/[id]/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { hasSessionSecret } from '@/lib/auth/session-secret'; +import { verifyAccountIdentity } from '@/lib/auth/verify-account-identity'; +import { + SignatureAssetError, + loadSignatureAsset, + deleteSignatureAsset, +} from '@/lib/signature-assets'; + +function classifyAssetError(error: unknown): { message: string; status: number } { + if (error instanceof SignatureAssetError) { + switch (error.code) { + case 'not_configured': + return { message: error.message, status: 503 }; + case 'invalid_identity': + case 'invalid_asset_id': + case 'invalid_mime': + case 'too_large': + case 'too_many': + return { message: error.message, status: 400 }; + case 'not_found': + return { message: error.message, status: 404 }; + case 'forbidden': + return { message: error.message, status: 403 }; + case 'path': + return { message: 'Invalid request', status: 400 }; + } + } + const msg = error instanceof Error ? error.message : 'Unknown error'; + return { message: `Internal server error: ${msg}`, status: 500 }; +} + +type RouteContext = { params: Promise<{ id: string }> }; + +/** + * GET /api/signatures/assets/:id + * Authenticated fetch of asset bytes for the composer / identity editor. + */ +export async function GET(request: NextRequest, context: RouteContext) { + if (!hasSessionSecret()) { + return NextResponse.json( + { error: 'Signature image storage requires SESSION_SECRET' }, + { status: 503 }, + ); + } + + const username = request.headers.get('x-settings-username'); + const serverUrl = request.headers.get('x-settings-server'); + if (!username || !serverUrl) { + return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 }); + } + + if (!(await verifyAccountIdentity(username, serverUrl))) { + return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); + } + + try { + const { id } = await context.params; + const { asset, bytes } = await loadSignatureAsset(username, serverUrl, id); + return new NextResponse(new Uint8Array(bytes), { + status: 200, + headers: { + 'Content-Type': asset.mimeType, + 'Content-Length': String(bytes.length), + 'Content-Disposition': `inline; filename="${asset.filename.replace(/"/g, '')}"`, + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }); + } catch (error) { + const classified = classifyAssetError(error); + if (classified.status >= 500) { + logger.error('Signature asset fetch error', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + return NextResponse.json({ error: classified.message }, { status: classified.status }); + } +} + +/** + * DELETE /api/signatures/assets/:id + */ +export async function DELETE(request: NextRequest, context: RouteContext) { + if (!hasSessionSecret()) { + return NextResponse.json( + { error: 'Signature image storage requires SESSION_SECRET' }, + { status: 503 }, + ); + } + + const username = request.headers.get('x-settings-username'); + const serverUrl = request.headers.get('x-settings-server'); + if (!username || !serverUrl) { + return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 }); + } + + if (!(await verifyAccountIdentity(username, serverUrl))) { + return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); + } + + try { + const { id } = await context.params; + await deleteSignatureAsset(username, serverUrl, id); + return NextResponse.json({ ok: true }); + } catch (error) { + const classified = classifyAssetError(error); + if (classified.status >= 500) { + logger.error('Signature asset delete error', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + return NextResponse.json({ error: classified.message }, { status: classified.status }); + } +} diff --git a/app/api/signatures/assets/route.ts b/app/api/signatures/assets/route.ts new file mode 100644 index 000000000..48061935b --- /dev/null +++ b/app/api/signatures/assets/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { hasSessionSecret } from '@/lib/auth/session-secret'; +import { verifyAccountIdentity } from '@/lib/auth/verify-account-identity'; +import { + SignatureAssetError, + listSignatureAssets, + saveSignatureAsset, + SIGNATURE_ASSET_MAX_BYTES, +} from '@/lib/signature-assets'; + +function classifyAssetError(error: unknown): { message: string; status: number } { + if (error instanceof SignatureAssetError) { + switch (error.code) { + case 'not_configured': + return { message: error.message, status: 503 }; + case 'invalid_identity': + case 'invalid_asset_id': + case 'invalid_mime': + case 'too_large': + case 'too_many': + return { message: error.message, status: 400 }; + case 'not_found': + return { message: error.message, status: 404 }; + case 'forbidden': + return { message: error.message, status: 403 }; + case 'path': + return { message: 'Invalid request', status: 400 }; + } + } + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EACCES' || code === 'EPERM') { + return { + message: 'Write permission denied on signature data directory.', + status: 500, + }; + } + if (code === 'ENOSPC') { + return { message: 'No disk space available to save signature image.', status: 507 }; + } + const msg = error instanceof Error ? error.message : 'Unknown error'; + return { message: `Internal server error: ${msg}`, status: 500 }; +} + +function requireConfigured(): NextResponse | null { + if (!hasSessionSecret()) { + return NextResponse.json( + { error: 'Signature image storage requires SESSION_SECRET' }, + { status: 503 }, + ); + } + return null; +} + +/** + * GET /api/signatures/assets?identityId=... + * Headers: x-settings-username, x-settings-server (same as settings sync) + */ +export async function GET(request: NextRequest) { + const blocked = requireConfigured(); + if (blocked) return blocked; + + const username = request.headers.get('x-settings-username'); + const serverUrl = request.headers.get('x-settings-server'); + const identityId = request.nextUrl.searchParams.get('identityId'); + if (!username || !serverUrl || !identityId) { + return NextResponse.json({ error: 'Missing identity headers or identityId' }, { status: 400 }); + } + + if (!(await verifyAccountIdentity(username, serverUrl))) { + return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); + } + + try { + const assets = await listSignatureAssets(username, serverUrl, identityId); + return NextResponse.json({ assets }); + } catch (error) { + const classified = classifyAssetError(error); + if (classified.status >= 500) { + logger.error('Signature asset list error', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + return NextResponse.json({ error: classified.message }, { status: classified.status }); + } +} + +/** + * POST /api/signatures/assets + * multipart/form-data: identityId, file + * Headers: x-settings-username, x-settings-server + */ +export async function POST(request: NextRequest) { + const blocked = requireConfigured(); + if (blocked) return blocked; + + const username = request.headers.get('x-settings-username'); + const serverUrl = request.headers.get('x-settings-server'); + if (!username || !serverUrl) { + return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 }); + } + + if (!(await verifyAccountIdentity(username, serverUrl))) { + return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); + } + + try { + const form = await request.formData(); + const identityId = String(form.get('identityId') || ''); + const file = form.get('file'); + if (!identityId || !(file instanceof File)) { + return NextResponse.json({ error: 'identityId and file are required' }, { status: 400 }); + } + if (file.size > SIGNATURE_ASSET_MAX_BYTES) { + return NextResponse.json( + { error: `Image exceeds the ${SIGNATURE_ASSET_MAX_BYTES} byte limit` }, + { status: 400 }, + ); + } + const buffer = Buffer.from(await file.arrayBuffer()); + const asset = await saveSignatureAsset(username, serverUrl, identityId, { + buffer, + filename: file.name || 'signature-image', + mimeType: file.type, + }); + return NextResponse.json({ asset }); + } catch (error) { + const classified = classifyAssetError(error); + if (classified.status >= 500) { + logger.error('Signature asset upload error', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + return NextResponse.json({ error: classified.message }, { status: classified.status }); + } +} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 73008d852..8a535a0bf 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -63,6 +63,13 @@ import type { Editor } from "@tiptap/react"; import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; import { fileStorage } from "@/lib/plugin-storage"; import { usePolicyStore } from "@/stores/policy-store"; +import { rewriteInlineImagesHtml, type InlineImage } from "@/lib/inline-images"; +import { + getEffectiveHtmlSignature, + resolveSignatureAssetsForCompose, + htmlHasSignatureAssets, +} from "@/lib/resolve-signature-for-compose"; +import { getExtendedSignature } from "@/lib/extended-signatures"; /** * Derives the text/plain alternative from the composer's HTML body, preserving @@ -231,6 +238,7 @@ type ComposerAttachment = { }; type SignatureIdentityLike = { + id?: string; htmlSignature?: string; textSignature?: string; } | null | undefined; @@ -305,6 +313,10 @@ export function EmailComposer({ const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); const signaturePosition = useSettingsStore((state) => state.signaturePosition); const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); + const extendedSignatures = useSettingsStore((state) => state.extendedSignatures); + const username = useAuthStore((state) => state.username); + const serverUrl = useAuthStore((state) => state.serverUrl); + const activeAccountId = useAccountStore((state) => state.activeAccountId); const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault); const activeIdentities = useIdentityStore((s) => s.identities); // Pro shell: surface identities from every connected account, grouped @@ -329,10 +341,27 @@ export function EmailComposer({ const initialCurrentIdentityForSig = initialData?.selectedIdentityId ? identities.find((i) => i.id === initialData.selectedIdentityId) || primaryIdentity : primaryIdentity; - const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature) - ? initialCurrentIdentityForSig + const initialAccountId = useAccountStore.getState().activeAccountId; + const initialExtendedHtml = initialCurrentIdentityForSig?.id && initialAccountId + ? getExtendedSignature( + useSettingsStore.getState().extendedSignatures, + initialAccountId, + initialCurrentIdentityForSig.id, + )?.html + : undefined; + const initialSignatureIdentity = ( + initialCurrentIdentityForSig?.htmlSignature + || initialCurrentIdentityForSig?.textSignature + || initialExtendedHtml + ) + ? (initialExtendedHtml + ? { ...initialCurrentIdentityForSig!, htmlSignature: initialExtendedHtml } + : initialCurrentIdentityForSig) : primaryIdentity; - const hasInitialSignature = !!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature); + const hasInitialSignature = !!( + initialSignatureIdentity?.htmlSignature + || initialSignatureIdentity?.textSignature + ); const shouldEmbedSignatureAboveQuote = (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote' && @@ -579,7 +608,10 @@ export function EmailComposer({ } return []; }); - const inlineImagesRef = useRef>([]); + const inlineImagesRef = useRef([]); + const signatureAssetIdsRef = useRef>(new Set()); + const [signatureAssetWarning, setSignatureAssetWarning] = useState(false); + const [signaturePreviewHtml, setSignaturePreviewHtml] = useState(null); const fileInputRef = useRef(null); const [validationErrors, setValidationErrors] = useState<{ to?: boolean; body?: boolean }>({}); const [shakeField, setShakeField] = useState(null); @@ -649,10 +681,32 @@ export function EmailComposer({ const currentIdentityRawId = currentIdentityParts.rawId ?? currentIdentity?.id; // Alias identities often lack a configured signature - fall back to the primary // identity's signature so replies (which auto-select a matching alias) still - // populate the user's signature. - const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature) - ? currentIdentity - : primaryIdentity; + // populate the user's signature. Prefer Bulwark extended HTML (with image + // asset refs) over the JMAP Identity.htmlSignature fallback when present. + const signatureIdentityBase = useMemo(() => { + const hasSig = (identity: typeof currentIdentity) => { + if (!identity) return false; + if (identity.htmlSignature || identity.textSignature) return true; + if (identity.id && activeAccountId) { + return !!getExtendedSignature(extendedSignatures, activeAccountId, identity.id)?.html?.trim(); + } + return false; + }; + return hasSig(currentIdentity) ? currentIdentity : primaryIdentity; + }, [currentIdentity, primaryIdentity, activeAccountId, extendedSignatures]); + + const signatureIdentity = useMemo(() => { + if (!signatureIdentityBase) return null; + const html = getEffectiveHtmlSignature( + signatureIdentityBase, + activeAccountId, + extendedSignatures, + ); + if (!html || html === signatureIdentityBase.htmlSignature) { + return signatureIdentityBase; + } + return { ...signatureIdentityBase, htmlSignature: html }; + }, [signatureIdentityBase, activeAccountId, extendedSignatures]); // Hold the TipTap editor instance so we can swap the embedded signature // when the user switches identity in "above quote" mode without rebuilding @@ -736,6 +790,98 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]); + // Hydrate Bulwark signature assets embedded in the composer body (or that + // will be appended at send time for below-quote replies). Uploads through + // the same inline-image pipeline as manually inserted composer images. + useEffect(() => { + if (plainTextMode) return; + if (!composerClient || !username || !serverUrl) return; + const html = signatureIdentity?.htmlSignature; + if (!htmlHasSignatureAssets(html)) { + setSignatureAssetWarning(false); + setSignaturePreviewHtml(null); + return; + } + + let cancelled = false; + (async () => { + // Drop previous signature-sourced registrations before resolving the new set. + inlineImagesRef.current = inlineImagesRef.current.filter((img) => !img.signatureAssetId); + signatureAssetIdsRef.current = new Set(); + + const result = await resolveSignatureAssetsForCompose({ + html: html!, + username, + serverUrl, + uploadBlob: (file) => composerClient.uploadBlob(file), + }); + if (cancelled) return; + + for (const image of result.images) { + if (!inlineImagesRef.current.some((e) => e.cid === image.cid)) { + inlineImagesRef.current.push(image); + } + if (image.signatureAssetId) { + signatureAssetIdsRef.current.add(image.signatureAssetId); + } + } + setSignatureAssetWarning(result.failedAssetIds.length > 0); + if (result.images.length > 0) { + setSignaturePreviewHtml(result.html); + } else if (result.failedAssetIds.length > 0) { + setSignaturePreviewHtml(result.html); + } + + // Swap placeholders inside an already-embedded signature block. + const updates = new Map(); + for (const image of result.images) { + updates.set(image.cid, image.dataUrl); + } + + const applyResolvedHtml = (prev: string): string => { + if (!prev.includes('data-signature-asset')) { + // Body may already have been rewritten to data-cid only. + return replaceInlineImagePlaceholders(prev, updates); + } + // Replace asset markers with resolved data URL + data-cid markup. + const doc = new DOMParser().parseFromString(`${prev}`, 'text/html'); + for (const image of result.images) { + if (!image.signatureAssetId) continue; + doc.querySelectorAll(`img[data-signature-asset="${image.signatureAssetId}"]`).forEach((img) => { + img.setAttribute('src', image.dataUrl); + img.setAttribute('data-cid', image.cid); + }); + } + for (const failedId of result.failedAssetIds) { + doc.querySelectorAll(`img[data-signature-asset="${failedId}"]`).forEach((img) => img.remove()); + } + return doc.body.innerHTML; + }; + + setBody((prev) => applyResolvedHtml(prev)); + + const editor = editorRef.current; + if (editor) { + const current = serializeEditorContent(editor); + const next = applyResolvedHtml(current); + if (next !== current) { + editor.commands.setContent(next, { emitUpdate: true }); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [ + composerClient, + plainTextMode, + username, + serverUrl, + signatureIdentity?.id, + signatureIdentity?.htmlSignature, + ]); + useEffect(() => { const handleClickOutsideSendMenu = (event: MouseEvent) => { if (!sendMenuRef.current?.contains(event.target as Node) && !mobileSendMenuRef.current?.contains(event.target as Node)) { @@ -938,11 +1084,13 @@ export function EmailComposer({ useEffect(() => { processEnrichment(cc, setCc); }, [cc]); useEffect(() => { processEnrichment(bcc, setBcc); }, [bcc]); - const composerSignatureHtml = signatureIdentity?.htmlSignature - ? `
${sanitizeSignatureHtmlForDisplay(signatureIdentity.htmlSignature)}
` - : signatureIdentity?.textSignature - ? `
${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` - : ''; + const composerSignatureHtml = signaturePreviewHtml + ? `
${sanitizeSignatureHtmlForDisplay(signaturePreviewHtml)}
` + : signatureIdentity?.htmlSignature + ? `
${sanitizeSignatureHtmlForDisplay(signatureIdentity.htmlSignature)}
` + : signatureIdentity?.textSignature + ? `
${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` + : ''; // Whether the body the user is editing already carries the signature, so the // send and draft-save paths must not append a second copy. @@ -1599,16 +1747,36 @@ export function EmailComposer({ // Prepare attachments for draft. cid/disposition ride along so inline // parts of a re-opened draft keep matching the body's cid: references. - const uploadedAttachments = attachments - .filter(att => att.blobId && !att.uploading) - .map(att => ({ - blobId: att.blobId!, - name: att.name, - type: att.type, - size: att.size, - ...(att.cid ? { cid: att.cid } : {}), - ...(att.disposition ? { disposition: att.disposition } : {}), - })); + const uploadedAttachments = [ + ...attachments + .filter(att => att.blobId && !att.uploading) + .map(att => ({ + blobId: att.blobId!, + name: att.name, + type: att.type, + size: att.size, + ...(att.cid ? { cid: att.cid } : {}), + ...(att.disposition ? { disposition: att.disposition } : {}), + })), + ...inlineImagesRef.current + .filter(img => img.blobId) + .map(img => ({ + blobId: img.blobId, + name: img.name, + type: img.type, + size: img.size, + cid: img.cid, + disposition: 'inline' as const, + })), + ]; + // Dedupe by cid/blobId when a hydrated draft part is also in inlineImagesRef. + const seenDraftAtt = new Set(); + const dedupedDraftAttachments = uploadedAttachments.filter((att) => { + const key = att.cid ? `cid:${att.cid}` : `blob:${att.blobId}`; + if (seenDraftAtt.has(key)) return false; + seenDraftAtt.add(key); + return true; + }); // A draft has to carry the signature just like a sent mail does. It is // otherwise only appended at send time, so every body the signature was @@ -1617,12 +1785,19 @@ export function EmailComposer({ // below the editor - was saved without it (#823). Embed the *marked-up* // form so re-opening the draft round-trips the signature as one block and // signatureAlreadyInBody sees it instead of appending a second copy. + // Prefer the already-resolved signature HTML (data URLs + data-cid) when + // Bulwark signature assets were hydrated for this identity. const draftSignatureHtml = signatureAlreadyInBody ? '' - : buildEmbeddedSignatureHtml(signatureIdentity, { - embed: true, - separator: signatureSeparatorEnabled, - }); + : signaturePreviewHtml + ? buildEmbeddedSignatureHtml( + { ...signatureIdentity, htmlSignature: signaturePreviewHtml }, + { embed: true, separator: signatureSeparatorEnabled }, + ) + : buildEmbeddedSignatureHtml(signatureIdentity, { + embed: true, + separator: signatureSeparatorEnabled, + }); const draftHtmlBody = plainTextMode ? undefined : `${body}${draftSignatureHtml}`; const draftTextBody = plainTextMode ? (signatureAlreadyInBody @@ -1639,7 +1814,7 @@ export function EmailComposer({ // draft version (below), and hashing them would mark each save dirty // again - an endless save loop. Name+type+size identifies an attachment // for change detection. - const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body: draftHtmlBody ?? draftTextBody, attachments: uploadedAttachments.map(({ name, type, size, cid }) => ({ name, type, size, cid })), identityId: selectedIdentityId, subAddressTag }); + const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body: draftHtmlBody ?? draftTextBody, attachments: dedupedDraftAttachments.map(({ name, type, size, cid }) => ({ name, type, size, cid })), identityId: selectedIdentityId, subAddressTag }); // Only save if data has changed if (currentData === lastSavedDataRef.current) { @@ -1673,7 +1848,7 @@ export function EmailComposer({ identityId: currentIdentityRawId, fromEmail, draftId: previousDraftId || undefined, - attachments: uploadedAttachments, + attachments: dedupedDraftAttachments, fromName, htmlBody: draftHtmlBody } @@ -1707,7 +1882,7 @@ export function EmailComposer({ // the matching parts (by name+size) of the version just created, so // the next save/send references live blobs instead of failing with // blobNotFound (#849). - if (uploadedAttachments.length && attachmentsRef.current.some(att => att.fromDraftPart && att.blobId)) { + if (dedupedDraftAttachments.length && attachmentsRef.current.some(att => att.fromDraftPart && att.blobId)) { try { const freshDraft = await composerClient.getEmail(savedDraftId); const freshParts = (freshDraft?.attachments ?? []).filter(p => !!p.blobId); @@ -1876,46 +2051,8 @@ export function EmailComposer({ // Rewrite data: URLs of dropped images (tagged with data-cid) into cid: // references so recipient clients that strip data URIs can still render them. - const rewriteInlineImages = (html: string): { - html: string; - attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>; - } => { - const known = inlineImagesRef.current; - const doc = new DOMParser().parseFromString(`${html}`, 'text/html'); - const used = new Map(); - - if (known.length > 0) { - doc.querySelectorAll('img[data-cid]').forEach((img) => { - const cid = img.getAttribute('data-cid'); - if (!cid) return; - const entry = known.find((e) => e.cid === cid); - if (!entry) return; - img.setAttribute('src', `cid:${cid}`); - img.removeAttribute('data-cid'); - used.set(cid, entry); - }); - } - - // Recipient mail clients apply default

margins inside table cells, - // inflating row height. Tiptap wraps cell text in

, so force margin:0 - // to match the composer's tight rows. - doc.querySelectorAll('td > p, th > p').forEach((p) => { - const existing = p.getAttribute('style') || ''; - p.setAttribute('style', `margin:0;${existing}`); - }); - - return { - html: doc.body.innerHTML, - attachments: Array.from(used.values()).map((e) => ({ - blobId: e.blobId, - name: e.name, - type: e.type, - size: e.size, - disposition: 'inline' as const, - cid: e.cid, - })), - }; - }; + const rewriteInlineImages = (html: string) => + rewriteInlineImagesHtml(html, inlineImagesRef.current); // Guard against double-submit. Rapid Send clicks (or a click racing the // keyboard shortcut) used to invoke handleSend once per click before the @@ -2066,12 +2203,33 @@ export function EmailComposer({ // above-quote replies, a re-opened draft) `signatureAlreadyInBody` is set // and the trailing append below is skipped so we don't duplicate it. - // Build HTML signature block (used only in rich text mode) + // Build HTML signature block (used only in rich text mode). When the + // signature was not embedded in the editor (below-quote replies), resolve + // Bulwark asset markers to cid: using the already-uploaded inline images. const buildSignatureHtml = (): string => { if (signatureAlreadyInBody) return ''; const sep = signatureSeparatorEnabled ? `

--
` : `

`; if (signatureIdentity?.htmlSignature) { - return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`; + let html = sanitizeSignatureHtml(signatureIdentity.htmlSignature); + if (htmlHasSignatureAssets(html) || html.includes('data-cid')) { + const doc = new DOMParser().parseFromString(`${html}`, 'text/html'); + doc.querySelectorAll('img[data-signature-asset], img[data-cid]').forEach((img) => { + const assetId = img.getAttribute('data-signature-asset'); + const existingCid = img.getAttribute('data-cid'); + const entry = inlineImagesRef.current.find((e) => + (assetId && e.signatureAssetId === assetId) || (existingCid && e.cid === existingCid), + ); + if (!entry) { + img.remove(); + return; + } + img.setAttribute('src', `cid:${entry.cid}`); + img.removeAttribute('data-cid'); + img.removeAttribute('data-signature-asset'); + }); + html = doc.body.innerHTML; + } + return `${sep}${html}`; } if (signatureIdentity?.textSignature) { return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`; @@ -2091,10 +2249,30 @@ export function EmailComposer({ : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts)); const rewritten = plainTextMode ? null : rewriteInlineImages(body); + const signatureHtml = plainTextMode ? '' : buildSignatureHtml(); const finalHtmlBody = plainTextMode ? undefined - : `

${rewritten!.html}
${buildSignatureHtml()}`; + : `
${rewritten!.html}
${signatureHtml}`; + + // Attachments from the body rewrite, plus any signature-sourced inline + // images referenced only in the appended signature HTML. const inlineAttachments = rewritten?.attachments ?? []; + if (signatureHtml) { + const seen = new Set(inlineAttachments.map((a) => a.cid)); + for (const img of inlineImagesRef.current) { + if (!img.signatureAssetId || seen.has(img.cid)) continue; + if (!signatureHtml.includes(`cid:${img.cid}`)) continue; + inlineAttachments.push({ + blobId: img.blobId, + name: img.name, + type: img.type, + size: img.size, + disposition: 'inline', + cid: img.cid, + }); + seen.add(img.cid); + } + } try { const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil); @@ -2855,10 +3033,16 @@ export function EmailComposer({ ) : null ) : composerSignatureHtml ? ( -
--
' : ''}${composerSignatureHtml}` }} - /> +
+ {signatureAssetWarning && ( +

+ {t('signature_image_load_failed')} +

+ )} +
--
' : ''}${composerSignatureHtml}` }} + /> +
) : null} diff --git a/components/identity/identity-form.tsx b/components/identity/identity-form.tsx index 0155b7b78..c2b49d6b1 100644 --- a/components/identity/identity-form.tsx +++ b/components/identity/identity-form.tsx @@ -1,14 +1,36 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { ImagePlus } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import type { Identity, EmailAddress } from '@/lib/jmap/types'; import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization'; import { getEmailValidationError, validateEmailList } from '@/lib/validation'; +import { + SIGNATURE_ASSET_ATTR, + buildJmapSignatureFallback, + collectSignatureAssetIds, + getExtendedSignature, + placeholderSignatureAssetImages, +} from '@/lib/extended-signatures'; +import { + SIGNATURE_ASSET_MAX_BYTES, + SIGNATURE_ASSETS_PER_IDENTITY_MAX, +} from '@/lib/signature-asset-constants'; +import { + uploadSignatureAssetClient, + fetchSignatureAssetBlob, + deleteSignatureAssetClient, + blobToDataUrl, +} from '@/lib/signature-assets-client'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; +import { INLINE_IMAGE_PLACEHOLDER } from '@/lib/email-composer-utils'; -// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes +// Stalwart's JMAP Identity/set caps signature fields at 2047 UTF-8 bytes const SIGNATURE_MAX_BYTES = 2047; const utf8Encoder = new TextEncoder(); @@ -25,7 +47,6 @@ function truncateToUtf8Bytes(s: string, maxBytes: number): string { if (utf8ByteLength(s.slice(0, mid)) <= maxBytes) lo = mid; else hi = mid - 1; } - // Don't split a surrogate pair: if we landed right after a high surrogate, back off one code unit. if (lo > 0) { const prev = s.charCodeAt(lo - 1); if (prev >= 0xD800 && prev <= 0xDBFF) lo -= 1; @@ -33,13 +54,20 @@ function truncateToUtf8Bytes(s: string, maxBytes: number): string { return s.slice(0, lo); } -interface IdentityFormData { +export interface IdentityFormData { name: string; email: string; replyTo?: EmailAddress[] | null; bcc?: EmailAddress[] | null; textSignature?: string | null; + /** JMAP Identity.htmlSignature (image-free fallback when assets are used). */ htmlSignature?: string | null; + /** Bulwark extended HTML with data-signature-asset markers; null clears. */ + extendedSignatureHtml?: string | null; + /** Asset ids present after save (for orphan cleanup). */ + signatureAssetIds?: string[]; + /** Asset ids that were referenced when the form opened. */ + previousSignatureAssetIds?: string[]; } interface IdentityFormProps { @@ -54,30 +82,83 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps) const tDisplay = useTranslations('identities.display'); const isEditing = !!identity; - const [formData, setFormData] = useState({ + const username = useAuthStore((s) => s.username); + const serverUrl = useAuthStore((s) => s.serverUrl); + const activeAccountId = useAccountStore((s) => s.activeAccountId); + const extendedSignatures = useSettingsStore((s) => s.extendedSignatures); + + const initialExtended = identity && activeAccountId + ? getExtendedSignature(extendedSignatures, activeAccountId, identity.id) + : null; + const initialHtml = initialExtended?.html || identity?.htmlSignature || ''; + const initialAssetIds = collectSignatureAssetIds(initialHtml); + + const [formData, setFormData] = useState({ name: identity?.name || '', email: identity?.email || '', replyTo: identity?.replyTo, bcc: identity?.bcc, textSignature: identity?.textSignature || '', - htmlSignature: identity?.htmlSignature || '', + htmlSignature: initialHtml, }); const [replyToInput, setReplyToInput] = useState( - identity?.replyTo?.map(a => a.email).join(', ') || '' + identity?.replyTo?.map((a) => a.email).join(', ') || '', ); const [bccInput, setBccInput] = useState( - identity?.bcc?.map(a => a.email).join(', ') || '' + identity?.bcc?.map((a) => a.email).join(', ') || '', ); const [isSubmitting, setIsSubmitting] = useState(false); + const [isUploadingImage, setIsUploadingImage] = useState(false); + const [imageError, setImageError] = useState(null); const [errors, setErrors] = useState>({}); + const [previewHtml, setPreviewHtml] = useState(() => + sanitizeSignatureHtmlForDisplay(placeholderSignatureAssetImages(initialHtml)), + ); + const fileInputRef = useRef(null); + const previousAssetIdsRef = useRef(initialAssetIds); + + // Hydrate asset placeholders in the live preview. + useEffect(() => { + let cancelled = false; + const html = formData.htmlSignature || ''; + const sanitized = sanitizeSignatureHtmlForDisplay(placeholderSignatureAssetImages(html)); + setPreviewHtml(sanitized); + + const assetIds = collectSignatureAssetIds(html); + if (!username || !serverUrl || assetIds.length === 0) return; + + (async () => { + const doc = new DOMParser().parseFromString(`${sanitized}`, 'text/html'); + let touched = false; + for (const assetId of assetIds) { + try { + const blob = await fetchSignatureAssetBlob(username, serverUrl, assetId); + if (cancelled) return; + const dataUrl = await blobToDataUrl(blob); + doc.querySelectorAll(`img[${SIGNATURE_ASSET_ATTR}="${assetId}"]`).forEach((img) => { + img.setAttribute('src', dataUrl); + touched = true; + }); + } catch { + // Leave placeholder; non-fatal for preview. + } + } + if (!cancelled && touched) { + setPreviewHtml(doc.body.innerHTML); + } + })(); + + return () => { + cancelled = true; + }; + }, [formData.htmlSignature, username, serverUrl]); const parseEmailList = (input: string): EmailAddress[] | undefined => { if (!input.trim()) return undefined; - - const emails = input.split(',').map(e => e.trim()).filter(Boolean); - return emails.map(email => ({ email })); + const emails = input.split(',').map((e) => e.trim()).filter(Boolean); + return emails.map((email) => ({ email })); }; const validate = (): boolean => { @@ -87,13 +168,11 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps) newErrors.name = t('name_required'); } - // Use secure email validation const emailError = getEmailValidationError(formData.email); if (emailError) { newErrors.email = emailError; } - // Validate reply-to email list if (replyToInput.trim()) { const validation = validateEmailList(replyToInput); if (!validation.valid) { @@ -101,7 +180,6 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps) } } - // Validate bcc email list if (bccInput.trim()) { const validation = validateEmailList(bccInput); if (!validation.valid) { @@ -113,27 +191,84 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps) return Object.keys(newErrors).length === 0; }; + const handleInsertImage = async (file: File) => { + setImageError(null); + if (!identity?.id) { + setImageError(t('image_requires_saved_identity')); + return; + } + if (!username || !serverUrl) { + setImageError(t('image_storage_unavailable')); + return; + } + if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') { + setImageError(t('image_type_rejected')); + return; + } + if (file.size > SIGNATURE_ASSET_MAX_BYTES) { + setImageError(t('image_too_large', { maxMb: 1 })); + return; + } + const currentCount = collectSignatureAssetIds(formData.htmlSignature || '').length; + if (currentCount >= SIGNATURE_ASSETS_PER_IDENTITY_MAX) { + setImageError(t('image_too_many', { max: SIGNATURE_ASSETS_PER_IDENTITY_MAX })); + return; + } + + setIsUploadingImage(true); + try { + const asset = await uploadSignatureAssetClient(username, serverUrl, identity.id, file); + const tag = `

`; + setFormData((prev) => ({ + ...prev, + htmlSignature: `${prev.htmlSignature || ''}${tag}`, + })); + } catch (error) { + const message = error instanceof Error ? error.message : t('image_upload_failed'); + setImageError(message); + } finally { + setIsUploadingImage(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!validate()) return; setIsSubmitting(true); - try { - // JMAP needs explicit null to clear a field; undefined would be dropped - // from the JSON payload and leave the server-side value untouched. const trimmedText = formData.textSignature?.trim() ?? ''; - const trimmedHtml = formData.htmlSignature?.trim() ?? ''; - const sanitizedData: IdentityFormData = { - ...formData, - textSignature: trimmedText ? formData.textSignature : null, - htmlSignature: trimmedHtml ? sanitizeSignatureHtml(formData.htmlSignature!) : null, + const rawHtml = formData.htmlSignature?.trim() ?? ''; + const sanitizedHtml = rawHtml ? sanitizeSignatureHtml(rawHtml) : ''; + const assetIds = collectSignatureAssetIds(sanitizedHtml); + const hasAssets = assetIds.length > 0; + + const jmapHtml = hasAssets + ? truncateToUtf8Bytes(buildJmapSignatureFallback(sanitizedHtml), SIGNATURE_MAX_BYTES) + : truncateToUtf8Bytes(sanitizedHtml, SIGNATURE_MAX_BYTES); + + const previousIds = previousAssetIdsRef.current; + const removed = previousIds.filter((id) => !assetIds.includes(id)); + if (username && serverUrl && removed.length > 0) { + await Promise.allSettled( + removed.map((id) => deleteSignatureAssetClient(username, serverUrl, id)), + ); + } + + const payload: IdentityFormData = { + name: formData.name, + email: formData.email, replyTo: parseEmailList(replyToInput) ?? null, bcc: parseEmailList(bccInput) ?? null, + textSignature: trimmedText ? formData.textSignature : null, + htmlSignature: jmapHtml.trim() ? jmapHtml : null, + extendedSignatureHtml: hasAssets ? sanitizedHtml : null, + signatureAssetIds: assetIds, + previousSignatureAssetIds: previousIds, }; - await onSave(sanitizedData); + await onSave(payload); } finally { setIsSubmitting(false); } @@ -141,7 +276,6 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps) return (
- {/* Name */}
- {/* Email */}
- {/* Reply-To */}
- {/* BCC */}
- {/* Text Signature */}
- {/* HTML Signature */}
- +
+ +
+ { + const file = e.target.files?.[0]; + if (file) void handleInsertImage(file); + }} + /> + +
+
+

{t('image_help')}

+ {imageError && ( +

+ {imageError} +

+ )}