diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 63b7a410..a15b3635 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -303,6 +303,7 @@ export default function Home() { } = useEmailStore(); const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); + const delayedSendSupported = client?.hasDelayedSend() ?? true; const activeEmails = isScheduledView ? scheduledEmails : emails; const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails; const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading; @@ -393,6 +394,11 @@ export default function Home() { // Restore mailbox selection. selectMailbox clears the current email, // which is fine because we re-apply the saved email below. if (state.mailboxId === SCHEDULED_MAILBOX_ID) { + if (!ctx.client?.hasDelayedSend()) { + setScheduledView(false); + selectEmail(null); + return; + } setScheduledView(true); selectMailbox(SCHEDULED_MAILBOX_ID); selectEmail(null); @@ -655,6 +661,12 @@ export default function Home() { }, }); + useEffect(() => { + if (!delayedSendSupported && isScheduledView) { + setScheduledView(false); + } + }, [delayedSendSupported, isScheduledView, setScheduledView]); + useEffect(() => { if (!pendingUndoSend) return; const pendingSendTime = new Date(pendingUndoSend.sendAt).getTime(); @@ -1534,6 +1546,10 @@ export default function Home() { const handleMailboxSelect = async (mailboxId: string) => { if (mailboxId === SCHEDULED_MAILBOX_ID) { + if (!delayedSendSupported) { + setScheduledView(false); + return; + } if (isUnifiedView) exitUnifiedView(); setScheduledView(true); selectMailbox(mailboxId); @@ -2326,6 +2342,7 @@ export default function Home() { selectedMailbox={selectedMailbox} selectedKeyword={selectedKeyword} scheduledTotal={scheduledTotal} + showScheduledMailbox={delayedSendSupported} onMailboxSelect={handleMailboxSelect} onTagSelect={handleTagSelect} onUnreadFilterClick={handleUnreadFilterClick} @@ -2630,6 +2647,8 @@ export default function Home() { emails={activeEmails} selectedEmailId={selectedEmail?.id} isLoading={activeIsLoading} + hasMore={activeHasMore} + isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined} isScheduledView={isScheduledView} onLoadMoreScheduled={() => client && loadMoreScheduledEmails(client)} onCancelScheduled={async (email) => { diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 366e4945..1d18ad4a 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -11,6 +11,7 @@ import { NextRequest, NextResponse } from 'next/server'; const ACCOUNT_ID = 'dev-account-001'; const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = []; +const emailCreationIds = new Map(); // --------------------------------------------------------------------------- // Mailboxes @@ -1534,6 +1535,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult { bodyValues: {}, }; emails.unshift(newEmail); + emailCreationIds.set(key, newId); created[key] = { id: newId }; } } @@ -1592,7 +1594,7 @@ function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResul const delayedUntil = Number.isFinite(holdUntilTime) ? new Date(holdUntilTime).toISOString() : undefined; created[key] = { id, ...(delayedUntil ? { sendAt: delayedUntil } : {}) }; if (delayedUntil && value.emailId && value.identityId) { - const emailId = value.emailId.startsWith('#') ? emails[emails.length - 1]?.id || value.emailId : value.emailId; + const emailId = value.emailId.startsWith('#') ? emailCreationIds.get(value.emailId.slice(1)) || value.emailId : value.emailId; scheduledSubmissions.push({ id, emailId, identityId: value.identityId, sendAt: delayedUntil, undoStatus: 'pending' }); } } diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 71a668db..30c0c08d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1441,7 +1441,7 @@ export function EmailComposer({ } // 7. Send via raw email path - const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil); + const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses]); if (effectiveDelayedUntil && finalDraftId) { client.deleteEmail(finalDraftId).catch(err => { debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 4cad5e31..2daa36ee 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -26,6 +26,8 @@ interface EmailListProps { onEmailDoubleClick?: (email: Email) => void; className?: string; isLoading?: boolean; + hasMore?: boolean; + isLoadingMoreItems?: boolean; onOpenConversation?: (thread: ThreadGroup) => void; onReply?: (email: Email) => void; onReplyAll?: (email: Email) => void; @@ -52,6 +54,8 @@ export function EmailList({ onEmailDoubleClick, className, isLoading = false, + hasMore, + isLoadingMoreItems, onOpenConversation, onReply, onReplyAll, @@ -117,6 +121,8 @@ export function EmailList({ const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); const isFocusedMailLayout = mailLayout === 'focus'; + const footerHasMore = hasMore ?? hasMoreEmails; + const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore; const estimateSize = useCallback(() => { if (isFocusedMailLayout) { @@ -488,13 +494,13 @@ export function EmailList({
- {isLoadingMore && hasMoreEmails && ( + {footerIsLoadingMore && footerHasMore && (
{t('loading_more')}
)} - {!hasMoreEmails && emails.length > 0 && ( + {!footerHasMore && emails.length > 0 && (
{t('no_more_emails')}
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index ab95be8e..a9ed0cfd 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -74,6 +74,7 @@ interface SidebarProps { onImportEmail?: (mailboxId: string) => void; onRefreshMailboxes?: () => void; scheduledTotal?: number; + showScheduledMailbox?: boolean; className?: string; } @@ -661,6 +662,7 @@ export function Sidebar({ onImportEmail, onRefreshMailboxes, scheduledTotal = 0, + showScheduledMailbox = false, className, }: SidebarProps) { const router = useRouter(); @@ -966,15 +968,17 @@ export function Sidebar({ onContextMenu={handleMailboxContextMenu} /> ))} - } - label={t('scheduled')} - depth={0} - isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'} - total={scheduledTotal} - onClick={() => onMailboxSelect?.('__scheduled__')} - isCollapsed={isCollapsed} - /> + {showScheduledMailbox && ( + } + label={t('scheduled')} + depth={0} + isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'} + total={scheduledTotal} + onClick={() => onMailboxSelect?.('__scheduled__')} + isCollapsed={isCollapsed} + /> + )} )} diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 23a21e94..a081d699 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -925,7 +925,7 @@ export class DemoJMAPClient implements IJMAPClient { async importRawEmail(): Promise { return generateDemoId('email'); } async submitEmail(): Promise { /* no-op */ } - async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string): Promise { + async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise { const emailId = generateDemoId('email'); const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts'); const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent'); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 36f276ac..fe7a1f04 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -152,7 +152,7 @@ export interface IJMAPClient { envelopeMailFrom?: string, ): Promise; - sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string): Promise; + sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise; getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number }>; cancelEmailSubmission(submissionId: string): Promise; rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index c10c7e2e..2b1b2284 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -372,8 +372,17 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string { return name.replace(/\s*<[^>]*>\s*$/, '').trim(); } -function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: number): Record | undefined { +function normalizeEnvelopeRecipients(recipients?: Array): 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): Record | undefined { if (!holdForSeconds) return undefined; + const rcptTo = normalizeEnvelopeRecipients(recipients); return { mailFrom: { email: fromEmail, @@ -381,6 +390,7 @@ function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: num HOLDFOR: String(holdForSeconds), }, }, + rcptTo, }; } @@ -3012,6 +3022,18 @@ export class JMAPClient implements IJMAPClient { 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; } @@ -5500,6 +5522,7 @@ export class JMAPClient implements IJMAPClient { sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, + envelopeRecipients?: string[], ): Promise { const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; // Upload the raw message @@ -5511,7 +5534,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, holdForSeconds); + const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients); const methodCalls: [string, Record, string][] = [ ['Email/import', { @@ -5690,7 +5713,12 @@ export class JMAPClient implements IJMAPClient { 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, holdForSeconds); + 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(), diff --git a/stores/email-store.ts b/stores/email-store.ts index dacabdb3..d9f9e37f 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -101,7 +101,7 @@ interface EmailStore { fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise; fetchQuota: (client: IJMAPClient) => Promise; sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string) => Promise; - sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string) => Promise; + sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise; deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise; moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; @@ -673,14 +673,14 @@ export const useEmailStore = create((set, get) => ({ } }, - sendRawEmail: async (client, rawMimeBlob, identityId, delayedUntil) => { + sendRawEmail: async (client, rawMimeBlob, identityId, delayedUntil, envelopeRecipients) => { set({ isLoading: true, error: null }); try { const mailboxes = await client.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) throw new Error('No sent mailbox found'); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); - const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil); + const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil, envelopeRecipients); set({ isLoading: false, pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt @@ -2095,10 +2095,15 @@ export const useEmailStore = create((set, get) => ({ }); }, - setScheduledView: (isScheduledView) => set(state => ({ - isScheduledView, - selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : state.selectedMailbox, - })), + setScheduledView: (isScheduledView) => set(state => { + const leavingScheduled = !isScheduledView && state.selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID; + return { + isScheduledView, + selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox, + selectedEmail: leavingScheduled ? null : state.selectedEmail, + selectedEmailIds: leavingScheduled ? new Set() : state.selectedEmailIds, + }; + }), clearPendingUndoSend: () => set({ pendingUndoSend: null }), fetchScheduledEmails: async (client) => { @@ -2191,6 +2196,10 @@ export const useEmailStore = create((set, get) => ({ await client.cancelEmailSubmission(submissionId); if (emailId) { await client.deleteEmail(emailId); + set(state => ({ + selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail, + selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== emailId)), + })); } if (get().pendingUndoSend?.submissionId === submissionId) { set({ pendingUndoSend: null }); @@ -2214,8 +2223,9 @@ export const useEmailStore = create((set, get) => ({ }, rescheduleScheduledEmail: async (client, submissionId, emailId, identityId, delayedUntil) => { + let result: SendEmailResult | undefined; try { - const result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, delayedUntil); + result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, delayedUntil); const pendingUndoSend = get().pendingUndoSend; if (pendingUndoSend?.submissionId === submissionId) { set({ pendingUndoSend: { ...pendingUndoSend, submissionId: result.emailSubmissionId || submissionId, sendAt: result.sendAt || delayedUntil } }); @@ -2223,6 +2233,19 @@ export const useEmailStore = create((set, get) => ({ return result; } finally { await get().fetchScheduledEmails(client); + if (result && get().selectedEmail?.id === emailId) { + const refreshed = get().scheduledEmails.find(email => email.id === emailId); + set(state => ({ + selectedEmail: refreshed || (state.selectedEmail ? { + ...state.selectedEmail, + emailSubmissionId: result?.emailSubmissionId || submissionId, + scheduledSendAt: result?.sendAt || delayedUntil, + scheduledIdentityId: identityId, + scheduledUndoStatus: 'pending' as const, + isScheduled: true, + } : state.selectedEmail), + })); + } } },