Feat: Scheduled send and send delay #322
* ADD DOC * Scheduld Send * add new shortcuts * fix * fix * fix bugs * rework * fix draft duplicating * fix err * some fixes * fixes from review * fixes from review * fixes from review * disable password managers for recipients * fix email store lazy load * add translations * fix styling * fixes --------- Co-authored-by: Linus Rath <139418639+rathlinus@users.noreply.github.com>
This commit is contained in:
co-authored by
Linus Rath
parent
82be047708
commit
31e96d6a46
@@ -11,6 +11,34 @@ function createClient(): JMAPClient {
|
||||
return client;
|
||||
}
|
||||
|
||||
function enableDelayedSend(client: JMAPClient) {
|
||||
Object.assign(client, {
|
||||
capabilities: {
|
||||
'urn:ietf:params:jmap:core': {},
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': {},
|
||||
},
|
||||
session: {
|
||||
primaryAccounts: {
|
||||
'urn:ietf:params:jmap:mail': 'account-1',
|
||||
'urn:ietf:params:jmap:submission': 'submission-account-1',
|
||||
},
|
||||
accounts: {
|
||||
'account-1': {
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
},
|
||||
},
|
||||
'submission-account-1': {
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface JMAPMethodCall {
|
||||
0: string;
|
||||
1: Record<string, unknown>;
|
||||
@@ -62,7 +90,7 @@ function mockSendEmailFlow() {
|
||||
payload = {
|
||||
methodResponses: [
|
||||
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'],
|
||||
['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'],
|
||||
['EmailSubmission/set', { created: { '1': { id: 'sub-1', sendAt: '2026-05-08T18:00:00Z' } } }, '1'],
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -167,4 +195,72 @@ describe('JMAPClient.sendEmail threading headers', () => {
|
||||
expect(draft.inReplyTo).toEqual(['real@example.com']);
|
||||
expect(draft.references).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses FUTURERELEASE envelope and submission capability for scheduled sends', async () => {
|
||||
const client = createClient();
|
||||
enableDelayedSend(client);
|
||||
const captured = mockSendEmailFlow();
|
||||
const delayedUntil = new Date(Date.now() + 60_000).toISOString();
|
||||
|
||||
const result = await client.sendEmail(
|
||||
['recipient@example.com'],
|
||||
'Scheduled test',
|
||||
'body',
|
||||
undefined, undefined, 'identity-1', 'user@example.com',
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
delayedUntil,
|
||||
);
|
||||
|
||||
const identityRequest = captured[1];
|
||||
expect(identityRequest.using).toContain('urn:ietf:params:jmap:submission');
|
||||
const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set');
|
||||
expect(submissionCall?.[1].accountId).toBe('submission-account-1');
|
||||
expect(submissionCall?.[1].create).toEqual({
|
||||
'1': {
|
||||
emailId: expect.stringMatching(/^#send-/),
|
||||
identityId: 'identity-1',
|
||||
envelope: {
|
||||
mailFrom: {
|
||||
email: 'user@example.com',
|
||||
parameters: { HOLDFOR: expect.stringMatching(/^\d+$/) },
|
||||
},
|
||||
rcptTo: [{ email: 'recipient@example.com' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(submissionCall?.[1].create)).not.toContain('sendAt');
|
||||
expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: '2026-05-08T18:00:00Z' });
|
||||
});
|
||||
|
||||
it('cleans up replacement submission if canceling the original fails during reschedule', async () => {
|
||||
const client = createClient();
|
||||
enableDelayedSend(client);
|
||||
vi.spyOn(client, 'getMailboxes').mockResolvedValue([
|
||||
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
|
||||
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
|
||||
] as never);
|
||||
vi.spyOn(client, 'getIdentities').mockResolvedValue([
|
||||
{ id: 'identity-1', name: 'User', email: 'user@example.com', mayDelete: false },
|
||||
]);
|
||||
const requestSpy = vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
|
||||
.mockImplementation(async (methodCalls) => {
|
||||
const args = methodCalls[0][1] as { create?: unknown; update?: Record<string, unknown> };
|
||||
if (args.create) {
|
||||
return { methodResponses: [['EmailSubmission/set', { created: { replacement: { id: 'sub-new' } } }, '0']] };
|
||||
}
|
||||
if (args.update?.['sub-old']) {
|
||||
return { methodResponses: [['EmailSubmission/set', { notUpdated: { 'sub-old': { type: 'cannotUnsend' } } }, '0']] };
|
||||
}
|
||||
return { methodResponses: [['EmailSubmission/set', { updated: { 'sub-new': null } }, '0']] };
|
||||
});
|
||||
|
||||
await expect(client.rescheduleEmailSubmission('sub-old', 'email-1', 'identity-1', new Date(Date.now() + 60_000).toISOString()))
|
||||
.rejects.toThrow('could not cancel the original');
|
||||
|
||||
expect(requestSpy).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.arrayContaining(['EmailSubmission/set', expect.objectContaining({ update: { 'sub-new': { undoStatus: 'canceled' } } })]),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
+98
-8
@@ -1,5 +1,5 @@
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from '@/lib/jmap/types';
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult } from '@/lib/jmap/types';
|
||||
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||
import { getDemoData, type DemoData } from './demo-data';
|
||||
import { generateDemoId } from './demo-utils';
|
||||
@@ -11,6 +11,7 @@ import { generateDemoId } from './demo-utils';
|
||||
export class DemoJMAPClient implements IJMAPClient {
|
||||
private data: DemoData;
|
||||
private blobStore = new Map<string, Blob>();
|
||||
private scheduledSubmissions = new Map<string, { id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled'; isSmime: boolean }>();
|
||||
private connectionCallback: ((connected: boolean) => void) | null = null;
|
||||
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
||||
private lastStates: AccountStates = {};
|
||||
@@ -49,15 +50,15 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
// ── Capabilities ──────────────────────────────────────────────
|
||||
|
||||
hasAccountCapability(_capability: string, _accountId?: string): boolean {
|
||||
return false;
|
||||
hasAccountCapability(capability: string, _accountId?: string): boolean {
|
||||
return capability === 'urn:ietf:params:jmap:submission';
|
||||
}
|
||||
|
||||
getCapabilities(): Record<string, unknown> {
|
||||
return {
|
||||
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': {},
|
||||
'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } },
|
||||
'urn:ietf:params:jmap:vacationresponse': {},
|
||||
'urn:ietf:params:jmap:contacts': {},
|
||||
'urn:ietf:params:jmap:calendars': {},
|
||||
@@ -70,6 +71,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
getMaxSizeUpload(): number { return 50_000_000; }
|
||||
getMaxCallsInRequest(): number { return 16; }
|
||||
getMaxObjectsInGet(): number { return 500; }
|
||||
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
|
||||
hasDelayedSend(): boolean { return true; }
|
||||
getEventSourceUrl(): string | null { return null; }
|
||||
supportsEmailSubmission(): boolean { return true; }
|
||||
supportsQuota(): boolean { return true; }
|
||||
@@ -293,6 +296,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
mode: 'single' | 'year' | 'month',
|
||||
_existingMailboxes: Mailbox[],
|
||||
_accountId?: string,
|
||||
): Promise<void> {
|
||||
if (emails.length === 0) return;
|
||||
if (mode === 'single') {
|
||||
@@ -456,7 +461,9 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
inReplyTo?: string[],
|
||||
references?: string[],
|
||||
): Promise<void> {
|
||||
delayedUntil?: string,
|
||||
_envelopeMailFrom?: string,
|
||||
): Promise<SendEmailResult> {
|
||||
// Remove draft if updating
|
||||
if (draftId) {
|
||||
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
|
||||
@@ -464,8 +471,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
|
||||
const email: Email = {
|
||||
id: generateDemoId('email'), threadId: generateDemoId('thread'),
|
||||
mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true },
|
||||
keywords: { $seen: true },
|
||||
mailboxIds: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: true },
|
||||
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
|
||||
size: body.length + (htmlBody?.length || 0),
|
||||
receivedAt: new Date().toISOString(),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
@@ -485,7 +492,22 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
references: references?.length ? references : undefined,
|
||||
};
|
||||
this.data.emails.push(email);
|
||||
let emailSubmissionId: string | undefined;
|
||||
if (delayedUntil) {
|
||||
emailSubmissionId = generateDemoId('submission');
|
||||
this.scheduledSubmissions.set(emailSubmissionId, {
|
||||
id: emailSubmissionId,
|
||||
emailId: email.id,
|
||||
identityId: _identityId || 'demo-identity',
|
||||
sendAt: delayedUntil,
|
||||
undoStatus: 'pending',
|
||||
isSmime: false,
|
||||
});
|
||||
}
|
||||
this.recalcMailboxCounts();
|
||||
return delayedUntil
|
||||
? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt: delayedUntil }
|
||||
: { scheduled: false, emailId: email.id };
|
||||
}
|
||||
|
||||
async sendImipReply(): Promise<void> { /* no-op in demo */ }
|
||||
@@ -918,7 +940,75 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
||||
async submitEmail(): Promise<void> { /* no-op */ }
|
||||
async sendRawEmail(): Promise<void> { /* no-op */ }
|
||||
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise<SendEmailResult> {
|
||||
const emailId = generateDemoId('email');
|
||||
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
|
||||
const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent');
|
||||
const email: Email = {
|
||||
id: emailId,
|
||||
threadId: generateDemoId('thread'),
|
||||
mailboxIds: { [(delayedUntil ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true },
|
||||
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
|
||||
size: 1024,
|
||||
receivedAt: new Date().toISOString(),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [],
|
||||
subject: 'S/MIME message',
|
||||
preview: 'Signed/encrypted demo message',
|
||||
hasAttachment: false,
|
||||
};
|
||||
this.data.emails.push(email);
|
||||
let emailSubmissionId: string | undefined;
|
||||
if (delayedUntil) {
|
||||
emailSubmissionId = generateDemoId('submission');
|
||||
this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: true });
|
||||
}
|
||||
this.recalcMailboxCounts();
|
||||
return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true };
|
||||
}
|
||||
|
||||
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
|
||||
const pending = Array.from(this.scheduledSubmissions.values())
|
||||
.filter(s => s.undoStatus === 'pending')
|
||||
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
|
||||
const page = pending.slice(position, position + limit);
|
||||
const emails = page.map((submission) => {
|
||||
const email = this.data.emails.find(e => e.id === submission.emailId);
|
||||
if (!email) return null;
|
||||
return {
|
||||
...email,
|
||||
scheduledSendAt: submission.sendAt,
|
||||
emailSubmissionId: submission.id,
|
||||
scheduledIdentityId: submission.identityId,
|
||||
scheduledUndoStatus: submission.undoStatus,
|
||||
isScheduled: true,
|
||||
isSmimeScheduled: submission.isSmime,
|
||||
} satisfies ScheduledEmail;
|
||||
}).filter((email): email is ScheduledEmail => email !== null);
|
||||
const nextPosition = position + page.length;
|
||||
return { emails, hasMore: nextPosition < pending.length, total: pending.length, nextPosition };
|
||||
}
|
||||
|
||||
async cancelEmailSubmission(submissionId: string): Promise<void> {
|
||||
const submission = this.scheduledSubmissions.get(submissionId);
|
||||
if (submission) submission.undoStatus = 'canceled';
|
||||
}
|
||||
|
||||
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
|
||||
await this.cancelEmailSubmission(submissionId);
|
||||
const replacement = generateDemoId('submission');
|
||||
this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: false });
|
||||
return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt: delayedUntil };
|
||||
}
|
||||
|
||||
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
email.mailboxIds[draftMailboxId] = true;
|
||||
if (sentMailboxId) delete email.mailboxIds[sentMailboxId];
|
||||
email.keywords.$draft = true;
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
|
||||
/**
|
||||
@@ -31,6 +31,8 @@ export interface IJMAPClient {
|
||||
getMaxSizeUpload(): number;
|
||||
getMaxCallsInRequest(): number;
|
||||
getMaxObjectsInGet(): number;
|
||||
getMaxDelayedSend(accountId?: string): number;
|
||||
hasDelayedSend(accountId?: string): boolean;
|
||||
getEventSourceUrl(): string | null;
|
||||
supportsEmailSubmission(): boolean;
|
||||
supportsQuota(): boolean;
|
||||
@@ -146,8 +148,15 @@ export interface IJMAPClient {
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
inReplyTo?: string[],
|
||||
references?: string[],
|
||||
delayedUntil?: string,
|
||||
envelopeMailFrom?: string,
|
||||
): Promise<void>;
|
||||
): Promise<SendEmailResult>;
|
||||
|
||||
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
|
||||
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
|
||||
cancelEmailSubmission(submissionId: string): Promise<void>;
|
||||
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
|
||||
restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void>;
|
||||
|
||||
sendImipReply(opts: {
|
||||
organizerEmail: string;
|
||||
@@ -285,5 +294,4 @@ export interface IJMAPClient {
|
||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
||||
submitEmail(emailId: string, identityId: string): Promise<void>;
|
||||
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
|
||||
}
|
||||
|
||||
+386
-18
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -61,6 +61,12 @@ interface JMAPEmailHeader {
|
||||
|
||||
type JMAPMethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
const SUBMISSION_USING = [
|
||||
'urn:ietf:params:jmap:core',
|
||||
'urn:ietf:params:jmap:mail',
|
||||
'urn:ietf:params:jmap:submission',
|
||||
] as const;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type JMAPResponseResult = Record<string, any>;
|
||||
|
||||
@@ -312,6 +318,27 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
|
||||
return emailCount === limit;
|
||||
}
|
||||
|
||||
function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean {
|
||||
return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/'));
|
||||
}
|
||||
|
||||
function isSmimeEmail(email: Email): boolean {
|
||||
const types: string[] = [];
|
||||
const collect = (part: Email['bodyStructure']): void => {
|
||||
if (!part) return;
|
||||
if (part.type) types.push(part.type.toLowerCase());
|
||||
part.subParts?.forEach(collect);
|
||||
};
|
||||
collect(email.bodyStructure);
|
||||
email.attachments?.forEach(att => types.push((att.type || '').toLowerCase()));
|
||||
return types.some(type =>
|
||||
type.includes('pkcs7') ||
|
||||
type.includes('x-pkcs7') ||
|
||||
type === 'application/pkcs7-mime' ||
|
||||
type === 'application/pkcs7-signature'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a single iCalendar content line per RFC 5545 §3.1.
|
||||
* Lines longer than 75 octets MUST be split with CRLF + a single linear white space character.
|
||||
@@ -347,6 +374,33 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string {
|
||||
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
|
||||
}
|
||||
|
||||
function normalizeEnvelopeRecipients(recipients?: Array<string | EmailAddress>): Array<{ email: string }> {
|
||||
return (recipients || [])
|
||||
.map((recipient) => typeof recipient === 'string' ? recipient : recipient.email)
|
||||
.map((email) => email.trim())
|
||||
.filter(Boolean)
|
||||
.map((email) => ({ email }));
|
||||
}
|
||||
|
||||
function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: number, recipients?: Array<string | EmailAddress>): Record<string, unknown> | undefined {
|
||||
if (!holdForSeconds) return undefined;
|
||||
const rcptTo = normalizeEnvelopeRecipients(recipients);
|
||||
return {
|
||||
mailFrom: {
|
||||
email: fromEmail,
|
||||
parameters: {
|
||||
HOLDFOR: String(holdForSeconds),
|
||||
},
|
||||
},
|
||||
rcptTo,
|
||||
};
|
||||
}
|
||||
|
||||
type SubmissionCapability = {
|
||||
maxDelayedSend?: number;
|
||||
submissionExtensions?: unknown;
|
||||
};
|
||||
|
||||
export class JMAPClient implements IJMAPClient {
|
||||
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
||||
|
||||
@@ -703,7 +757,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
using: using || (hasSubmissionMethod(methodCalls) ? [...SUBMISSION_USING] : ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]),
|
||||
methodCalls,
|
||||
};
|
||||
|
||||
@@ -2094,8 +2148,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
inReplyTo?: string[],
|
||||
references?: string[],
|
||||
delayedUntil?: string,
|
||||
envelopeMailFrom?: string
|
||||
): Promise<void> {
|
||||
): Promise<SendEmailResult> {
|
||||
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||||
const emailId = `send-${Date.now()}`;
|
||||
const mailboxes = await this.getMailboxes();
|
||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||
@@ -2197,12 +2253,19 @@ export class JMAPClient implements IJMAPClient {
|
||||
// e.g. sending from a domain-catch-all alias without a dedicated Identity),
|
||||
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
|
||||
// is omitted the server derives mailFrom from the Identity.
|
||||
const submissionCreate = (submissionId: string): Record<string, unknown> => {
|
||||
const buildSubmissionCreate = (submissionId: string): Record<string, unknown> => {
|
||||
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
|
||||
if (envelopeMailFrom) {
|
||||
if (holdForSeconds || envelopeMailFrom) {
|
||||
const envelopeRecipients = [...to, ...(cc || []), ...(bcc || [])]
|
||||
.map((email) => email.trim())
|
||||
.filter(Boolean)
|
||||
.map((email) => ({ email }));
|
||||
create.envelope = {
|
||||
mailFrom: { email: envelopeMailFrom },
|
||||
rcptTo: [...to, ...(cc || []), ...(bcc || [])].map((email) => ({ email })),
|
||||
mailFrom: {
|
||||
email: envelopeMailFrom || fromEmail || this.username,
|
||||
...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}),
|
||||
},
|
||||
rcptTo: envelopeRecipients,
|
||||
};
|
||||
}
|
||||
return { [submissionId]: create };
|
||||
@@ -2219,8 +2282,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "1"]);
|
||||
methodCalls.push(["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
create: submissionCreate("1"),
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: buildSubmissionCreate("1"),
|
||||
onSuccessUpdateEmail,
|
||||
}, "2"]);
|
||||
} else {
|
||||
@@ -2229,14 +2292,18 @@ export class JMAPClient implements IJMAPClient {
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "0"]);
|
||||
methodCalls.push(["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
create: submissionCreate("1"),
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: buildSubmissionCreate("1"),
|
||||
onSuccessUpdateEmail,
|
||||
}, "1"]);
|
||||
}
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
|
||||
let createdEmailId: string | undefined;
|
||||
let emailSubmissionId: string | undefined;
|
||||
let serverSendAt: string | undefined;
|
||||
|
||||
if (response.methodResponses) {
|
||||
for (const [methodName, result] of response.methodResponses) {
|
||||
if (methodName.endsWith('/error')) {
|
||||
@@ -2268,8 +2335,24 @@ export class JMAPClient implements IJMAPClient {
|
||||
`${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (methodName === 'Email/set' && result.created?.[emailId]?.id) {
|
||||
createdEmailId = result.created[emailId].id;
|
||||
}
|
||||
if (methodName === 'EmailSubmission/set' && result.created?.['1']?.id) {
|
||||
emailSubmissionId = result.created['1'].id;
|
||||
serverSendAt = result.created['1'].sendAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (delayedUntil && emailSubmissionId && !serverSendAt) {
|
||||
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
|
||||
}
|
||||
|
||||
return delayedUntil
|
||||
? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt }
|
||||
: { scheduled: false, emailId: createdEmailId, emailSubmissionId };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2431,7 +2514,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "0"],
|
||||
["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
||||
onSuccessUpdateEmail: {
|
||||
"#sub-1": {
|
||||
@@ -2609,7 +2692,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "0"],
|
||||
["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
||||
onSuccessUpdateEmail: {
|
||||
"#sub-1": {
|
||||
@@ -2761,7 +2844,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "0"],
|
||||
["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
||||
onSuccessUpdateEmail: {
|
||||
"#sub-1": {
|
||||
@@ -2975,6 +3058,85 @@ export class JMAPClient implements IJMAPClient {
|
||||
return coreCapability?.maxObjectsInGet || 500;
|
||||
}
|
||||
|
||||
getMaxDelayedSend(accountId?: string): number {
|
||||
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
|
||||
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
|
||||
}
|
||||
|
||||
hasDelayedSend(accountId?: string): boolean {
|
||||
const submissionCapability = this.getSubmissionCapability(accountId);
|
||||
const submissionExtensions = submissionCapability?.submissionExtensions;
|
||||
const hasFutureRelease = this.hasSubmissionExtension(submissionExtensions, 'FUTURERELEASE');
|
||||
|
||||
return !!submissionCapability
|
||||
&& hasFutureRelease
|
||||
&& this.getMaxDelayedSend(accountId) > 0;
|
||||
}
|
||||
|
||||
private validateDelayedUntil(delayedUntil: string, accountId?: string): number {
|
||||
const time = new Date(delayedUntil).getTime();
|
||||
if (!Number.isFinite(time)) {
|
||||
throw new Error('Scheduled send time is invalid');
|
||||
}
|
||||
const now = Date.now();
|
||||
if (time <= now) {
|
||||
throw new Error('Scheduled send time must be in the future');
|
||||
}
|
||||
const maxDelayedSend = this.getMaxDelayedSend(accountId);
|
||||
if (!this.hasDelayedSend(accountId) || maxDelayedSend <= 0) {
|
||||
throw new Error('Scheduled send is not supported for this account');
|
||||
}
|
||||
if (time > now + maxDelayedSend * 1000) {
|
||||
throw new Error('Scheduled send time is later than the server allows');
|
||||
}
|
||||
return Math.ceil((time - now) / 1000);
|
||||
}
|
||||
|
||||
private async getEmailSubmissionSendAt(submissionId: string): Promise<string | undefined> {
|
||||
const response = await this.request([
|
||||
['EmailSubmission/get', {
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
ids: [submissionId],
|
||||
properties: ['sendAt', 'undoStatus'],
|
||||
}, '0'],
|
||||
]);
|
||||
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { sendAt?: string } | undefined;
|
||||
return submission?.sendAt;
|
||||
}
|
||||
|
||||
private async getEmailSubmissionEnvelope(submissionId: string): Promise<{ rcptTo?: Array<{ email: string }> } | undefined> {
|
||||
const response = await this.request([
|
||||
['EmailSubmission/get', {
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
ids: [submissionId],
|
||||
properties: ['envelope'],
|
||||
}, '0'],
|
||||
]);
|
||||
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { envelope?: { rcptTo?: Array<{ email: string }> } } | undefined;
|
||||
return submission?.envelope;
|
||||
}
|
||||
|
||||
private getSubmissionAccountId(accountId?: string): string {
|
||||
return accountId || this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'] || this.accountId;
|
||||
}
|
||||
|
||||
private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined {
|
||||
const submissionAccountId = this.getSubmissionAccountId(accountId);
|
||||
return this.session?.accounts?.[submissionAccountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission'] as SubmissionCapability | undefined;
|
||||
}
|
||||
|
||||
private hasSubmissionExtension(submissionExtensions: unknown, extension: string): boolean {
|
||||
const target = extension.toUpperCase();
|
||||
if (Array.isArray(submissionExtensions)) {
|
||||
return submissionExtensions.some(item => typeof item === 'string' && item.toUpperCase() === target);
|
||||
}
|
||||
if (submissionExtensions && typeof submissionExtensions === 'object') {
|
||||
return Object.entries(submissionExtensions as Record<string, unknown>)
|
||||
.some(([key, value]) => key.toUpperCase() === target && value !== false && value != null);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getEventSourceUrl(): string | null {
|
||||
if (!this.session) return null;
|
||||
|
||||
@@ -5430,7 +5592,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
async submitEmail(emailId: string, identityId: string): Promise<void> {
|
||||
const response = await this.request([
|
||||
['EmailSubmission/set', {
|
||||
accountId: this.accountId,
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: { 'smime-submit': { emailId, identityId } },
|
||||
}, '0'],
|
||||
]);
|
||||
@@ -5451,7 +5613,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
identityId: string,
|
||||
sentMailboxId: string,
|
||||
draftMailboxId?: string,
|
||||
): Promise<void> {
|
||||
delayedUntil?: string,
|
||||
envelopeRecipients?: string[],
|
||||
): Promise<SendEmailResult> {
|
||||
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||||
// Upload the raw message
|
||||
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
||||
const { blobId } = await this.uploadBlob(file);
|
||||
@@ -5459,6 +5624,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
// Import into Drafts first, then move to Sent after submission succeeds.
|
||||
// This avoids encrypt-on-append affecting the SMTP send. See #188.
|
||||
const importMailboxId = draftMailboxId || sentMailboxId;
|
||||
const identities = await this.getIdentities();
|
||||
const identity = identities.find(item => item.id === identityId);
|
||||
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
|
||||
|
||||
const methodCalls: [string, Record<string, unknown>, string][] = [
|
||||
['Email/import', {
|
||||
accountId: this.accountId,
|
||||
@@ -5471,11 +5640,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
},
|
||||
}, '0'],
|
||||
['EmailSubmission/set', {
|
||||
accountId: this.accountId,
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: {
|
||||
'raw-submit': {
|
||||
emailId: '#raw-import',
|
||||
identityId,
|
||||
...(envelope ? { envelope } : {}),
|
||||
},
|
||||
},
|
||||
...(draftMailboxId ? {
|
||||
@@ -5491,6 +5661,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
];
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
let emailId: string | undefined;
|
||||
let emailSubmissionId: string | undefined;
|
||||
let serverSendAt: string | undefined;
|
||||
|
||||
// Check for errors
|
||||
for (const [methodName, result] of response.methodResponses ?? []) {
|
||||
@@ -5502,6 +5675,201 @@ export class JMAPClient implements IJMAPClient {
|
||||
const firstErr = Object.values(r.notCreated)[0];
|
||||
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
|
||||
}
|
||||
if (methodName === 'Email/import') {
|
||||
emailId = (result as { created?: Record<string, { id?: string }> }).created?.['raw-import']?.id;
|
||||
}
|
||||
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, emailId, emailSubmissionId, sendAt: serverSendAt, isSmime: true }
|
||||
: { scheduled: false, emailId, 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 };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const pageSize = Math.max(limit, 50);
|
||||
const submissions: EmailSubmission[] = [];
|
||||
let rawPosition = 0;
|
||||
let rawTotal = 0;
|
||||
|
||||
do {
|
||||
const queryResponse = await this.request([
|
||||
['EmailSubmission/query', {
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
limit: pageSize,
|
||||
position: rawPosition,
|
||||
}, '0'],
|
||||
]);
|
||||
|
||||
const query = queryResponse.methodResponses?.[0]?.[1] as { ids?: string[]; total?: number; position?: number } | undefined;
|
||||
const ids = query?.ids ?? [];
|
||||
rawTotal = query?.total ?? rawPosition + ids.length;
|
||||
if (ids.length === 0) break;
|
||||
|
||||
const submissionResponse = await this.request([
|
||||
['EmailSubmission/get', {
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
ids,
|
||||
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus', 'deliveryStatus'],
|
||||
}, '0'],
|
||||
]);
|
||||
|
||||
submissions.push(...((submissionResponse.methodResponses?.[0]?.[1]?.list ?? []) as EmailSubmission[])
|
||||
.filter(submission => {
|
||||
if (submission.undoStatus !== 'pending' || !submission.sendAt) return false;
|
||||
const sendAtTime = new Date(submission.sendAt).getTime();
|
||||
return Number.isFinite(sendAtTime) && sendAtTime > now;
|
||||
}));
|
||||
|
||||
rawPosition += ids.length;
|
||||
} while (rawPosition < rawTotal);
|
||||
|
||||
submissions.sort((a, b) => new Date(a.sendAt || '').getTime() - new Date(b.sendAt || '').getTime());
|
||||
const total = submissions.length;
|
||||
const pageSubmissions = submissions.slice(position, position + limit);
|
||||
const nextPosition = position + pageSubmissions.length;
|
||||
|
||||
if (pageSubmissions.length === 0) {
|
||||
return { emails: [], hasMore: false, total, nextPosition };
|
||||
}
|
||||
|
||||
const emailResponse = await this.request([
|
||||
['Email/get', {
|
||||
accountId: this.accountId,
|
||||
ids: pageSubmissions.map(submission => submission.emailId),
|
||||
properties: [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'bcc', 'replyTo',
|
||||
'subject', 'preview', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'hasAttachment', 'sentAt',
|
||||
'messageId', 'inReplyTo', 'references', 'headers', 'blobId', 'bodyStructure',
|
||||
],
|
||||
fetchTextBodyValues: true,
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchAllBodyValues: true,
|
||||
maxBodyValueBytes: 256000,
|
||||
}, '0'],
|
||||
]);
|
||||
|
||||
const emailById = new Map(((emailResponse.methodResponses?.[0]?.[1]?.list ?? []) as Email[]).map(email => [email.id, email]));
|
||||
const emails = pageSubmissions
|
||||
.map((submission): ScheduledEmail | null => {
|
||||
const email = emailById.get(submission.emailId);
|
||||
if (!email || !submission.sendAt) return null;
|
||||
return {
|
||||
...email,
|
||||
threadId: submission.threadId || email.threadId,
|
||||
scheduledSendAt: submission.sendAt,
|
||||
emailSubmissionId: submission.id,
|
||||
scheduledIdentityId: submission.identityId,
|
||||
scheduledUndoStatus: submission.undoStatus,
|
||||
scheduledDeliveryStatus: submission.deliveryStatus,
|
||||
isScheduled: true,
|
||||
isSmimeScheduled: isSmimeEmail(email),
|
||||
};
|
||||
})
|
||||
.filter((email): email is ScheduledEmail => email !== null)
|
||||
.sort((a, b) => new Date(a.scheduledSendAt).getTime() - new Date(b.scheduledSendAt).getTime());
|
||||
|
||||
return { emails, hasMore: nextPosition < total, total, nextPosition };
|
||||
}
|
||||
|
||||
async cancelEmailSubmission(submissionId: string): Promise<void> {
|
||||
const response = await this.request([
|
||||
['EmailSubmission/set', {
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
update: { [submissionId]: { undoStatus: 'canceled' } },
|
||||
}, '0'],
|
||||
]);
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
const error = result?.notUpdated?.[submissionId];
|
||||
if (error) {
|
||||
throw new Error(error.description || error.type || 'Failed to cancel scheduled send');
|
||||
}
|
||||
}
|
||||
|
||||
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
|
||||
const holdForSeconds = this.validateDelayedUntil(delayedUntil);
|
||||
const mailboxes = await this.getMailboxes();
|
||||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||
const identities = await this.getIdentities();
|
||||
const identity = identities.find(item => item.id === identityId);
|
||||
const existingEnvelope = await this.getEmailSubmissionEnvelope(submissionId);
|
||||
const email = existingEnvelope?.rcptTo?.length ? undefined : await this.getEmail(emailId);
|
||||
const envelopeRecipients = existingEnvelope?.rcptTo?.length
|
||||
? existingEnvelope.rcptTo
|
||||
: [...(email?.to || []), ...(email?.cc || []), ...(email?.bcc || [])];
|
||||
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
|
||||
const response = await this.request([
|
||||
['EmailSubmission/set', {
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } },
|
||||
...(draftsMailbox && sentMailbox ? {
|
||||
onSuccessUpdateEmail: {
|
||||
'#replacement': {
|
||||
[`mailboxIds/${draftsMailbox.id}`]: null,
|
||||
[`mailboxIds/${sentMailbox.id}`]: true,
|
||||
'keywords/$draft': null,
|
||||
},
|
||||
},
|
||||
} : {}),
|
||||
}, '0'],
|
||||
]);
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
const createError = result?.notCreated?.replacement;
|
||||
if (createError) {
|
||||
throw new Error(createError.description || createError.type || 'Failed to reschedule email');
|
||||
}
|
||||
const replacementId = result?.created?.replacement?.id;
|
||||
const serverSendAt = result?.created?.replacement?.sendAt;
|
||||
if (!replacementId) {
|
||||
throw new Error('Server did not return a replacement scheduled send ID');
|
||||
}
|
||||
const finalSendAt = serverSendAt || await this.getEmailSubmissionSendAt(replacementId);
|
||||
try {
|
||||
await this.cancelEmailSubmission(submissionId);
|
||||
} catch (error) {
|
||||
try {
|
||||
await this.cancelEmailSubmission(replacementId);
|
||||
} catch (cleanupError) {
|
||||
console.error('Failed to clean up replacement scheduled send after reschedule failure:', cleanupError);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Failed to cancel original scheduled send';
|
||||
throw new Error(`Reschedule created a replacement but could not cancel the original: ${message}`);
|
||||
}
|
||||
return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt };
|
||||
}
|
||||
|
||||
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
|
||||
const update: Record<string, unknown> = {
|
||||
[`mailboxIds/${draftMailboxId}`]: true,
|
||||
'keywords/$draft': true,
|
||||
};
|
||||
if (sentMailboxId) {
|
||||
update[`mailboxIds/${sentMailboxId}`] = null;
|
||||
}
|
||||
const response = await this.request([
|
||||
['Email/set', {
|
||||
accountId: this.accountId,
|
||||
update: { [emailId]: update },
|
||||
}, '0'],
|
||||
]);
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
const error = result?.notUpdated?.[emailId];
|
||||
if (error) {
|
||||
throw new Error(error.description || error.type || 'Failed to restore email to drafts');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5580,4 +5948,4 @@ export class JMAPClient implements IJMAPClient {
|
||||
['urn:ietf:params:jmap:core'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -42,6 +42,32 @@ export interface Email {
|
||||
// Unified mailbox support - set when displaying emails from multiple accounts
|
||||
accountId?: string;
|
||||
accountLabel?: string;
|
||||
// Client-only scheduled-send metadata, populated from EmailSubmission/query.
|
||||
scheduledSendAt?: string;
|
||||
emailSubmissionId?: string;
|
||||
scheduledIdentityId?: string;
|
||||
scheduledUndoStatus?: 'pending' | 'final' | 'canceled';
|
||||
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
|
||||
isScheduled?: boolean;
|
||||
isSmimeScheduled?: boolean;
|
||||
}
|
||||
|
||||
export interface SendEmailResult {
|
||||
scheduled: boolean;
|
||||
emailId?: string;
|
||||
emailSubmissionId?: string;
|
||||
sendAt?: string;
|
||||
isSmime?: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduledEmail extends Email {
|
||||
scheduledSendAt: string;
|
||||
emailSubmissionId: string;
|
||||
scheduledIdentityId: string;
|
||||
scheduledUndoStatus: 'pending' | 'final' | 'canceled';
|
||||
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
|
||||
isScheduled: true;
|
||||
isSmimeScheduled: boolean;
|
||||
}
|
||||
|
||||
export interface AuthenticationResults {
|
||||
@@ -785,4 +811,4 @@ export const UNIFIED_ROLE_BY_ID: Record<string, UnifiedMailboxRole> = Object.fro
|
||||
|
||||
export function isUnifiedMailboxId(id: string): boolean {
|
||||
return id in UNIFIED_ROLE_BY_ID;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user