Feat: Scheduled send and send delay #322
* ADD DOC * Scheduld Send * add new shortcuts * fix * fix * fix bugs * rework * fix draft duplicating * fix err * some fixes * fixes from review * fixes from review * fixes from review * disable password managers for recipients * fix email store lazy load * add translations * fix styling * fixes --------- Co-authored-by: Linus Rath <139418639+rathlinus@users.noreply.github.com>
This commit is contained in:
co-authored by
Linus Rath
parent
82be047708
commit
31e96d6a46
+355
-84
@@ -75,6 +75,8 @@ import { buildQuoteHeader } from "@/lib/quote-header";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||
|
||||
const SCHEDULED_MAILBOX_ID = '__scheduled__';
|
||||
|
||||
|
||||
export default function Home() {
|
||||
const t = useTranslations();
|
||||
@@ -108,12 +110,39 @@ export default function Home() {
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastUndoToastSubmissionRef = useRef<string | null>(null);
|
||||
const initialMailLoadClientRef = useRef<object | null>(null);
|
||||
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||
const { identities } = useIdentityStore();
|
||||
useIdentitySync();
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
|
||||
|
||||
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
|
||||
const value = window.prompt(t('email_viewer.reschedule_prompt'));
|
||||
if (!value) return null;
|
||||
const time = new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) {
|
||||
toast.error(t('email_composer.schedule_send_invalid'));
|
||||
return null;
|
||||
}
|
||||
if (time <= Date.now()) {
|
||||
toast.error(t('email_composer.schedule_send_future'));
|
||||
return null;
|
||||
}
|
||||
if (!client?.hasDelayedSend()) {
|
||||
toast.error(t('email_composer.schedule_send_unsupported'));
|
||||
return null;
|
||||
}
|
||||
const maxDelayedSend = client.getMaxDelayedSend();
|
||||
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
|
||||
toast.error(t('email_composer.schedule_send_too_late'));
|
||||
return null;
|
||||
}
|
||||
return new Date(time).toISOString();
|
||||
}, [client, t]);
|
||||
|
||||
// Load trusted senders address book when feature is enabled
|
||||
useEffect(() => {
|
||||
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
|
||||
@@ -270,6 +299,21 @@ export default function Home() {
|
||||
fetchTagCounts,
|
||||
fetchEmailContent,
|
||||
isUnifiedView,
|
||||
scheduledEmails,
|
||||
scheduledTotal,
|
||||
scheduledHasMore,
|
||||
isLoadingScheduled,
|
||||
isScheduledView,
|
||||
setScheduledView,
|
||||
fetchScheduledEmails,
|
||||
loadMoreScheduledEmails,
|
||||
cancelScheduledEmail,
|
||||
cancelScheduledEmailForEdit,
|
||||
rescheduleScheduledEmail,
|
||||
refreshScheduledMetadata,
|
||||
cancelUndoSend,
|
||||
clearPendingUndoSend,
|
||||
pendingUndoSend,
|
||||
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||
refreshUnifiedCounts,
|
||||
exitUnifiedView,
|
||||
@@ -294,6 +338,10 @@ export default function Home() {
|
||||
useProMultiAccountMailboxes();
|
||||
|
||||
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;
|
||||
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const connectedAccountsSignature = useMemo(
|
||||
@@ -329,7 +377,7 @@ export default function Home() {
|
||||
conversationThreadId: null as string | null,
|
||||
});
|
||||
navRestoreStateRef.current.client = client;
|
||||
navRestoreStateRef.current.emails = emails;
|
||||
navRestoreStateRef.current.emails = activeEmails;
|
||||
navRestoreStateRef.current.mailboxes = mailboxes;
|
||||
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
|
||||
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
|
||||
@@ -355,7 +403,24 @@ 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 && state.mailboxId !== ctx.selectedMailbox) {
|
||||
if (state.mailboxId === SCHEDULED_MAILBOX_ID) {
|
||||
if (!ctx.client?.hasDelayedSend()) {
|
||||
setScheduledView(false);
|
||||
selectEmail(null);
|
||||
return;
|
||||
}
|
||||
setScheduledView(true);
|
||||
selectMailbox(SCHEDULED_MAILBOX_ID);
|
||||
selectEmail(null);
|
||||
if (ctx.client) {
|
||||
try {
|
||||
await fetchScheduledEmails(ctx.client);
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch scheduled emails on history restore:', error);
|
||||
}
|
||||
}
|
||||
} else if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
|
||||
setScheduledView(false);
|
||||
selectMailbox(state.mailboxId);
|
||||
if (ctx.client) {
|
||||
try {
|
||||
@@ -416,19 +481,19 @@ export default function Home() {
|
||||
// Keyboard shortcuts handlers
|
||||
const keyboardHandlers = useMemo(() => ({
|
||||
onNextEmail: () => {
|
||||
if (emails.length === 0) return;
|
||||
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||
const nextIndex = currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
|
||||
if (nextIndex >= 0 && nextIndex < emails.length) {
|
||||
handleEmailSelect(emails[nextIndex]);
|
||||
if (activeEmails.length === 0) return;
|
||||
const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||
const nextIndex = currentIndex < activeEmails.length - 1 ? currentIndex + 1 : currentIndex;
|
||||
if (nextIndex >= 0 && nextIndex < activeEmails.length) {
|
||||
handleEmailSelect(activeEmails[nextIndex]);
|
||||
}
|
||||
},
|
||||
onPreviousEmail: () => {
|
||||
if (emails.length === 0) return;
|
||||
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : emails.length;
|
||||
if (activeEmails.length === 0) return;
|
||||
const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : activeEmails.length;
|
||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
|
||||
if (prevIndex >= 0 && prevIndex < emails.length) {
|
||||
handleEmailSelect(emails[prevIndex]);
|
||||
if (prevIndex >= 0 && prevIndex < activeEmails.length) {
|
||||
handleEmailSelect(activeEmails[prevIndex]);
|
||||
}
|
||||
},
|
||||
onOpenEmail: () => {
|
||||
@@ -444,18 +509,23 @@ export default function Home() {
|
||||
}
|
||||
},
|
||||
onReply: () => {
|
||||
if (isScheduledView) return;
|
||||
if (selectedEmail) handleReply();
|
||||
},
|
||||
onReplyAll: () => {
|
||||
if (isScheduledView) return;
|
||||
if (selectedEmail) handleReplyAll();
|
||||
},
|
||||
onForward: () => {
|
||||
if (isScheduledView) return;
|
||||
if (selectedEmail) handleForward();
|
||||
},
|
||||
onToggleStar: () => {
|
||||
if (isScheduledView) return;
|
||||
if (selectedEmail) handleToggleStar();
|
||||
},
|
||||
onArchive: async () => {
|
||||
if (isScheduledView) return;
|
||||
if (selectedEmailIds.size > 0 && client) {
|
||||
try {
|
||||
await batchArchive(client);
|
||||
@@ -467,6 +537,7 @@ export default function Home() {
|
||||
}
|
||||
},
|
||||
onDelete: async () => {
|
||||
if (isScheduledView) return;
|
||||
if (selectedEmailIds.size > 0 && client) {
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
const isInTrash = currentMailbox?.role === 'trash';
|
||||
@@ -496,6 +567,7 @@ export default function Home() {
|
||||
}
|
||||
},
|
||||
onMarkAsUnread: async () => {
|
||||
if (isScheduledView) return;
|
||||
if (!client) return;
|
||||
if (selectedEmailIds.size > 0) {
|
||||
await batchMarkAsRead(client, false);
|
||||
@@ -504,6 +576,7 @@ export default function Home() {
|
||||
}
|
||||
},
|
||||
onMarkAsRead: async () => {
|
||||
if (isScheduledView) return;
|
||||
if (!client) return;
|
||||
if (selectedEmailIds.size > 0) {
|
||||
await batchMarkAsRead(client, true);
|
||||
@@ -512,6 +585,7 @@ export default function Home() {
|
||||
}
|
||||
},
|
||||
onToggleSpam: async () => {
|
||||
if (isScheduledView) return;
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
const isInJunk = currentMailbox?.role === 'junk';
|
||||
if (selectedEmailIds.size > 0 && client) {
|
||||
@@ -539,6 +613,7 @@ export default function Home() {
|
||||
if (isMobile) setActiveView('viewer');
|
||||
},
|
||||
onFocusSearch: () => {
|
||||
if (isScheduledView) return;
|
||||
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
@@ -550,22 +625,27 @@ export default function Home() {
|
||||
},
|
||||
onRefresh: async () => {
|
||||
if (client && selectedMailbox) {
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
if (selectedMailbox === SCHEDULED_MAILBOX_ID) {
|
||||
await fetchScheduledEmails(client);
|
||||
} else {
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectAll: () => {
|
||||
if (isScheduledView) return;
|
||||
selectAllEmails();
|
||||
},
|
||||
onDeselectAll: () => {
|
||||
clearSelection();
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]);
|
||||
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes, isScheduledView]);
|
||||
|
||||
// Initialize keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
enabled: isAuthenticated && !showComposer,
|
||||
emails,
|
||||
emails: activeEmails,
|
||||
selectedEmailId: selectedEmail?.id,
|
||||
selectionCount: selectedEmailIds.size,
|
||||
handlers: keyboardHandlers,
|
||||
@@ -578,13 +658,36 @@ export default function Home() {
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
const state = useEmailStore.getState();
|
||||
await Promise.all([
|
||||
state.fetchMailboxes(client),
|
||||
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client),
|
||||
]);
|
||||
if (state.isScheduledView || state.selectedMailbox === SCHEDULED_MAILBOX_ID) {
|
||||
await Promise.all([
|
||||
state.fetchMailboxes(client),
|
||||
state.fetchScheduledEmails(client),
|
||||
]);
|
||||
} else {
|
||||
await state.fetchMailboxes(client);
|
||||
await state.refreshScheduledMetadata(client);
|
||||
await (state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!delayedSendSupported && isScheduledView) {
|
||||
setScheduledView(false);
|
||||
}
|
||||
}, [delayedSendSupported, isScheduledView, setScheduledView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUndoSend) return;
|
||||
const pendingSendTime = new Date(pendingUndoSend.sendAt).getTime();
|
||||
if (!Number.isFinite(pendingSendTime) || pendingSendTime <= Date.now()) {
|
||||
clearPendingUndoSend();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(clearPendingUndoSend, pendingSendTime - Date.now());
|
||||
return () => clearTimeout(timer);
|
||||
}, [clearPendingUndoSend, pendingUndoSend]);
|
||||
|
||||
// Update page title based on context
|
||||
useEffect(() => {
|
||||
let title = appName;
|
||||
@@ -788,50 +891,61 @@ export default function Home() {
|
||||
}, [isAuthenticated, client, handleMailtoProtocolRequest]);
|
||||
|
||||
// Fallback fetch for paths that didn't go through login()'s prefetch
|
||||
// (notably checkAuth on page refresh). The prefetch in auth-store/login()
|
||||
// populates mailboxes before this effect first runs, so on the post-login
|
||||
// path this block is a no-op.
|
||||
// (notably checkAuth on page refresh). Settings pages can also prefill
|
||||
// mailboxes without emails, so bootstrap emails once per client when the
|
||||
// mail route mounts even if mailbox data is already present.
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && client && mailboxes.length === 0) {
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const loadData = async (attempt = 1) => {
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchMailboxes(client),
|
||||
fetchQuota(client)
|
||||
]);
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
const selectedMailboxId = state.selectedMailbox;
|
||||
|
||||
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
|
||||
const delay = Math.min(1000 * attempt, 5000);
|
||||
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
|
||||
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMailboxId) {
|
||||
await fetchEmails(client, selectedMailboxId);
|
||||
} else {
|
||||
await fetchEmails(client);
|
||||
}
|
||||
|
||||
fetchTagCounts(client);
|
||||
} catch (error) {
|
||||
console.error('Error loading email data:', error);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
};
|
||||
if (!isAuthenticated || !client) {
|
||||
initialMailLoadClientRef.current = null;
|
||||
return;
|
||||
}
|
||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]);
|
||||
|
||||
if (initialMailLoadClientRef.current === client) return;
|
||||
initialMailLoadClientRef.current = client;
|
||||
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const loadData = async (attempt = 1) => {
|
||||
try {
|
||||
const needsMailboxes = useEmailStore.getState().mailboxes.length === 0;
|
||||
await Promise.all([
|
||||
needsMailboxes ? fetchMailboxes(client) : Promise.resolve(),
|
||||
fetchQuota(client)
|
||||
]);
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
const selectedMailboxId = state.selectedMailbox;
|
||||
|
||||
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
|
||||
const delay = Math.min(1000 * attempt, 5000);
|
||||
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
|
||||
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshScheduledMetadata(client);
|
||||
|
||||
// Fetch emails for the selected mailbox after scheduled metadata is available.
|
||||
if (selectedMailboxId) {
|
||||
await fetchEmails(client, selectedMailboxId);
|
||||
} else {
|
||||
await fetchEmails(client);
|
||||
}
|
||||
|
||||
fetchTagCounts(client);
|
||||
} catch (error) {
|
||||
console.error('Error loading email data:', error);
|
||||
initialMailLoadClientRef.current = null;
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
};
|
||||
}, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
|
||||
|
||||
// Push notifications: set up once per client and tear down when the client
|
||||
// goes away (logout or account switch). Kept separate from the fetch effect
|
||||
@@ -938,7 +1052,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
// Only set timeout if there's a selected email, it's unread, and we have a client
|
||||
if (!selectedEmail || !client || selectedEmail.keywords?.$seen) {
|
||||
if (!selectedEmail || !client || selectedEmail.keywords?.$seen || isScheduledView) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -972,7 +1086,7 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedEmail?.id]);
|
||||
}, [selectedEmail?.id, isScheduledView]);
|
||||
|
||||
// Handle new email notifications - play sound
|
||||
useEffect(() => {
|
||||
@@ -1013,6 +1127,7 @@ export default function Home() {
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
delayedUntil?: string;
|
||||
}) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -1020,8 +1135,13 @@ export default function Home() {
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
const originalEmailId = selectedEmail?.id;
|
||||
|
||||
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.envelopeMailFrom);
|
||||
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom);
|
||||
setShowComposer(false);
|
||||
if (result.scheduled) {
|
||||
await refreshScheduledMetadata(client);
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the original email with $answered or $forwarded keyword
|
||||
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
|
||||
@@ -1039,7 +1159,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
// Refresh the current mailbox to update the UI
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
if (!isScheduledView) await fetchEmails(client, selectedMailbox);
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
}
|
||||
@@ -1169,6 +1289,45 @@ export default function Home() {
|
||||
if (isMobile) setActiveView('viewer');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUndoSend || !client) return;
|
||||
if (lastUndoToastSubmissionRef.current === pendingUndoSend.submissionId) return;
|
||||
|
||||
lastUndoToastSubmissionRef.current = pendingUndoSend.submissionId;
|
||||
const pending = pendingUndoSend;
|
||||
const undoDurationMs = Math.max(sendDelaySeconds, 8) * 1000;
|
||||
|
||||
toast.success(t('email_viewer.scheduled_send_created'), {
|
||||
duration: undoDurationMs,
|
||||
action: {
|
||||
label: t('email_viewer.undo_send'),
|
||||
onClick: () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const restored = await cancelUndoSend(client, pending);
|
||||
if (restored && !pending.isSmime) {
|
||||
await handleEditDraft(restored);
|
||||
}
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
} catch (error) {
|
||||
console.error('Failed to undo scheduled send:', error);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const current = useEmailStore.getState().pendingUndoSend;
|
||||
if (current?.submissionId === pending.submissionId) {
|
||||
clearPendingUndoSend();
|
||||
}
|
||||
}, undoDurationMs);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t]);
|
||||
|
||||
const handleReplyAll = async () => {
|
||||
if (selectedEmail) {
|
||||
const ok = await emailHooks.onBeforeReplyAll.intercept({
|
||||
@@ -1435,7 +1594,29 @@ 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);
|
||||
selectEmail(null);
|
||||
clearSelection();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView("list");
|
||||
}
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
}
|
||||
if (client) await fetchScheduledEmails(client);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnifiedMailboxId(mailboxId)) {
|
||||
setScheduledView(false);
|
||||
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
||||
if (!role) return;
|
||||
|
||||
@@ -1459,6 +1640,7 @@ export default function Home() {
|
||||
if (isUnifiedView) {
|
||||
exitUnifiedView();
|
||||
}
|
||||
setScheduledView(false);
|
||||
|
||||
selectMailbox(mailboxId);
|
||||
selectEmail(null); // Clear selected email when switching mailboxes
|
||||
@@ -1485,6 +1667,7 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const handleTagSelect = async (keywordId: string | null) => {
|
||||
setScheduledView(false);
|
||||
selectKeyword(keywordId);
|
||||
|
||||
// On mobile, close sidebar and go to list view
|
||||
@@ -1899,6 +2082,16 @@ export default function Home() {
|
||||
});
|
||||
|
||||
const originalEmailId = selectedEmail.id;
|
||||
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
|
||||
let delayedUntil: string | undefined;
|
||||
if (sendDelaySeconds > 0) {
|
||||
if (!client.hasDelayedSend()) {
|
||||
const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm'));
|
||||
if (!confirmed) return;
|
||||
} else {
|
||||
delayedUntil = new Date(Date.now() + sendDelaySeconds * 1000).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234).
|
||||
const threading = computeReplyThreadingHeaders({
|
||||
@@ -1907,7 +2100,7 @@ export default function Home() {
|
||||
});
|
||||
|
||||
// Send reply with just the body text
|
||||
await sendEmail(
|
||||
const result = await sendEmail(
|
||||
client,
|
||||
[sender.email],
|
||||
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
||||
@@ -1922,9 +2115,15 @@ export default function Home() {
|
||||
undefined,
|
||||
threading?.inReplyTo,
|
||||
threading?.references,
|
||||
delayedUntil,
|
||||
envelopeMailFrom,
|
||||
);
|
||||
|
||||
if (result.scheduled) {
|
||||
await refreshScheduledMetadata(client);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the original email as answered
|
||||
try {
|
||||
await client.setKeyword(originalEmailId, '$answered');
|
||||
@@ -1949,7 +2148,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
// Get current mailbox name for mobile header
|
||||
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||
const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||
@@ -1966,9 +2165,8 @@ export default function Home() {
|
||||
setShowComposer(false);
|
||||
}
|
||||
|
||||
// Show the list stub immediately so subject/sender render without
|
||||
// waiting for the body fetch - avoids the loading flicker.
|
||||
const listEmail = emails.find(e => e.id === email.id);
|
||||
const listEmail = activeEmails.find(e => e.id === email.id);
|
||||
if (listEmail) {
|
||||
selectEmail(listEmail);
|
||||
}
|
||||
@@ -2005,6 +2203,14 @@ export default function Home() {
|
||||
|
||||
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||
if (fullEmail) {
|
||||
if (listEmail?.isScheduled) {
|
||||
fullEmail.scheduledSendAt = listEmail.scheduledSendAt;
|
||||
fullEmail.emailSubmissionId = listEmail.emailSubmissionId;
|
||||
fullEmail.scheduledIdentityId = listEmail.scheduledIdentityId;
|
||||
fullEmail.scheduledUndoStatus = listEmail.scheduledUndoStatus;
|
||||
fullEmail.isScheduled = true;
|
||||
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
|
||||
}
|
||||
if (emailAccountId) {
|
||||
fullEmail.accountId = emailAccountId;
|
||||
fullEmail.accountLabel = listEmail?.accountLabel;
|
||||
@@ -2037,14 +2243,14 @@ export default function Home() {
|
||||
};
|
||||
|
||||
// Navigate to next/previous email in the list
|
||||
const selectedEmailIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||
const selectedEmailIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||
|
||||
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < emails.length - 1
|
||||
? () => handleEmailSelect(emails[selectedEmailIndex + 1])
|
||||
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < activeEmails.length - 1
|
||||
? () => handleEmailSelect(activeEmails[selectedEmailIndex + 1])
|
||||
: undefined;
|
||||
|
||||
const handleNavigatePrev = selectedEmailIndex > 0
|
||||
? () => handleEmailSelect(emails[selectedEmailIndex - 1])
|
||||
? () => handleEmailSelect(activeEmails[selectedEmailIndex - 1])
|
||||
: undefined;
|
||||
|
||||
// Handle opening conversation view on mobile
|
||||
@@ -2199,6 +2405,8 @@ export default function Home() {
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
selectedKeyword={selectedKeyword}
|
||||
scheduledTotal={scheduledTotal}
|
||||
showScheduledMailbox={delayedSendSupported}
|
||||
onMailboxSelect={handleMailboxSelect}
|
||||
onTagSelect={handleTagSelect}
|
||||
onUnreadFilterClick={handleUnreadFilterClick}
|
||||
@@ -2280,26 +2488,27 @@ export default function Home() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedEmailIds.size > 0) {
|
||||
if (selectedEmailIds.size === emails.length) {
|
||||
if (selectedEmailIds.size === activeEmails.length) {
|
||||
clearSelection();
|
||||
} else {
|
||||
selectAllEmails();
|
||||
}
|
||||
} else if (emails.length > 0) {
|
||||
} else if (activeEmails.length > 0) {
|
||||
const currentId = selectedEmail?.id;
|
||||
const target = currentId && emails.some((e) => e.id === currentId)
|
||||
const target = currentId && activeEmails.some((e) => e.id === currentId)
|
||||
? currentId
|
||||
: emails[0].id;
|
||||
: activeEmails[0].id;
|
||||
toggleEmailSelection(target);
|
||||
}
|
||||
}}
|
||||
disabled={isScheduledView}
|
||||
className={cn(
|
||||
"flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
selectedEmailIds.size > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={selectedEmailIds.size > 0 ? (selectedEmailIds.size === emails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
|
||||
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : selectedEmailIds.size > 0 ? (selectedEmailIds.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
|
||||
>
|
||||
{selectedEmailIds.size > 0 ? (
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
@@ -2317,6 +2526,8 @@ export default function Home() {
|
||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||
data-search-input
|
||||
data-tour="search-input"
|
||||
disabled={isUnifiedView || isScheduledView}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -2332,13 +2543,15 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAdvancedSearch}
|
||||
disabled={isUnifiedView || isScheduledView}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
|
||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={t("advanced_search.toggle_filters")}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||
@@ -2480,11 +2693,17 @@ export default function Home() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(searchQuery || !isFilterEmpty(searchFilters)) && !isLoading && (
|
||||
{(searchQuery || !isFilterEmpty(searchFilters)) && !activeIsLoading && !isScheduledView && (
|
||||
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
|
||||
{hasMoreEmails
|
||||
? t("advanced_search.results_found_more", { count: emails.length })
|
||||
: t("advanced_search.results_found", { count: emails.length })}
|
||||
{activeHasMore
|
||||
? t("advanced_search.results_found_more", { count: activeEmails.length })
|
||||
: t("advanced_search.results_found", { count: activeEmails.length })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScheduledView && !activeIsLoading && (
|
||||
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
|
||||
{t('email_list.scheduled_count', { count: scheduledTotal })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2493,9 +2712,35 @@ export default function Home() {
|
||||
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
<EmailList
|
||||
emails={emails}
|
||||
emails={activeEmails}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
isLoading={isLoading}
|
||||
isLoading={activeIsLoading}
|
||||
hasMore={activeHasMore}
|
||||
isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined}
|
||||
isScheduledView={isScheduledView}
|
||||
onLoadMoreScheduled={() => client && loadMoreScheduledEmails(client)}
|
||||
onCancelScheduled={async (email) => {
|
||||
if (client && email.emailSubmissionId) await cancelScheduledEmail(client, email.emailSubmissionId, email.id);
|
||||
}}
|
||||
onCancelScheduledForEdit={async (email) => {
|
||||
if (!client) return;
|
||||
const restored = await cancelScheduledEmailForEdit(client, email);
|
||||
if (email.isSmimeScheduled) {
|
||||
setComposerMode('compose');
|
||||
setPendingDraft(null);
|
||||
} else if (restored) {
|
||||
await handleEditDraft(restored);
|
||||
return;
|
||||
}
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
}}
|
||||
onRescheduleScheduled={async (email) => {
|
||||
const delayedUntil = promptForRescheduleDelayedUntil();
|
||||
if (delayedUntil && client && email.emailSubmissionId && email.scheduledIdentityId) {
|
||||
await rescheduleScheduledEmail(client, email.emailSubmissionId, email.id, email.scheduledIdentityId, delayedUntil);
|
||||
}
|
||||
}}
|
||||
onEmailSelect={handleEmailSelect}
|
||||
onEmailDoubleClick={isEmbedded ? ((email) => {
|
||||
useProTabStore.getState().openEmailTab({
|
||||
@@ -2656,6 +2901,14 @@ export default function Home() {
|
||||
await handleEmailSend(data);
|
||||
setPendingDraft(null);
|
||||
}}
|
||||
onScheduledSendCreated={async () => {
|
||||
if (client) {
|
||||
await refreshScheduledMetadata(client);
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
}
|
||||
setShowComposer(false);
|
||||
setPendingDraft(null);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
@@ -2751,6 +3004,24 @@ export default function Home() {
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onEditDraft={handleEditDraft}
|
||||
onCancelScheduled={async () => {
|
||||
if (client && selectedEmail?.emailSubmissionId) await cancelScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id);
|
||||
}}
|
||||
onCancelScheduledForEdit={async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
const restored = await cancelScheduledEmailForEdit(client, selectedEmail);
|
||||
if (selectedEmail.isSmimeScheduled) {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
return;
|
||||
}
|
||||
if (restored) await handleEditDraft(restored);
|
||||
}}
|
||||
onRescheduleScheduled={async (delayedUntil) => {
|
||||
if (client && selectedEmail?.emailSubmissionId && selectedEmail.scheduledIdentityId) {
|
||||
await rescheduleScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id, selectedEmail.scheduledIdentityId, delayedUntil);
|
||||
}
|
||||
}}
|
||||
onCompose={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
|
||||
@@ -10,6 +10,8 @@ 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
|
||||
@@ -1533,6 +1535,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
|
||||
bodyValues: {},
|
||||
};
|
||||
emails.unshift(newEmail);
|
||||
emailCreationIds.set(key, newId);
|
||||
created[key] = { id: newId };
|
||||
}
|
||||
}
|
||||
@@ -1575,8 +1578,51 @@ function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
|
||||
return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult {
|
||||
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: { 'sub-1': { id: 'sub-mock-1' } }, notCreated: null }, callId];
|
||||
function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult {
|
||||
const created: Record<string, { id: string; sendAt?: string }> = {};
|
||||
const updated: Record<string, null> = {};
|
||||
const create = args.create as Record<string, { emailId?: string; identityId?: string; envelope?: { mailFrom?: { parameters?: { HOLDFOR?: string; HOLDUNTIL?: string } } } }> | undefined;
|
||||
if (create) {
|
||||
for (const [key, value] of Object.entries(create)) {
|
||||
const id = `submission-${Date.now()}-${key}`;
|
||||
const holdFor = value.envelope?.mailFrom?.parameters?.HOLDFOR;
|
||||
const holdUntil = value.envelope?.mailFrom?.parameters?.HOLDUNTIL;
|
||||
const holdForSeconds = holdFor ? Number(holdFor) : Number.NaN;
|
||||
const holdUntilTime = Number.isFinite(holdForSeconds) && holdForSeconds > 0
|
||||
? Date.now() + holdForSeconds * 1000
|
||||
: holdUntil ? new Date(holdUntil).getTime() : Number.NaN;
|
||||
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('#') ? emailCreationIds.get(value.emailId.slice(1)) || value.emailId : value.emailId;
|
||||
scheduledSubmissions.push({ id, emailId, identityId: value.identityId, sendAt: delayedUntil, undoStatus: 'pending' });
|
||||
}
|
||||
}
|
||||
}
|
||||
const update = args.update as Record<string, { undoStatus?: 'pending' | 'final' | 'canceled' }> | undefined;
|
||||
if (update) {
|
||||
for (const [id, patch] of Object.entries(update)) {
|
||||
const submission = scheduledSubmissions.find(s => s.id === id);
|
||||
if (submission && patch.undoStatus) {
|
||||
submission.undoStatus = patch.undoStatus;
|
||||
updated[id] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, notCreated: null, notUpdated: null }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionQuery(args: MethodArgs, callId: string): MethodResult {
|
||||
const position = Number(args.position || 0);
|
||||
const limit = Number(args.limit || 50);
|
||||
const submissions = [...scheduledSubmissions].sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
|
||||
return ['EmailSubmission/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: submissions.slice(position, position + limit).map(s => s.id), total: submissions.length, position, canCalculateChanges: false }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResult {
|
||||
const ids = args.ids as string[] | undefined;
|
||||
const list = ids ? scheduledSubmissions.filter(s => ids.includes(s.id)) : scheduledSubmissions;
|
||||
return ['EmailSubmission/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||
}
|
||||
|
||||
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
|
||||
@@ -1639,6 +1685,8 @@ const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => Meth
|
||||
'Identity/get': handleIdentityGet,
|
||||
'Identity/set': handleIdentitySet,
|
||||
'EmailSubmission/set': handleEmailSubmissionSet,
|
||||
'EmailSubmission/query': handleEmailSubmissionQuery,
|
||||
'EmailSubmission/get': handleEmailSubmissionGet,
|
||||
'Quota/get': handleQuotaGet,
|
||||
'VacationResponse/get': handleVacationResponseGet,
|
||||
'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId],
|
||||
@@ -1776,7 +1824,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
isReadOnly: false,
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': {},
|
||||
'urn:ietf:params:jmap:submission': { maxDelayedSend: 2592000, submissionExtensions: { FUTURERELEASE: true } },
|
||||
'urn:ietf:params:jmap:quota': {},
|
||||
'urn:ietf:params:jmap:vacationresponse': {},
|
||||
'urn:ietf:params:jmap:contacts': {},
|
||||
|
||||
Reference in New Issue
Block a user