mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
Merge pull request #1360 from arnauda-gh/fix/scheduled-email-date-and-conversation-status
# Conflicts: # components/email/email-viewer.tsx # components/email/thread-conversation-view.tsx # components/mail/mail-app.tsx
This commit is contained in:
@@ -755,6 +755,7 @@ function mvSenderDate(date: Date | string, timeFormat: '12h' | '24h'): string {
|
||||
* snippet. Clicking it unfolds the message in place.
|
||||
*/
|
||||
function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; compact?: boolean }) {
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const sender = email.from?.[0];
|
||||
const unread = !email.keywords?.$seen;
|
||||
return (
|
||||
@@ -771,6 +772,12 @@ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Ema
|
||||
<span dir="auto" className={cn("text-sm truncate", unread ? "font-bold" : "font-semibold")}>
|
||||
{sender?.name || sender?.email || ''}
|
||||
</span>
|
||||
{email.isScheduled && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium bg-primary/10 text-primary shrink-0">
|
||||
<CalendarClock className="w-3 h-3" />
|
||||
{email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')}
|
||||
</span>
|
||||
)}
|
||||
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
|
||||
</span>
|
||||
@@ -847,6 +854,7 @@ export function MvExpandedConversationCard({
|
||||
blobSource?: EmailBlobContext;
|
||||
}) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const externalContent = useConversationExternalContent();
|
||||
const sender = email.from?.[0];
|
||||
const hasBody = !!email.bodyValues;
|
||||
@@ -885,6 +893,12 @@ export function MvExpandedConversationCard({
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span dir="auto" className="text-sm font-semibold truncate">{sender?.name || sender?.email || ''}</span>
|
||||
{email.isScheduled && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium bg-primary/10 text-primary shrink-0">
|
||||
<CalendarClock className="w-3 h-3" />
|
||||
{email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')}
|
||||
</span>
|
||||
)}
|
||||
{!compact && sender?.name && sender.email && (
|
||||
<span className="text-xs text-muted-foreground truncate"><{sender.email}></span>
|
||||
)}
|
||||
@@ -920,6 +934,14 @@ export function MvExpandedConversationCard({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{email.isScheduled && (
|
||||
<div className={cn("border-b border-border bg-primary/10 py-2 flex items-center gap-2 text-primary text-xs font-medium", compact ? "px-4" : "ps-[80px] pe-6")}>
|
||||
<CalendarClock className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
{t('scheduled_banner', { date: email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : '' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showDetails && <MvConversationDetails email={email} timeFormat={timeFormat} className={compact ? "px-4" : "ps-[80px] pe-6"} />}
|
||||
{hasBody ? (
|
||||
<ConversationEmailBody
|
||||
|
||||
@@ -40,7 +40,9 @@ import {
|
||||
FileArchive,
|
||||
File,
|
||||
Eye,
|
||||
CalendarClock,
|
||||
} from "@/components/icons";
|
||||
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
@@ -360,6 +362,7 @@ function EmailCard({
|
||||
}: EmailCardProps) {
|
||||
const t = useTranslations();
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const plugin = useConversationPluginProps(email);
|
||||
const sender = email.from?.[0];
|
||||
const formatAddress = useOwnDomainAddress();
|
||||
@@ -402,6 +405,12 @@ function EmailCard({
|
||||
{sender?.name || (sender?.email && formatAddress(sender.email)) || t('email_viewer.unknown_sender')}
|
||||
</span>
|
||||
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} interactive={false} />
|
||||
{email.isScheduled && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary shrink-0">
|
||||
<CalendarClock className="w-3.5 h-3.5" />
|
||||
{t('sidebar.scheduled')}
|
||||
</span>
|
||||
)}
|
||||
{isStarred && (
|
||||
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
|
||||
)}
|
||||
@@ -410,7 +419,14 @@ function EmailCard({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDate(emailDisplayDate(email))}
|
||||
{email.isScheduled && email.scheduledSendAt ? (
|
||||
<span className="text-primary font-medium inline-flex items-center gap-1">
|
||||
<CalendarClock className="w-3.5 h-3.5" />
|
||||
{formatScheduledSendAt(email.scheduledSendAt, timeFormat)}
|
||||
</span>
|
||||
) : (
|
||||
formatDate(emailDisplayDate(email))
|
||||
)}
|
||||
</div>
|
||||
{!isExpanded && density !== 'extra-compact' && (
|
||||
<p dir="auto" className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
@@ -430,6 +446,14 @@ function EmailCard({
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border animate-in slide-in-from-top-2 duration-200">
|
||||
{email.isScheduled && (
|
||||
<div className="px-4 py-2 bg-primary/10 border-b border-border flex items-center gap-2 text-primary text-xs font-medium">
|
||||
<CalendarClock className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
{t('email_viewer.scheduled_banner', { date: email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : '' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<PluginSlot name="reader-below-subject" className="w-full" extraProps={plugin.emailProps} />
|
||||
<PluginSlot name="email-banner" className="w-full" extraProps={plugin.emailProps} />
|
||||
<ConversationEmailBody
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cleanPreview, formatDate } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "@/components/icons";
|
||||
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward, CalendarClock } from "@/components/icons";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -174,6 +174,7 @@ export function ThreadEmailItem({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2.5">
|
||||
{email.isScheduled && <CalendarClock className="w-3.5 h-3.5 text-primary" />}
|
||||
{isStarred && <Star className="w-3 h-3 fill-amber-400 text-amber-400" />}
|
||||
{isAnswered && <Reply className="w-3 h-3 text-muted-foreground" />}
|
||||
{isForwarded && <Forward className="w-3 h-3 text-muted-foreground" />}
|
||||
@@ -278,6 +279,9 @@ export function ThreadEmailItem({
|
||||
|
||||
{/* Indicators */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{email.isScheduled && (
|
||||
<CalendarClock className="w-3 h-3 text-primary" />
|
||||
)}
|
||||
{isStarred && (
|
||||
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
|
||||
@@ -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, restoreBatchesFor, restoreBatchesForSelection, TrashMailboxNotFoundError, type EmailRestoreBatch , type CrossAccountMoveReceipt } 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, restoreBatchesFor, restoreBatchesForSelection, TrashMailboxNotFoundError, type EmailRestoreBatch , type CrossAccountMoveReceipt } 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";
|
||||
@@ -2182,27 +2182,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,
|
||||
@@ -2223,8 +2208,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);
|
||||
@@ -4017,8 +4025,46 @@ 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);
|
||||
// As below: a failed refresh must not reach the quick-reply box.
|
||||
try {
|
||||
await refreshCurrentMailbox(client);
|
||||
await refreshInlineRepliedThread();
|
||||
} catch (error) {
|
||||
console.error('Quick reply scheduled, but refreshing the view failed:', error);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4030,34 +4076,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);
|
||||
}
|
||||
@@ -4102,7 +4121,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);
|
||||
@@ -4403,7 +4424,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;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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: {},
|
||||
sourceClientAccountId: 'login-1',
|
||||
sourceAccountId: 'acc-1',
|
||||
};
|
||||
}
|
||||
|
||||
describe('scheduled email annotation in email-store', () => {
|
||||
// Keyed like the store's map: login, account, then the message id.
|
||||
const metadataMap = new Map([
|
||||
['login-1|acc-1|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);
|
||||
});
|
||||
});
|
||||
+16
-3
@@ -714,7 +714,7 @@ function getNextSelectedEmailAfterRemovingRow(
|
||||
return null;
|
||||
}
|
||||
|
||||
function annotateScheduledEmails(
|
||||
export function annotateScheduledEmails(
|
||||
emails: Email[],
|
||||
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
|
||||
): Email[] {
|
||||
@@ -745,14 +745,26 @@ function scheduledSubmissionMap(client: IJMAPClient, emails: ScheduledEmail[]):
|
||||
}]));
|
||||
}
|
||||
|
||||
function annotateScheduledEmail(
|
||||
export function annotateScheduledEmail(
|
||||
email: Email,
|
||||
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
|
||||
): 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;
|
||||
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,
|
||||
@@ -7952,6 +7964,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
scheduledEmails: result.emails,
|
||||
scheduledEmailIds,
|
||||
scheduledSubmissionByEmailId,
|
||||
emails: annotateScheduledEmails(get().emails, scheduledSubmissionByEmailId),
|
||||
scheduledTotal: result.total,
|
||||
scheduledTotalByAccount: result.totalByAccount ?? {},
|
||||
scheduledHasMore: result.hasMore,
|
||||
|
||||
Reference in New Issue
Block a user