feat: add new plugin API to submit without moving to box mail and import to box
This commit is contained in:
committed by
Linus Rath
parent
88b07a1713
commit
b4739c111f
@@ -1035,6 +1035,18 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
||||||
async submitEmail(): Promise<void> { /* no-op */ }
|
async submitEmail(): Promise<void> { /* no-op */ }
|
||||||
|
async submitRawEmail(blob: Blob,
|
||||||
|
identityId: string,
|
||||||
|
delayedUntil?: string,
|
||||||
|
_envelopeRecipients?: string[],): Promise<SendEmailResult> {
|
||||||
|
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<SendEmailResult> {
|
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise<SendEmailResult> {
|
||||||
const emailId = generateDemoId('email');
|
const emailId = generateDemoId('email');
|
||||||
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
|
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export interface IJMAPClient {
|
|||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
|
|
||||||
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
|
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
|
||||||
|
submitRawEmail(blob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
|
||||||
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
|
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
|
||||||
cancelEmailSubmission(submissionId: string): Promise<void>;
|
cancelEmailSubmission(submissionId: string): Promise<void>;
|
||||||
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
|
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
|
||||||
|
|||||||
+81
-1
@@ -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.
|
* Encapsulates the full import → update → submit flow.
|
||||||
*/
|
*/
|
||||||
async sendRawEmail(
|
async sendRawEmail(
|
||||||
@@ -6393,6 +6393,86 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
: { scheduled: false, emailId, emailSubmissionId, isSmime: true };
|
: { 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<SendEmailResult> {
|
||||||
|
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, unknown>, 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<string, { description?: string; type?: string }> };
|
||||||
|
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<string, { id?: string; sendAt?: string }> }).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 }> {
|
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
|
||||||
if (!this.hasDelayedSend()) {
|
if (!this.hasDelayedSend()) {
|
||||||
return { emails: [], hasMore: false, total: 0, nextPosition: position };
|
return { emails: [], hasMore: false, total: 0, nextPosition: position };
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { generateUUID } from '../utils';
|
|||||||
const PRIVILEGED_ONLY_METHODS = new Set<string>([
|
const PRIVILEGED_ONLY_METHODS = new Set<string>([
|
||||||
'jmap.fetchBlob',
|
'jmap.fetchBlob',
|
||||||
'jmap.sendRaw',
|
'jmap.sendRaw',
|
||||||
|
'jmap.submitRaw',
|
||||||
|
'jmap.importRaw',
|
||||||
'upfiles.get',
|
'upfiles.get',
|
||||||
'webauthn.getOrCreate',
|
'webauthn.getOrCreate',
|
||||||
'upfiles.set',
|
'upfiles.set',
|
||||||
@@ -43,6 +45,8 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
|||||||
// jmap (privileged-tier only; see PRIVILEGED_ONLY_METHODS)
|
// jmap (privileged-tier only; see PRIVILEGED_ONLY_METHODS)
|
||||||
'jmap.fetchBlob': 'email:blob-read',
|
'jmap.fetchBlob': 'email:blob-read',
|
||||||
'jmap.sendRaw': 'email:raw-send',
|
'jmap.sendRaw': 'email:raw-send',
|
||||||
|
'jmap.submitRaw': 'email:raw-send',
|
||||||
|
'jmap.importRaw': 'email:raw-send',
|
||||||
// uploaded files (privileged-tier only) :
|
// uploaded files (privileged-tier only) :
|
||||||
// Used only to get a file before it is uploaded to alterate it.
|
// Used only to get a file before it is uploaded to alterate it.
|
||||||
// To just read, use jmap.fetchBlob.
|
// To just read, use jmap.fetchBlob.
|
||||||
@@ -247,6 +251,11 @@ async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: st
|
|||||||
return new Uint8Array(buf);
|
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
|
* 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
|
* 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(
|
async function doJmapSendRaw(
|
||||||
rawBytes: ArrayBuffer | ArrayBufferView,
|
rawBytes: ArrayBuffer | ArrayBufferView,
|
||||||
identityId: string,
|
identityId: string,
|
||||||
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
|
opts?: JmapSubmitRawOptions,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
if (typeof identityId !== 'string' || !identityId) throw new Error('jmap.sendRaw: identityId required');
|
if (typeof identityId !== 'string' || !identityId) throw new Error('jmap.sendRaw: identityId required');
|
||||||
const { client } = useAuthStore.getState();
|
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<unknown> {
|
||||||
|
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<string, boolean>;
|
||||||
|
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<string> {
|
||||||
|
|
||||||
|
const { client } = useAuthStore.getState();
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('jmap.importRaw: no active session');
|
||||||
|
}
|
||||||
|
let mailboxIds: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
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) ─────────────────────────────────────────────
|
// ─── WebAuthn (privileged tier) ─────────────────────────────────────────────
|
||||||
|
|
||||||
// This salt acts as a constant context identifier for key derivation.
|
// This salt acts as a constant context identifier for key derivation.
|
||||||
@@ -491,6 +582,16 @@ export async function dispatchApiCall(
|
|||||||
args[1] as string,
|
args[1] as string,
|
||||||
args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
|
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<string, boolean>; accountId?: string } | undefined,
|
||||||
|
);
|
||||||
case 'upfiles.get' : return getFile(args[0] as string);
|
case 'upfiles.get' : return getFile(args[0] as string);
|
||||||
case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File);
|
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);
|
case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string | undefined, args[2] as string | undefined);
|
||||||
|
|||||||
@@ -191,6 +191,18 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
identityId: string,
|
identityId: string,
|
||||||
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
|
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
|
||||||
) => callApi('jmap.sendRaw', [rawBytes, identityId, opts]),
|
) => 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<string, boolean>; accountId?: string },
|
||||||
|
) => callApi('jmap.importRaw', [rawBytes, mailboxRoles, opts]),
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Used to alterate files before they are uploaded to server.
|
* Used to alterate files before they are uploaded to server.
|
||||||
|
|||||||
Reference in New Issue
Block a user