fixes from review
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user