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:
Linus Rath
2026-10-28 17:14:23 +01:00
6 changed files with 259 additions and 57 deletions
+22
View File
@@ -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">&lt;{sender.email}&gt;</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
+25 -1
View File
@@ -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
+5 -1
View File
@@ -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" />
)}
+82 -52
View File
@@ -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
View File
@@ -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,