This commit is contained in:
Lucas Gaitzsch
2026-05-09 00:31:34 +02:00
parent f45b67fe19
commit 4a3c775b40
9 changed files with 164 additions and 95 deletions
+8 -4
View File
@@ -1553,12 +1553,16 @@ function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult {
const created: Record<string, { id: string; sendAt?: string }> = {};
const updated: Record<string, null> = {};
const create = args.create as Record<string, { emailId?: string; identityId?: string; envelope?: { mailFrom?: { parameters?: { HOLDUNTIL?: string } } } }> | undefined;
const create = args.create as Record<string, { emailId?: string; identityId?: string; envelope?: { mailFrom?: { parameters?: { HOLDFOR?: string; HOLDUNTIL?: string } } } }> | undefined;
if (create) {
for (const [key, value] of Object.entries(create)) {
const id = `submission-${Date.now()}-${key}`;
const holdFor = value.envelope?.mailFrom?.parameters?.HOLDFOR;
const holdUntil = value.envelope?.mailFrom?.parameters?.HOLDUNTIL;
const holdUntilTime = holdUntil ? new Date(holdUntil).getTime() : Number.NaN;
const holdForSeconds = holdFor ? Number(holdFor) : Number.NaN;
const holdUntilTime = Number.isFinite(holdForSeconds) && holdForSeconds > 0
? Date.now() + holdForSeconds * 1000
: holdUntil ? new Date(holdUntil).getTime() : Number.NaN;
const delayedUntil = Number.isFinite(holdUntilTime) ? new Date(holdUntilTime).toISOString() : undefined;
created[key] = { id, ...(delayedUntil ? { sendAt: delayedUntil } : {}) };
if (delayedUntil && value.emailId && value.identityId) {
@@ -1583,8 +1587,8 @@ function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResul
function handleEmailSubmissionQuery(args: MethodArgs, callId: string): MethodResult {
const position = Number(args.position || 0);
const limit = Number(args.limit || 50);
const pending = scheduledSubmissions.filter(s => s.undoStatus === 'pending').sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
return ['EmailSubmission/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: pending.slice(position, position + limit).map(s => s.id), total: pending.length, position, canCalculateChanges: false }, callId];
const submissions = [...scheduledSubmissions].sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
return ['EmailSubmission/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: submissions.slice(position, position + limit).map(s => s.id), total: submissions.length, position, canCalculateChanges: false }, callId];
}
function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResult {
+3 -2
View File
@@ -152,6 +152,7 @@ export function EmailContextMenu({
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
// Build color options from keyword definitions in settings
const colorOptions = emailKeywords.map((kw) => ({
@@ -205,7 +206,7 @@ export function EmailContextMenu({
</ContextMenuHeader>
)}
{isScheduled && !showBatchActions && (
{isScheduled && !showBatchActions && canCancelScheduled && (
<>
<ContextMenuItem
icon={CalendarClock}
@@ -228,7 +229,7 @@ export function EmailContextMenu({
</>
)}
{isScheduled && <ContextMenuSeparator />}
{canCancelScheduled && <ContextMenuSeparator />}
{!isScheduled && (
<>
+29 -20
View File
@@ -505,30 +505,39 @@ export function EmailList({
/>
{isScheduledView && thread.latestEmail.isScheduled && (
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/10 px-4 py-2 text-xs">
{thread.latestEmail.scheduledUndoStatus && thread.latestEmail.scheduledUndoStatus !== 'pending' && (
<span className="rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
{thread.latestEmail.scheduledUndoStatus}
</span>
)}
<span className="flex items-center gap-1 text-muted-foreground">
<CalendarClock className="w-3.5 h-3.5" />
{new Date(thread.latestEmail.scheduledSendAt || '').toLocaleString()}
</span>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => onCancelScheduled?.(thread.latestEmail)}>
<XCircle className="w-3.5 h-3.5 mr-1" />
{t('cancel_scheduled_send')}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) onRescheduleScheduled?.(thread.latestEmail, delayedUntil);
}}
>
<CalendarClock className="w-3.5 h-3.5 mr-1" />
{t('reschedule_send')}
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => onCancelScheduledForEdit?.(thread.latestEmail)}>
<Edit3 className="w-3.5 h-3.5 mr-1" />
{thread.latestEmail.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
{thread.latestEmail.scheduledUndoStatus === 'pending' && (
<>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => onCancelScheduled?.(thread.latestEmail)}>
<XCircle className="w-3.5 h-3.5 mr-1" />
{t('cancel_scheduled_send')}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) onRescheduleScheduled?.(thread.latestEmail, delayedUntil);
}}
>
<CalendarClock className="w-3.5 h-3.5 mr-1" />
{t('reschedule_send')}
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => onCancelScheduledForEdit?.(thread.latestEmail)}>
<Edit3 className="w-3.5 h-3.5 mr-1" />
{thread.latestEmail.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
</>
)}
</div>
)}
</div>
+20 -15
View File
@@ -871,6 +871,7 @@ export function EmailViewer({
// Detect if the email is a draft
const isDraft = email?.keywords?.['$draft'] === true;
const isScheduled = email?.isScheduled === true;
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
// Color options for email tags (from user-defined keyword settings)
const colorOptions = emailKeywords.map((kw) => ({
@@ -3137,7 +3138,7 @@ export function EmailViewer({
<ChevronLeft className="w-5 h-5" />
</Button>
)}
{isScheduled && (
{isScheduled && canCancelScheduled && (
<>
<Button variant="default" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
<X className="w-4 h-4" />
@@ -4593,20 +4594,24 @@ export function EmailViewer({
</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
}}
>
{t('reschedule_send')}
</Button>
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit}>
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
{canCancelScheduled && (
<>
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
}}
>
{t('reschedule_send')}
</Button>
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit}>
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
</>
)}
</div>
</div>
</div>
+12 -3
View File
@@ -16,14 +16,22 @@ function enableDelayedSend(client: JMAPClient) {
capabilities: {
'urn:ietf:params:jmap:core': {},
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: ['FUTURERELEASE'] },
'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': {},
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: ['FUTURERELEASE'] },
},
},
'submission-account-1': {
accountCapabilities: {
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: true } },
},
},
},
@@ -189,6 +197,7 @@ describe('JMAPClient.sendEmail threading headers', () => {
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-/),
@@ -196,7 +205,7 @@ describe('JMAPClient.sendEmail threading headers', () => {
envelope: {
mailFrom: {
email: 'user@example.com',
parameters: { HOLDUNTIL: expect.stringMatching(/^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} \+0000$/) },
parameters: { HOLDFOR: expect.stringMatching(/^\d+$/) },
},
},
},
+1 -1
View File
@@ -58,7 +58,7 @@ export class DemoJMAPClient implements IJMAPClient {
return {
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: ['FUTURERELEASE'] },
'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': {},
+65 -48
View File
@@ -337,27 +337,23 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string {
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
}
function formatHoldUntil(delayedUntil: string): string {
const date = new Date(delayedUntil);
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const pad = (value: number) => String(value).padStart(2, '0');
return `${weekdays[date.getUTCDay()]}, ${pad(date.getUTCDate())} ${months[date.getUTCMonth()]} ${date.getUTCFullYear()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} +0000`;
}
function createDelayedSubmissionEnvelope(fromEmail: string, delayedUntil?: string): Record<string, unknown> | undefined {
if (!delayedUntil) return undefined;
function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: number): Record<string, unknown> | undefined {
if (!holdForSeconds) return undefined;
return {
mailFrom: {
email: fromEmail,
parameters: {
HOLDUNTIL: formatHoldUntil(delayedUntil),
HOLDFOR: String(holdForSeconds),
},
},
};
}
type SubmissionCapability = {
maxDelayedSend?: number;
submissionExtensions?: unknown;
};
export class JMAPClient implements IJMAPClient {
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
@@ -2105,7 +2101,7 @@ export class JMAPClient implements IJMAPClient {
references?: string[],
delayedUntil?: string
): Promise<SendEmailResult> {
if (delayedUntil) this.validateDelayedUntil(delayedUntil);
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');
@@ -2213,10 +2209,10 @@ export class JMAPClient implements IJMAPClient {
const submissionCreate = {
emailId: `#${emailId}`,
identityId: finalIdentityId,
...(delayedUntil ? { envelope: createDelayedSubmissionEnvelope(fromEmail || this.username, delayedUntil) } : {}),
...(holdForSeconds ? { envelope: createDelayedSubmissionEnvelope(fromEmail || this.username, holdForSeconds) } : {}),
};
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: { "1": submissionCreate },
onSuccessUpdateEmail,
}, "2"]);
@@ -2228,10 +2224,10 @@ export class JMAPClient implements IJMAPClient {
const submissionCreate = {
emailId: `#${emailId}`,
identityId: finalIdentityId,
...(delayedUntil ? { envelope: createDelayedSubmissionEnvelope(fromEmail || this.username, delayedUntil) } : {}),
...(holdForSeconds ? { envelope: createDelayedSubmissionEnvelope(fromEmail || this.username, holdForSeconds) } : {}),
};
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: { "1": submissionCreate },
onSuccessUpdateEmail,
}, "1"]);
@@ -2435,7 +2431,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": {
@@ -2613,7 +2609,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": {
@@ -2765,7 +2761,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": {
@@ -2902,27 +2898,21 @@ export class JMAPClient implements IJMAPClient {
}
getMaxDelayedSend(accountId?: string): number {
const id = accountId || this.accountId;
const accountCapability = this.session?.accounts?.[id]?.accountCapabilities?.["urn:ietf:params:jmap:submission"] as { maxDelayedSend?: number } | undefined;
const sessionCapability = this.session?.capabilities?.["urn:ietf:params:jmap:submission"] as { maxDelayedSend?: number } | undefined;
const maxDelayedSend = accountCapability?.maxDelayedSend ?? sessionCapability?.maxDelayedSend;
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
}
hasDelayedSend(accountId?: string): boolean {
const id = accountId || this.accountId;
const accountCapability = this.session?.accounts?.[id]?.accountCapabilities?.["urn:ietf:params:jmap:submission"] as { submissionExtensions?: unknown } | undefined;
const sessionCapability = this.session?.capabilities?.["urn:ietf:params:jmap:submission"] as { submissionExtensions?: unknown } | undefined;
const submissionExtensions = accountCapability?.submissionExtensions ?? sessionCapability?.submissionExtensions;
const hasFutureRelease = Array.isArray(submissionExtensions)
&& submissionExtensions.some(extension => typeof extension === 'string' && extension.toUpperCase() === 'FUTURERELEASE');
const submissionCapability = this.getSubmissionCapability(accountId);
const submissionExtensions = submissionCapability?.submissionExtensions;
const hasFutureRelease = this.hasSubmissionExtension(submissionExtensions, 'FUTURERELEASE');
return this.supportsEmailSubmission()
return !!submissionCapability
&& hasFutureRelease
&& this.getMaxDelayedSend(id) > 0;
&& this.getMaxDelayedSend(accountId) > 0;
}
private validateDelayedUntil(delayedUntil: string, accountId?: string): void {
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');
@@ -2938,12 +2928,13 @@ export class JMAPClient implements IJMAPClient {
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.accountId,
accountId: this.getSubmissionAccountId(),
ids: [submissionId],
properties: ['sendAt', 'undoStatus'],
}, '0'],
@@ -2952,6 +2943,27 @@ export class JMAPClient implements IJMAPClient {
return submission?.sendAt;
}
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;
@@ -5397,7 +5409,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'],
]);
@@ -5420,7 +5432,7 @@ export class JMAPClient implements IJMAPClient {
draftMailboxId?: string,
delayedUntil?: string,
): Promise<SendEmailResult> {
if (delayedUntil) this.validateDelayedUntil(delayedUntil);
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);
@@ -5430,7 +5442,7 @@ export class JMAPClient implements IJMAPClient {
const importMailboxId = draftMailboxId || sentMailboxId;
const identities = await this.getIdentities();
const identity = identities.find(item => item.id === identityId);
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, delayedUntil);
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds);
const methodCalls: [string, Record<string, unknown>, string][] = [
['Email/import', {
@@ -5444,7 +5456,7 @@ export class JMAPClient implements IJMAPClient {
},
}, '0'],
['EmailSubmission/set', {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: {
'raw-submit': {
emailId: '#raw-import',
@@ -5505,9 +5517,7 @@ export class JMAPClient implements IJMAPClient {
const queryResponse = await this.request([
['EmailSubmission/query', {
accountId: this.accountId,
filter: { undoStatus: 'pending' },
sort: [{ property: 'sendAt', isAscending: true }],
accountId: this.getSubmissionAccountId(),
limit,
position,
}, '0'],
@@ -5521,13 +5531,18 @@ export class JMAPClient implements IJMAPClient {
const submissionResponse = await this.request([
['EmailSubmission/get', {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
ids,
properties: ['id', 'emailId', 'identityId', 'sendAt', 'undoStatus'],
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus', 'deliveryStatus'],
}, '0'],
]);
const now = Date.now();
const submissions = ((submissionResponse.methodResponses?.[0]?.[1]?.list ?? []) as EmailSubmission[])
.filter(submission => submission.sendAt && Number.isFinite(new Date(submission.sendAt).getTime()));
.filter(submission => {
if (!submission.sendAt) return false;
const sendAtTime = new Date(submission.sendAt).getTime();
return Number.isFinite(sendAtTime) && sendAtTime > now;
});
if (submissions.length === 0) {
return { emails: [], hasMore: false, total: query?.total ?? 0 };
@@ -5556,10 +5571,12 @@ export class JMAPClient implements IJMAPClient {
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),
};
@@ -5574,7 +5591,7 @@ export class JMAPClient implements IJMAPClient {
async cancelEmailSubmission(submissionId: string): Promise<void> {
const response = await this.request([
['EmailSubmission/set', {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
update: { [submissionId]: { undoStatus: 'canceled' } },
}, '0'],
]);
@@ -5586,16 +5603,16 @@ export class JMAPClient implements IJMAPClient {
}
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
this.validateDelayedUntil(delayedUntil);
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 envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, delayedUntil);
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds);
const response = await this.request([
['EmailSubmission/set', {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } },
...(draftsMailbox && sentMailbox ? {
onSuccessUpdateEmail: {
+2
View File
@@ -47,6 +47,7 @@ export interface Email {
emailSubmissionId?: string;
scheduledIdentityId?: string;
scheduledUndoStatus?: 'pending' | 'final' | 'canceled';
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
isScheduled?: boolean;
isSmimeScheduled?: boolean;
}
@@ -64,6 +65,7 @@ export interface ScheduledEmail extends Email {
emailSubmissionId: string;
scheduledIdentityId: string;
scheduledUndoStatus: 'pending' | 'final' | 'canceled';
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
isScheduled: true;
isSmimeScheduled: boolean;
}
+24 -2
View File
@@ -18,6 +18,8 @@ type ScheduledSubmissionMetadata = {
undoStatus: 'pending' | 'final' | 'canceled';
};
const VIRTUAL_SCHEDULED_MAILBOX_ID = '__scheduled__';
type PendingUndoSend = { submissionId: string; emailId?: string; sendAt: string; isSmime: boolean };
interface EmailStore {
@@ -400,7 +402,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Auto-select inbox if no mailbox is selected or the current selection
// doesn't exist in the fetched list (e.g. after an account switch)
const currentSelectedMailbox = get().selectedMailbox;
const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox);
const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID
|| (currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox));
const loadingPatch = isInitialLoad ? { isLoading: false } : {};
if (!selectionValid) {
// Find inbox from PRIMARY account (not shared accounts)
@@ -425,6 +428,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
try {
const targetMailboxId = mailboxId || get().selectedMailbox;
if (targetMailboxId === VIRTUAL_SCHEDULED_MAILBOX_ID) {
set({ isLoading: false, emails: [], hasMoreEmails: false, totalEmails: 0 });
await get().fetchScheduledEmails(client);
return;
}
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = get().mailboxes;
@@ -511,6 +519,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoadingMore: true, error: null });
try {
if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
set({ isLoadingMore: false });
await get().loadMoreScheduledEmails(client);
return;
}
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
@@ -1678,6 +1692,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Only refresh if a mailbox is currently selected
if (!selectedMailbox) return;
if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
await get().fetchScheduledEmails(client);
return;
}
try {
// Fetch emails for the current mailbox without clearing the list first
// This provides a smoother update experience
@@ -2040,7 +2059,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
},
setScheduledView: (isScheduledView) => set({ isScheduledView }),
setScheduledView: (isScheduledView) => set(state => ({
isScheduledView,
selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : state.selectedMailbox,
})),
clearPendingUndoSend: () => set({ pendingUndoSend: null }),
fetchScheduledEmails: async (client) => {