- {formatDate(emailDisplayDate(email))}
+ {email.isScheduled && email.scheduledSendAt ? (
+
+
+ {formatScheduledSendAt(email.scheduledSendAt, timeFormat)}
+
+ ) : (
+ formatDate(emailDisplayDate(email))
+ )}
{!isExpanded && density !== 'extra-compact' && (
@@ -424,6 +440,14 @@ function EmailCard({
{/* Expanded Content */}
{isExpanded && (
+ {email.isScheduled &&
}
{isStarred &&
}
{isAnswered &&
}
{isForwarded &&
}
@@ -276,6 +277,9 @@ export function ThreadEmailItem({
{/* Indicators */}
+ {email.isScheduled && (
+
+ )}
{isStarred && (
)}
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index bbd5971b7..f83a24730 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -37,7 +37,7 @@ import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
import { messagePriorityFromHeaders, type MessagePriority } from "@/lib/message-priority";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
-import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder } from "@/stores/email-store";
+import { useEmailStore, annotateScheduledEmails, annotateScheduledEmail, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder } from "@/stores/email-store";
import { findDraftIdentityIdForLogin, listRowSource, resolveDraftSource, type DraftSource } from "@/lib/draft-source";
import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
@@ -2132,27 +2132,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}
- if (result.scheduled) {
- await refreshScheduledMetadata(client);
- if (isScheduledView) await fetchScheduledEmails(client);
- return;
- }
-
- // Delayed sends get the undo toast instead; a filing error or a refused
- // recipient already warned.
- if (!result.filingError && !result.rejectedRecipients?.length) toast.success(t('notifications.email_sent'));
-
- // Refresh the current mailbox to update the UI
- if (!isScheduledView) {
- await refreshCurrentMailbox(client);
- // Re-fetch the replied thread's cross-folder data so the expanded
- // view shows the newly sent reply without collapsing.
- if (originalEmailId) {
- const emailState = useEmailStore.getState();
- const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
- if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) {
- // Route to the email's own account so shared/group threads refresh
- // from the right server, not the active one. (#281, #814)
+ const refreshRepliedThreadAfterSend = async (emailId: string | undefined) => {
+ if (!emailId || !client) return;
+ const emailState = useEmailStore.getState();
+ const repliedEmail = emailState.emails.find(e => e.id === emailId);
+ if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) {
+ try {
const route = resolveThreadRoute({
isUnifiedView: emailState.isUnifiedView,
ref: repliedEmail,
@@ -2173,8 +2158,31 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return { threadEmailsCache: c };
});
}
+ } catch (e) {
+ debug.error('Failed to refresh replied thread after send:', e);
}
}
+ };
+
+ if (result.scheduled) {
+ await refreshScheduledMetadata(client);
+ if (isScheduledView) {
+ await fetchScheduledEmails(client);
+ } else {
+ await refreshCurrentMailbox(client);
+ }
+ await refreshRepliedThreadAfterSend(originalEmailId);
+ return;
+ }
+
+ // Delayed sends get the undo toast instead; a filing error or a refused
+ // recipient already warned.
+ if (!result.filingError && !result.rejectedRecipients?.length) toast.success(t('notifications.email_sent'));
+
+ // Refresh the current mailbox to update the UI
+ if (!isScheduledView) {
+ await refreshCurrentMailbox(client);
+ await refreshRepliedThreadAfterSend(originalEmailId);
}
} catch (error) {
console.error("Failed to send email:", error);
@@ -3971,8 +3979,41 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}
+ const refreshInlineRepliedThread = async () => {
+ const emailState = useEmailStore.getState();
+ const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
+ if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) {
+ const route = resolveThreadRoute({
+ isUnifiedView: emailState.isUnifiedView,
+ ref: repliedEmail,
+ mailboxes: viewMailboxes,
+ selectedMailbox: emailState.selectedMailbox,
+ });
+ const threadClient = route.clientAccountId
+ ? (useAuthStore.getState().getClientForAccount(route.clientAccountId) ?? client)
+ : client;
+ try {
+ const fullEmails = await threadClient.getThreadEmails(
+ repliedEmail.threadId,
+ route.accountId ?? client.getAccountId(),
+ );
+ if (fullEmails.length > 0) {
+ useEmailStore.setState((state) => {
+ const c = new Map(state.threadEmailsCache);
+ c.set(threadKeyFor(repliedEmail), fullEmails);
+ return { threadEmailsCache: c };
+ });
+ }
+ } catch (e) {
+ debug.error('Failed to refresh replied thread after send:', e);
+ }
+ }
+ };
+
if (result.scheduled) {
await refreshScheduledMetadata(client);
+ await refreshCurrentMailbox(client);
+ await refreshInlineRepliedThread();
return;
}
@@ -3984,34 +4025,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
try {
// Refresh emails to show the sent reply
await refreshCurrentMailbox(client);
- // Re-fetch the replied thread's cross-folder data so the expanded
- // view shows the newly sent reply without collapsing.
- const emailState = useEmailStore.getState();
- const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
- if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) {
- // Route to the email's own account so shared/group threads refresh from
- // the right server, not the active one. (#281, #814)
- const route = resolveThreadRoute({
- isUnifiedView: emailState.isUnifiedView,
- ref: repliedEmail,
- mailboxes: viewMailboxes,
- selectedMailbox: emailState.selectedMailbox,
- });
- const threadClient = route.clientAccountId
- ? (useAuthStore.getState().getClientForAccount(route.clientAccountId) ?? client)
- : client;
- const fullEmails = await threadClient.getThreadEmails(
- repliedEmail.threadId,
- route.accountId ?? client.getAccountId(),
- );
- if (fullEmails.length > 0) {
- useEmailStore.setState((state) => {
- const c = new Map(state.threadEmailsCache);
- c.set(threadKeyFor(repliedEmail), fullEmails);
- return { threadEmailsCache: c };
- });
- }
- }
+ await refreshInlineRepliedThread();
} catch (error) {
console.error('Quick reply sent, but refreshing the view failed:', error);
}
@@ -4055,7 +4069,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
e.sourceClientAccountId = ref.sourceClientAccountId;
e.sourceAccountId = ref.sourceAccountId;
}
- return emails
+ const scheduledSubmissionByEmailId = useEmailStore.getState().scheduledSubmissionByEmailId;
+ const annotated = annotateScheduledEmails(emails, scheduledSubmissionByEmailId);
+ return annotated
.filter((e) => e.id === ref.id || !e.keywords?.$draft)
.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
})().catch(() => null);
@@ -4301,7 +4317,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const fullEmail = await requestEmailContent(fetchClient, email.id, accountId);
if (openSeq !== emailOpenSeqRef.current) return;
if (fullEmail) {
- if (listEmail?.isScheduled) {
+ const scheduledSubmissionByEmailId = useEmailStore.getState().scheduledSubmissionByEmailId;
+ const annotated = annotateScheduledEmail(fullEmail, scheduledSubmissionByEmailId);
+ if (annotated.isScheduled) {
+ fullEmail.scheduledSendAt = annotated.scheduledSendAt;
+ fullEmail.emailSubmissionId = annotated.emailSubmissionId;
+ fullEmail.scheduledIdentityId = annotated.scheduledIdentityId;
+ fullEmail.scheduledUndoStatus = annotated.scheduledUndoStatus;
+ fullEmail.scheduledAccountId = annotated.scheduledAccountId;
+ fullEmail.isScheduled = true;
+ } else if (listEmail?.isScheduled) {
fullEmail.scheduledSendAt = listEmail.scheduledSendAt;
fullEmail.emailSubmissionId = listEmail.emailSubmissionId;
fullEmail.scheduledIdentityId = listEmail.scheduledIdentityId;
diff --git a/stores/__tests__/email-store-scheduled-annotation.test.ts b/stores/__tests__/email-store-scheduled-annotation.test.ts
new file mode 100644
index 000000000..5f8d0fb47
--- /dev/null
+++ b/stores/__tests__/email-store-scheduled-annotation.test.ts
@@ -0,0 +1,106 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { useEmailStore, annotateScheduledEmail, annotateScheduledEmails } from '../email-store';
+import type { Email } from '@/lib/jmap/types';
+
+function makeEmail(id: string, threadId = `thread-${id}`): Email {
+ return {
+ id,
+ threadId,
+ mailboxIds: { inbox: true },
+ keywords: {},
+ size: 100,
+ receivedAt: new Date().toISOString(),
+ from: [{ name: 'Test', email: 'test@example.com' }],
+ to: [{ name: 'User', email: 'user@example.com' }],
+ subject: `Email ${id}`,
+ preview: 'preview',
+ hasAttachment: false,
+ textBody: [],
+ htmlBody: [],
+ bodyValues: {},
+ };
+}
+
+describe('scheduled email annotation in email-store', () => {
+ const metadataMap = new Map([
+ ['email-scheduled', {
+ submissionId: 'sub-1',
+ sendAt: '2026-10-30T10:00:00Z',
+ identityId: 'id-1',
+ undoStatus: 'pending' as const,
+ accountId: 'acc-1',
+ }],
+ ]);
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ useEmailStore.setState({
+ emails: [makeEmail('email-1'), makeEmail('email-scheduled')],
+ selectedEmail: null,
+ scheduledSubmissionByEmailId: metadataMap,
+ scheduledEmailIds: new Set(['email-scheduled']),
+ });
+ });
+
+ it('annotates email with scheduled properties when present in scheduledSubmissionByEmailId', () => {
+ const email = makeEmail('email-scheduled');
+ const annotated = annotateScheduledEmail(email, metadataMap);
+
+ expect(annotated.isScheduled).toBe(true);
+ expect(annotated.scheduledSendAt).toBe('2026-10-30T10:00:00Z');
+ expect(annotated.emailSubmissionId).toBe('sub-1');
+ expect(annotated.scheduledUndoStatus).toBe('pending');
+ expect(annotated.scheduledAccountId).toBe('acc-1');
+ });
+
+ it('returns identical reference when email is not scheduled and not in metadata', () => {
+ const email = makeEmail('email-1');
+ const annotated = annotateScheduledEmail(email, metadataMap);
+
+ expect(annotated).toBe(email);
+ expect(annotated.isScheduled).toBeUndefined();
+ });
+
+ it('removes scheduled properties when email is no longer in scheduled metadata', () => {
+ const previouslyScheduled: Email = {
+ ...makeEmail('email-no-longer-scheduled'),
+ isScheduled: true,
+ scheduledSendAt: '2026-10-30T10:00:00Z',
+ emailSubmissionId: 'sub-old',
+ };
+ const cleaned = annotateScheduledEmail(previouslyScheduled, new Map());
+
+ expect(cleaned.isScheduled).toBeUndefined();
+ expect(cleaned.scheduledSendAt).toBeUndefined();
+ expect(cleaned.emailSubmissionId).toBeUndefined();
+ });
+
+ it('annotates a list of emails in annotateScheduledEmails', () => {
+ const emails = [makeEmail('email-1'), makeEmail('email-scheduled')];
+ const annotatedList = annotateScheduledEmails(emails, metadataMap);
+
+ expect(annotatedList[0].isScheduled).toBeUndefined();
+ expect(annotatedList[0]).toBe(emails[0]);
+ expect(annotatedList[1].isScheduled).toBe(true);
+ expect(annotatedList[1].scheduledSendAt).toBe('2026-10-30T10:00:00Z');
+ });
+
+ it('returns original array reference if scheduledSubmissionByEmailId is empty', () => {
+ const emails = [makeEmail('email-1')];
+ const result = annotateScheduledEmails(emails, new Map());
+ expect(result).toBe(emails);
+ });
+
+ it('preserves email object reference across selectEmail, beginOpenEmail, and finishOpenEmail', () => {
+ // Ensuring reference identity is preserved for plugin open/close hooks
+ const email = makeEmail('email-1');
+ useEmailStore.getState().selectEmail(email);
+ expect(useEmailStore.getState().selectedEmail).toBe(email);
+
+ useEmailStore.getState().beginOpenEmail(email);
+ expect(useEmailStore.getState().selectedEmail).toBe(email);
+
+ useEmailStore.getState().finishOpenEmail(email);
+ expect(useEmailStore.getState().selectedEmail).toBe(email);
+ });
+});
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 32d7e2503..23c7b53d8 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -603,7 +603,7 @@ function getNextSelectedEmailAfterRemovingRow(
return null;
}
-function annotateScheduledEmails(
+export function annotateScheduledEmails(
emails: Email[],
scheduledSubmissionByEmailId: Map
): Email[] {
@@ -634,14 +634,27 @@ function scheduledSubmissionMap(client: IJMAPClient, emails: ScheduledEmail[]):
}]));
}
-function annotateScheduledEmail(
+export function annotateScheduledEmail(
email: Email,
scheduledSubmissionByEmailId: Map
): Email {
// The row's login and account: its stamps, else the folder on screen.
const { client, clientAccountId, accountId } = resolveEmailBlobContext(email, useAuthStore.getState().client);
- const scheduled = scheduledSubmissionByEmailId.get(scheduledEmailKey(clientAccountId, accountId ?? client?.getAccountId(), email.id));
- if (!scheduled) return email;
+ const scheduled = scheduledSubmissionByEmailId.get(scheduledEmailKey(clientAccountId, accountId ?? client?.getAccountId(), email.id))
+ ?? scheduledSubmissionByEmailId.get(email.id);
+ if (!scheduled) {
+ if (email.isScheduled) {
+ const copy = { ...email };
+ delete copy.isScheduled;
+ delete copy.scheduledSendAt;
+ delete copy.emailSubmissionId;
+ delete copy.scheduledIdentityId;
+ delete copy.scheduledUndoStatus;
+ delete copy.scheduledAccountId;
+ return copy;
+ }
+ return email;
+ }
return {
...email,
scheduledSendAt: scheduled.sendAt,
@@ -7629,6 +7642,7 @@ export const useEmailStore = create((set, get) => ({
scheduledEmails: result.emails,
scheduledEmailIds,
scheduledSubmissionByEmailId,
+ emails: annotateScheduledEmails(get().emails, scheduledSubmissionByEmailId),
scheduledTotal: result.total,
scheduledTotalByAccount: result.totalByAccount ?? {},
scheduledHasMore: result.hasMore,