diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 4086fbb5..844184ff 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -1035,6 +1035,18 @@ export class DemoJMAPClient implements IJMAPClient { async importRawEmail(): Promise { return generateDemoId('email'); } async submitEmail(): Promise { /* no-op */ } + async submitRawEmail(blob: Blob, + identityId: string, + delayedUntil?: string, + _envelopeRecipients?: string[],): Promise { + const emailId = generateDemoId('email'); + let emailSubmissionId: string | undefined; + if (delayedUntil) { + emailSubmissionId = generateDemoId('submission'); + this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: true }); + } + return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true }; + } async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise { const emailId = generateDemoId('email'); const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts'); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 37033dda..1b189d03 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -186,6 +186,7 @@ export interface IJMAPClient { }): Promise; sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise; + submitRawEmail(blob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise; getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>; cancelEmailSubmission(submissionId: string): Promise; rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 5d5d05dc..d3aa7057 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -6305,7 +6305,7 @@ export class JMAPClient implements IJMAPClient { } /** - * Import a raw S/MIME message, move it to the Sent mailbox, and submit it. + * Import a raw S/MIME PGP/MIME message, move it to the Sent mailbox, and submit it. * Encapsulates the full import → update → submit flow. */ async sendRawEmail( @@ -6393,6 +6393,86 @@ export class JMAPClient implements IJMAPClient { : { scheduled: false, emailId, emailSubmissionId, isSmime: true }; } + /** + * Submit a raw email blob to the network via JMAP EmailSubmission without auto-archiving to Sent. + */ + async submitRawEmail( + blob: Blob, + identityId: string, + delayedUntil?: string, + envelopeRecipients?: string[], + ): Promise { + const mailboxes = await this.getMailboxes(); + const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); + if (!draftsMailbox) { + throw new Error('Drafts mailbox not found'); + } + + const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; + + const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); + const { blobId } = await this.uploadBlob(file); + + const identities = await this.getIdentities(); + const identity = identities.find(item => item.id === identityId); + const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients); + + //Temporarily import the raw email into Drafts to satisfy JMAP's requirement that an EmailSubmission references an existing Email. + // The Email will be destroyed after submission. + const methodCalls: [string, Record, string][] = [ + ['Email/import', { + accountId: this.accountId, + emails: { + 'temp-submit': { + blobId, + mailboxIds: { [draftsMailbox.id]: true }, + keywords: { '$draft': true }, + }, + }, + }, '0'], + ['EmailSubmission/set', { + accountId: this.getSubmissionAccountId(), + create: { + 'raw-submit': { + emailId: '#temp-submit', + identityId, + ...(envelope ? { envelope } : {}), + }, + }, + //destroy the temporary email after submission to avoid leaving a draft behind. + onSuccessDestroyEmail: ['#raw-submit'], + }, '1'], + ]; + + const response = await this.request(methodCalls); + let emailSubmissionId: string | undefined; + let serverSendAt: string | undefined; + + for (const [methodName, result] of response.methodResponses ?? []) { + if (methodName.endsWith('/error')) { + throw new Error((result as { description?: string }).description || `Failed: ${(result as { type?: string }).type}`); + } + const r = result as { notCreated?: Record }; + if (r.notCreated) { + const firstErr = Object.values(r.notCreated)[0]; + throw new Error(firstErr?.description || firstErr?.type || 'Failed to submit raw email'); + } + if (methodName === 'EmailSubmission/set') { + const created = (result as { created?: Record }).created?.['raw-submit']; + emailSubmissionId = created?.id; + serverSendAt = created?.sendAt; + } + } + + if (delayedUntil && emailSubmissionId && !serverSendAt) { + serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId); + } + + return delayedUntil + ? { scheduled: true, emailSubmissionId, sendAt: serverSendAt, isSmime: true } + : { scheduled: false, emailSubmissionId, isSmime: true }; + } + async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> { if (!this.hasDelayedSend()) { return { emails: [], hasMore: false, total: 0, nextPosition: position }; diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index f9c84323..882d84da 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -21,6 +21,8 @@ import { generateUUID } from '../utils'; const PRIVILEGED_ONLY_METHODS = new Set([ 'jmap.fetchBlob', 'jmap.sendRaw', + 'jmap.submitRaw', + 'jmap.importRaw', 'upfiles.get', 'webauthn.getOrCreate', 'upfiles.set', @@ -43,6 +45,8 @@ const PERM_PER_METHOD: Record = { // jmap (privileged-tier only; see PRIVILEGED_ONLY_METHODS) 'jmap.fetchBlob': 'email:blob-read', 'jmap.sendRaw': 'email:raw-send', + 'jmap.submitRaw': 'email:raw-send', + 'jmap.importRaw': '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. @@ -247,6 +251,11 @@ async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: st return new Uint8Array(buf); } +interface JmapSubmitRawOptions { + delayedUntil?: string; + envelopeRecipients?: string[]; +} + /** * Submit a fully-formed raw RFC822 message (e.g. one a plugin has signed and/or * encrypted) via the host's raw-send path, which also files it into Sent. The @@ -255,7 +264,7 @@ async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: st async function doJmapSendRaw( rawBytes: ArrayBuffer | ArrayBufferView, identityId: string, - opts?: { delayedUntil?: string; envelopeRecipients?: string[] }, + opts?: JmapSubmitRawOptions, ): Promise { if (typeof identityId !== 'string' || !identityId) throw new Error('jmap.sendRaw: identityId required'); const { client } = useAuthStore.getState(); @@ -277,6 +286,88 @@ async function doJmapSendRaw( ); } + +/** + * submit a fully-formed raw RFC822 message without putting it in sent box. + */ +async function doJmapSubmitRaw( + rawBytes: ArrayBuffer | ArrayBufferView, + identityId: string, + opts?: JmapSubmitRawOptions, +): Promise { + if (typeof identityId !== 'string' || !identityId) { + throw new Error('jmap.submitRaw: identityId required'); + } + + const { client } = useAuthStore.getState(); + if (!client) { + throw new Error('jmap.submitRaw: no active session'); + } + + const view = rawBytes instanceof ArrayBuffer + ? new Uint8Array(rawBytes) + : new Uint8Array(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength); + + const copy = new Uint8Array(view.byteLength); + copy.set(view); + const blob = new Blob([copy.buffer], { type: 'message/rfc822' }); + + return client.submitRawEmail( + blob, + identityId, + opts?.delayedUntil, + opts?.envelopeRecipients, + ); +} + +interface JmapImportRawOptions { + keywords?: Record; + accountId?: string; +} + +/** + * Import a fully-formed raw RFC822 message into the user's mailbox. + */ +async function doJmapImportRaw( + rawBytes: ArrayBuffer | ArrayBufferView, + mailboxRoles: string[], + opts?: JmapImportRawOptions, +): Promise { + + const { client } = useAuthStore.getState(); + if (!client) { + throw new Error('jmap.importRaw: no active session'); + } + let mailboxIds: Record = {}; + + const mailboxes = await client.getMailboxes(); + for (const role of mailboxRoles) { + const mailbox = mailboxes.find(mb => mb.role === role); + if (!mailbox) { + throw new Error(`Mailbox with role "${role}" not found`); + } + mailboxIds[mailbox.id] = true; + } + + if (Object.keys(mailboxIds).length === 0) { + throw new Error('No valid mailboxes found for the specified roles'); + } + const view = rawBytes instanceof ArrayBuffer + ? new Uint8Array(rawBytes) + : new Uint8Array(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength); + + const copy = new Uint8Array(view.byteLength); + copy.set(view); + const blob = new Blob([copy.buffer], { type: 'message/rfc822' }); + + return client.importRawEmail( + blob, + mailboxIds, + opts?.keywords, + opts?.accountId, + ); +} + // ─── WebAuthn (privileged tier) ───────────────────────────────────────────── // This salt acts as a constant context identifier for key derivation. @@ -491,6 +582,16 @@ export async function dispatchApiCall( args[1] as string, args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined, ); + case 'jmap.submitRaw': return doJmapSubmitRaw( + args[0] as ArrayBuffer | ArrayBufferView, + args[1] as string, + args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined, + ); + case 'jmap.importRaw': return doJmapImportRaw( + args[0] as ArrayBuffer | ArrayBufferView, + args[1] as string[], + args[2] as { keywords?: Record; accountId?: 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 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string | undefined, args[2] as string | undefined); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 9e72b519..3a698825 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -191,6 +191,18 @@ function buildPluginApi(manifest: PluginManifest) { identityId: string, opts?: { delayedUntil?: string; envelopeRecipients?: string[] }, ) => callApi('jmap.sendRaw', [rawBytes, identityId, opts]), + /** Submit without putting in sent box a fully-formed raw RFC822 message (already signed/encrypted). */ + submitRaw: ( + rawBytes: ArrayBuffer | ArrayBufferView, + identityId: string, + opts?: { delayedUntil?: string; envelopeRecipients?: string[] }, + ) => callApi('jmap.submitRaw', [rawBytes, identityId, opts]), + /** Import a fully-formed raw RFC822 message into the user's mailbox. */ + importRaw: ( + rawBytes: ArrayBuffer | ArrayBufferView, + mailboxRoles: string[], + opts?: { keywords?: Record; accountId?: string }, + ) => callApi('jmap.importRaw', [rawBytes, mailboxRoles, opts]), }, /** * Used to alterate files before they are uploaded to server.