fixes from review

This commit is contained in:
Lucas Gaitzsch
2026-05-22 12:21:32 +02:00
parent dd322d4c9d
commit 7dccc2f432
9 changed files with 108 additions and 26 deletions
+19
View File
@@ -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) => {
+3 -1
View File
@@ -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<string, string>();
// ---------------------------------------------------------------------------
// 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' });
}
}
+1 -1
View File
@@ -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);
+8 -2
View File
@@ -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({
</div>
<div className="py-4 flex justify-center">
{isLoadingMore && hasMoreEmails && (
{footerIsLoadingMore && footerHasMore && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{t('loading_more')}</span>
</div>
)}
{!hasMoreEmails && emails.length > 0 && (
{!footerHasMore && emails.length > 0 && (
<div className="text-sm text-muted-foreground border-t border-border pt-6">
{t('no_more_emails')}
</div>
+13 -9
View File
@@ -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}
/>
))}
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
/>
{showScheduledMailbox && (
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
/>
)}
</>
)}
</>
+1 -1
View File
@@ -925,7 +925,7 @@ export class DemoJMAPClient implements IJMAPClient {
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
async submitEmail(): Promise<void> { /* no-op */ }
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string): Promise<SendEmailResult> {
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise<SendEmailResult> {
const emailId = generateDemoId('email');
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent');
+1 -1
View File
@@ -152,7 +152,7 @@ export interface IJMAPClient {
envelopeMailFrom?: string,
): Promise<SendEmailResult>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string): Promise<SendEmailResult>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number }>;
cancelEmailSubmission(submissionId: string): Promise<void>;
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
+31 -3
View File
@@ -372,8 +372,17 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string {
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
}
function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: number): Record<string, unknown> | undefined {
function normalizeEnvelopeRecipients(recipients?: Array<string | EmailAddress>): 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<string | EmailAddress>): Record<string, unknown> | 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<SendEmailResult> {
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, unknown>, 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(),
+31 -8
View File
@@ -101,7 +101,7 @@ interface EmailStore {
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>;
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<SendEmailResult>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string) => Promise<SendEmailResult>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
@@ -673,14 +673,14 @@ export const useEmailStore = create<EmailStore>((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<EmailStore>((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<string>() : state.selectedEmailIds,
};
}),
clearPendingUndoSend: () => set({ pendingUndoSend: null }),
fetchScheduledEmails: async (client) => {
@@ -2191,6 +2196,10 @@ export const useEmailStore = create<EmailStore>((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<EmailStore>((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<EmailStore>((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),
}));
}
}
},