From 752e71198cdde97b3f034b4d260b7255795a853e Mon Sep 17 00:00:00 2001 From: Paulhenry Saux Date: Thu, 9 Jul 2026 14:55:34 +0200 Subject: [PATCH] feat: add 3 new plugin hooks : onBeforeBlobUpload, onBeforeDraftAutoSave, onBeforeEditDraft (#586) Co-authored-by: Linus Rath --- app/(main)/[locale]/page.tsx | 2 ++ components/email/email-composer.tsx | 51 +++++++++++++++++++++-------- lib/plugin-hooks.ts | 13 ++++++++ lib/plugin-sandbox/consent.ts | 1 + lib/plugin-sandbox/host-api.ts | 24 ++++++++++++++ lib/plugin-sandbox/runtime.tsx | 10 ++++++ lib/plugin-storage.ts | 26 +++++++++++++-- lib/plugin-types.ts | 18 ++++++++++ package-lock.json | 1 - 9 files changed, 129 insertions(+), 17 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 5d98c72e..3b0550d1 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1344,6 +1344,8 @@ export default function Home() { draft = fullDraft; } + draft = await emailHooks.onBeforeEditDraft.transform(draft); + const bodyText = draft.bodyValues ? Object.values(draft.bodyValues).map(v => v.value).join('\n') : ''; diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 5a62c335..5d50e7ab 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -17,7 +17,7 @@ import { isFilePreviewable } from "@/lib/file-preview"; import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; import { buildSignatureBlock } from "@/components/email/signature-block"; import { emailHooks, contactHooks } from "@/lib/plugin-hooks"; -import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types"; +import type { AlmostSavedDraft, OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types"; import { useAuthStore } from "@/stores/auth-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; @@ -53,6 +53,7 @@ import { isValidEmail } from "@/lib/validation"; import { RichTextEditor } from "@/components/email/rich-text-editor"; import type { Editor } from "@tiptap/react"; import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; +import { fileStorage } from "@/lib/plugin-storage"; /** * Derives the text/plain alternative from the composer's HTML body, preserving @@ -1129,7 +1130,16 @@ export function EmailComposer({ const controller = newAttachments[i].abortController; try { if (controller?.signal.aborted) continue; - const { blobId } = await client.uploadBlob(file); + + const fileId = generateUUID(); + await fileStorage.saveFile(fileId, file); + + const newFileId = await emailHooks.onBeforeBlobUpload.transform(fileId); + + const newFile = await fileStorage.getFile(newFileId) || file; + await fileStorage.deleteFile(newFileId); + + const { blobId } = await client.uploadBlob(newFile); if (controller?.signal.aborted) continue; setAttachments(prev => @@ -1337,21 +1347,36 @@ export function EmailComposer({ try { const previousDraftId = draftIdRef.current; + let savedDraft : AlmostSavedDraft = { + to: toAddresses, + subject: subject || t('no_subject'), + body: plainTextMode ? body : htmlToPlainText(body), + cc: ccAddresses, + bcc: bccAddresses, + identityId: currentIdentityRawId, + fromEmail, + draftId: previousDraftId || undefined, + attachments: uploadedAttachments, + fromName, + htmlBody: plainTextMode ? undefined : body + } + savedDraft = await emailHooks.onBeforeDraftAutoSave.transform(savedDraft); + // Use the JMAP client and raw identity id for the *owning* account // - falls back to active client for single-account / same-account // identities. See `composerClient` derivation above. const savedDraftId = await composerClient.createDraft( - toAddresses, - subject || t('no_subject'), - plainTextMode ? body : htmlToPlainText(body), - ccAddresses, - bccAddresses, - currentIdentityRawId, - fromEmail, - previousDraftId || undefined, - uploadedAttachments, - fromName, - plainTextMode ? undefined : body + savedDraft.to, + savedDraft.subject, + savedDraft.body, + savedDraft.cc, + savedDraft.bcc, + savedDraft.identityId, + savedDraft.fromEmail, + savedDraft.draftId, + savedDraft.attachments, + savedDraft.fromName, + savedDraft.htmlBody ); // Update the ref synchronously so a queued save sees the new id and diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 53330b9d..00ccc58c 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -183,7 +183,14 @@ export const emailHooks = { onComposerOpen: new HookBus(), onBeforeEmailSend: new HookBus(), onAfterEmailSend: new HookBus(), + // Transform hook - fires before the draft is auto-saved to the server. + // Receive fields passed to client.createDraft and may mutate fields in place. + // Return false to cancel the auto-save or a the fields. + onBeforeDraftAutoSave: new HookBus(), onDraftAutoSave: new HookBus(), + // Transform hook - fires before a draft is created from an email in draft mailbox. + // Receive a Email object and may mutate fields in place. + onBeforeEditDraft: new HookBus(), onBeforeEmailDelete: new HookBus(), onAfterEmailDelete: new HookBus(), onBeforeEmailMove: new HookBus(), @@ -232,6 +239,12 @@ export const emailHooks = { // attachment. Handler receives AttachmentInfo (size/type/name only - the // raw file is not exposed). Return false to refuse the upload. onBeforeAttachmentUpload: new HookBus(), + // Intercept hook fired before a file is uploaded to JMAP server. + // It is fired after onBeforeAttachmentUpload. + // Handler receive the {file: File, blobId: 'undefined'} object. + // If it uploaded, it must return the object with true blobId. + // Here, the raw file sended is exposed and can be modified or replaced. + onBeforeBlobUpload: new HookBus(), // Observer fired after an attachment has been uploaded and its blobId is // available. Handler receives AttachmentInfo with `blobId` populated. onAfterAttachmentUpload: new HookBus(), diff --git a/lib/plugin-sandbox/consent.ts b/lib/plugin-sandbox/consent.ts index 97e6a1d9..2a1f9871 100644 --- a/lib/plugin-sandbox/consent.ts +++ b/lib/plugin-sandbox/consent.ts @@ -60,6 +60,7 @@ const PERMISSION_LABELS: Record = { 'crypto:full': { title: 'Full cryptographic access (high risk)', body: 'Runs with full cryptographic access in a privileged, same-origin context. It can read your message bodies and private keys, store key material, and sign/encrypt on your behalf. Only enable plugins you fully trust — this is comparable to a full-access browser extension.' }, 'email:raw-send': { title: 'Send raw messages', body: 'Submit fully-formed (e.g. signed or encrypted) messages on your behalf.' }, 'email:blob-read': { title: 'Read raw message content', body: 'Fetch the raw bytes of your messages and attachments (needed to decrypt and verify them).' }, + 'email:blob-write': { title: 'Alterate raw message content', body: 'get the raw file content before it is uploaded to alterate it just before is is sended to server. (needed to encrypt)' }, 'email:render-takeover': { title: 'Replace rendered email content', body: 'Replace the displayed content of an opened message (e.g. to show decrypted text and a signature-verification badge).' }, 'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' }, 'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' }, diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 1f6c0cce..ea8d7b14 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -9,6 +9,8 @@ import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { apiFetch } from '../browser-navigation'; import { awaitDialog } from './host-dialog'; +import { fileStorage} from '../plugin-storage' +import { generateUUID } from '../utils'; /** * Methods only callable from the privileged (same-origin) tier. These expose @@ -19,6 +21,8 @@ import { awaitDialog } from './host-dialog'; const PRIVILEGED_ONLY_METHODS = new Set([ 'jmap.fetchBlob', 'jmap.sendRaw', + 'upfiles.get', + 'upfiles.set', ]); const PERM_PER_METHOD: Record = { @@ -38,6 +42,11 @@ const PERM_PER_METHOD: Record = { // jmap (privileged-tier only; see PRIVILEGED_ONLY_METHODS) 'jmap.fetchBlob': 'email:blob-read', 'jmap.sendRaw': 'email:raw-send', + // uploaded files (privileged-tier only) : + // Used only to get a file before it is uploaded to alterate it. + // To just read, use jmap.fetchBlob. + 'upfiles.get' : 'email:blob-write', + 'upfiles.save' : 'email:blob-write', // admin 'admin.getConfig': 'admin:config', 'admin.getAllConfig': 'admin:config', @@ -263,6 +272,19 @@ async function doJmapSendRaw( ); } +// ─── Uploaded files in IndexedDB (privileged tier) ────────────────────────── + +async function getFile(fileID:string): Promise { + return await fileStorage.getFile(fileID) +} + +async function saveFile(formerFileID:string, file: File): Promise { + const fileId = generateUUID(); + await fileStorage.saveFile(fileId, file); + await fileStorage.deleteFile(formerFileID); + return fileId; +} + // ─── admin config (same as before) ──────────────────────────── async function adminGetAll(pluginId: string): Promise> { @@ -335,6 +357,8 @@ export async function dispatchApiCall( args[1] as string, args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined, ); + case 'upfiles.get' : return getFile(args[0] as string); + case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File); case 'admin.getConfig': return adminGet(plugin.id, args[0] as string); case 'admin.getAllConfig': return adminGetAll(plugin.id); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index e1cfdeed..6e1c0d9a 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -189,6 +189,16 @@ function buildPluginApi(manifest: PluginManifest) { opts?: { delayedUntil?: string; envelopeRecipients?: string[] }, ) => callApi('jmap.sendRaw', [rawBytes, identityId, opts]), }, + /** + * Used to alterate files before they are uploaded to server. + * Edited files are saved on indexedDB and remove once the upload to server begins. + */ + upfiles: { + save: (formerFileId:string, file:File) => + callApi('upfiles.save', [formerFileId, file]) as Promise, + get: (fileId:string) => + callApi('upfiles.get', [fileId]) as Promise, + }, toast: { success: (m: string) => { void callApi('toast.success', [m]); }, error: (m: string) => { void callApi('toast.error', [m]); }, diff --git a/lib/plugin-storage.ts b/lib/plugin-storage.ts index 51ef42b8..98863666 100644 --- a/lib/plugin-storage.ts +++ b/lib/plugin-storage.ts @@ -1,12 +1,13 @@ // IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews) const DB_NAME = 'bulwark-plugins'; -// Bumped to 2 to add the theme-skin store; existing stores are preserved. -const DB_VERSION = 2; +// Bumped to 3 to add the file-plugin store; existing stores are preserved. +const DB_VERSION = 3; const STORE_PLUGINS = 'plugin-code'; const STORE_THEMES = 'theme-css'; const STORE_THEME_SKINS = 'theme-skin'; const STORE_PREVIEWS = 'previews'; +const STORE_FILE_ACCESS_PLUGIN = 'file-plugin' function openDB(): Promise { return new Promise((resolve, reject) => { @@ -26,6 +27,9 @@ function openDB(): Promise { if (!db.objectStoreNames.contains(STORE_PREVIEWS)) { db.createObjectStore(STORE_PREVIEWS); } + if (!db.objectStoreNames.contains(STORE_FILE_ACCESS_PLUGIN)) { + db.createObjectStore(STORE_FILE_ACCESS_PLUGIN); + } }; request.onsuccess = () => resolve(request.result); @@ -33,7 +37,7 @@ function openDB(): Promise { }); } -async function putItem(storeName: string, key: string, value: string | Blob): Promise { +async function putItem(storeName: string, key: string, value: string | Blob | File): Promise { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readwrite'); @@ -111,3 +115,19 @@ export const pluginStorage = { await deleteItem(STORE_PREVIEWS, id); }, }; + +/** + * Used in host-api for plugins to access to raw data files + * to modify them before they are uploaded. + */ +export const fileStorage = { + async saveFile(fileId: string, file: File): Promise { + await putItem(STORE_FILE_ACCESS_PLUGIN, fileId, file); + }, + async getFile(fileId: string): Promise { + return getItem(STORE_FILE_ACCESS_PLUGIN, fileId); + }, + async deleteFile(fileId: string): Promise { + await deleteItem(STORE_FILE_ACCESS_PLUGIN, fileId); + }, +} \ No newline at end of file diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index ca2fea27..0ba61872 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -674,6 +674,22 @@ export interface OutgoingEmail { /** Free-form custom headers added by the composer or earlier handlers */ headers?: Record; } +/** + * Passed to onBeforeDraftAutoSave handlers as a transform value. + */ +export interface AlmostSavedDraft{ + to: string[], + subject: string, + body: string, + cc?: string[], + bcc?: string[], + identityId?: string, + fromEmail?: string, + draftId?: string, + attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, + fromName?: string, + htmlBody?: string +} /** * Passed to onBeforeReply / onBeforeReplyAll / onBeforeForward intercept hooks. @@ -885,6 +901,8 @@ export const ALL_PERMISSIONS = [ 'email:raw-send', // Fetch a message blob's raw bytes by blobId (for decrypt/verify). 'email:blob-read', + // Upload a file to server (for encrypt). + 'email:blob-write', // Replace the rendered body of an opened email (render-takeover). 'email:render-takeover', 'calendar:read', 'calendar:write', diff --git a/package-lock.json b/package-lock.json index eca1c271..a92ce07d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7908,7 +7908,6 @@ "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "license": "Apache-2.0", "optional": true, "peer": true, "dependencies": {