diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index b46607c0..6dc585d6 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -108,6 +108,7 @@ export default function Home() { const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState(null); const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const markAsReadTimeoutRef = useRef(null); + const lastUndoToastSubmissionRef = useRef(null); const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { identities } = useIdentityStore(); useIdentitySync(); @@ -1256,6 +1257,44 @@ 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; + + toast.info(t('email_viewer.undo_send_scheduled'), { + duration: 8000, + 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(); + } + }, 8000); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, t]); + const handleReplyAll = async () => { if (selectedEmail) { const ok = await emailHooks.onBeforeReplyAll.intercept({ @@ -2608,11 +2647,6 @@ export default function Home() { setShowComposer(true); if (isMobile) setActiveView('viewer'); }} - onRescheduleScheduled={async (email, delayedUntil) => { - if (client && email.emailSubmissionId && email.scheduledIdentityId) { - await rescheduleScheduledEmail(client, email.emailSubmissionId, email.id, email.scheduledIdentityId, delayedUntil); - } - }} onEmailSelect={handleEmailSelect} onEmailDoubleClick={isEmbedded ? ((email) => { useProTabStore.getState().openEmailTab({ @@ -2964,24 +2998,6 @@ export default function Home() { )} - {pendingUndoSend && ( -
- {t('email_viewer.undo_send_scheduled')} - -
- )} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 835d749e..71a668db 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; @@ -160,6 +160,18 @@ function buildEmbeddedSignatureHtml( return ''; } +function formatLocalDateTimeInput(date: Date): string { + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function getDefaultScheduleValue(): string { + const tomorrowAtEight = new Date(); + tomorrowAtEight.setDate(tomorrowAtEight.getDate() + 1); + tomorrowAtEight.setHours(8, 0, 0, 0); + return formatLocalDateTimeInput(tomorrowAtEight); +} + export function EmailComposer({ onSend, onScheduledSendCreated, @@ -373,6 +385,8 @@ export function EmailComposer({ const [showScheduleDialog, setShowScheduleDialog] = useState(false); const [scheduleValue, setScheduleValue] = useState(''); const [scheduleError, setScheduleError] = useState(''); + const [showSendMenu, setShowSendMenu] = useState(false); + const sendMenuRef = useRef(null); const saveTemplateModalRef = useFocusTrap({ isActive: showSaveAsTemplate, @@ -465,6 +479,23 @@ export function EmailComposer({ } }, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]); + useEffect(() => { + const handleClickOutsideSendMenu = (event: MouseEvent) => { + if (!sendMenuRef.current?.contains(event.target as Node)) { + setShowSendMenu(false); + } + }; + document.addEventListener('mousedown', handleClickOutsideSendMenu); + return () => document.removeEventListener('mousedown', handleClickOutsideSendMenu); + }, []); + + const openScheduleDialog = useCallback(() => { + setScheduleError(''); + setScheduleValue(getDefaultScheduleValue()); + setShowScheduleDialog(true); + setShowSendMenu(false); + }, []); + useEffect(() => { if (!autoSelectReplyIdentity) return; if (selectedIdentityId || initialData?.selectedIdentityId) return; @@ -1550,8 +1581,10 @@ export function EmailComposer({ if (e.defaultPrevented) return; const isPlainEscape = e.key === 'Escape' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey; - const isSendShortcut = e.key === 'Enter' && e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey; - if (!isPlainEscape && !isSendShortcut) return; + const hasPrimaryModifier = e.ctrlKey || e.metaKey; + const isSendShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && !e.shiftKey; + const isScheduleShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && e.shiftKey; + if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) return; if ( showTemplatePicker || @@ -1577,6 +1610,12 @@ export function EmailComposer({ e.preventDefault(); handleSend(); + return; + } + + if (isScheduleShortcut) { + e.preventDefault(); + if (!e.repeat && client?.hasDelayedSend()) openScheduleDialog(); } }; @@ -2007,22 +2046,6 @@ export function EmailComposer({ > - {client?.hasDelayedSend() && ( - - )} - {/* S/MIME toggles */} {canSmimeSign && ( <> @@ -2060,15 +2083,56 @@ export function EmailComposer({ > {t('discard')} - + {client?.hasDelayedSend() ? ( +
+ + + {showSendMenu && ( +
+ +
+ )} +
+ ) : ( + + )} diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 42acccf1..4cad5e31 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -3,8 +3,8 @@ import { Email, ThreadGroup } from "@/lib/jmap/types"; import { ThreadListItem } from "./thread-list-item"; import { EmailContextMenu } from "./email-context-menu"; -import { cn } from "@/lib/utils"; -import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, XCircle, Edit3 } from "lucide-react"; +import { cn, formatDateTime } from "@/lib/utils"; +import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; @@ -18,7 +18,6 @@ import { useTranslations } from "next-intl"; import { useVirtualizer } from "@tanstack/react-virtual"; import { SearchChips } from "@/components/search/search-chips"; import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; -import { toast } from "@/stores/toast-store"; interface EmailListProps { emails: Email[]; @@ -44,7 +43,6 @@ interface EmailListProps { onLoadMoreScheduled?: () => void; onCancelScheduled?: (email: Email) => void | Promise; onCancelScheduledForEdit?: (email: Email) => void | Promise; - onRescheduleScheduled?: (email: Email, delayedUntil: string) => void | Promise; } export function EmailList({ @@ -71,10 +69,8 @@ export function EmailList({ onLoadMoreScheduled, onCancelScheduled, onCancelScheduledForEdit, - onRescheduleScheduled, }: EmailListProps) { const t = useTranslations('email_list'); - const tComposer = useTranslations('email_composer'); const { client } = useAuthStore(); const { selectedEmailIds, @@ -119,6 +115,7 @@ export function EmailList({ const density = useSettingsStore((state) => state.density); const showPreview = useSettingsStore((state) => state.showPreview); const mailLayout = useSettingsStore((state) => state.mailLayout); + const timeFormat = useSettingsStore((state) => state.timeFormat); const isFocusedMailLayout = mailLayout === 'focus'; const estimateSize = useCallback(() => { @@ -237,30 +234,6 @@ export function EmailList({ } }, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]); - const promptForRescheduleDelayedUntil = useCallback((): string | null => { - const value = window.prompt(t('reschedule_prompt')); - if (!value) return null; - const time = new Date(value).getTime(); - if (!Number.isFinite(time)) { - toast.error(tComposer('schedule_send_invalid')); - return null; - } - if (time <= Date.now()) { - toast.error(tComposer('schedule_send_future')); - return null; - } - if (!client?.hasDelayedSend()) { - toast.error(tComposer('schedule_send_unsupported')); - return null; - } - const maxDelayedSend = client.getMaxDelayedSend(); - if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) { - toast.error(tComposer('schedule_send_too_late')); - return null; - } - return new Date(time).toISOString(); - }, [client, t, tComposer]); - const handleToggleThreadExpansion = useCallback(async (threadId: string) => { const isExpanded = expandedThreadIds.has(threadId); @@ -316,11 +289,6 @@ export function EmailList({ return (
{/* Batch Actions Toolbar */} - {isScheduledView && emails.length > 0 && ( -
- {t('scheduled_actions_hint')} -
- )}
handleToggleThreadExpansion(thread.threadId)} onEmailSelect={(email) => onEmailSelect?.(email)} onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined} - onContextMenu={openContextMenu} + onContextMenu={isScheduledView ? undefined : openContextMenu} onOpenConversation={onOpenConversation} onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined} onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined} @@ -508,39 +476,10 @@ export function EmailList({ /> {isScheduledView && thread.latestEmail.isScheduled && (
- {thread.latestEmail.scheduledUndoStatus && thread.latestEmail.scheduledUndoStatus !== 'pending' && ( - - {thread.latestEmail.scheduledUndoStatus} - - )} - {new Date(thread.latestEmail.scheduledSendAt || '').toLocaleString()} + {thread.latestEmail.scheduledSendAt ? formatDateTime(thread.latestEmail.scheduledSendAt, timeFormat) : ''} - {thread.latestEmail.scheduledUndoStatus === 'pending' && ( - <> - - - - - )}
)}
@@ -590,12 +529,9 @@ export function EmailList({ onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} onEditDraft={() => onEditDraft?.(contextMenu.data!)} - onCancelScheduled={() => onCancelScheduled?.(contextMenu.data!)} - onCancelScheduledForEdit={() => onCancelScheduledForEdit?.(contextMenu.data!)} - onRescheduleScheduled={() => { - const delayedUntil = promptForRescheduleDelayedUntil(); - if (delayedUntil) onRescheduleScheduled?.(contextMenu.data!, delayedUntil); - }} + onCancelScheduled={isScheduledView ? undefined : () => onCancelScheduled?.(contextMenu.data!)} + onCancelScheduledForEdit={isScheduledView ? undefined : () => onCancelScheduledForEdit?.(contextMenu.data!)} + onRescheduleScheduled={undefined} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchDelete={() => client && batchDelete(client)} onBatchArchive={async () => { diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts index 0a27942c..b0278141 100644 --- a/hooks/use-keyboard-shortcuts.ts +++ b/hooks/use-keyboard-shortcuts.ts @@ -289,6 +289,8 @@ export const KEYBOARD_SHORTCUTS = { { key: "x", description: "shortcuts.threads.expand_collapse" }, ], composer: [ + { key: "Ctrl + Enter", description: "shortcuts.composer.send" }, + { key: "Ctrl + Shift + Enter", description: "shortcuts.composer.schedule_send" }, { key: "t", description: "shortcuts.composer.template_picker" }, ], } as const; diff --git a/locales/cs/common.json b/locales/cs/common.json index 973612d4..4252c77f 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "Rozbalit/sbalit vlákno" }, "composer": { + "send": "Odeslat e-mail", + "schedule_send": "Naplánovat odeslání", "template_picker": "Otevřít výběr šablon" } }, diff --git a/locales/da/common.json b/locales/da/common.json index a5645a08..01c088c5 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1811,6 +1811,8 @@ "expand_collapse": "Udvid/skjul tråd" }, "composer": { + "send": "Send e-mail", + "schedule_send": "Planlæg afsendelse", "template_picker": "Åbn skabelonvælger" } }, @@ -2915,4 +2917,4 @@ "unified_mailbox": { "search_unavailable": "Søgning er ikke tilgængelig i den samlede visning" } -} \ No newline at end of file +} diff --git a/locales/de/common.json b/locales/de/common.json index a40f56e6..6e8238d0 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "Unterhaltung erweitern/einklappen" }, "composer": { + "send": "E-Mail senden", + "schedule_send": "Senden planen", "template_picker": "Vorlagenauswahl öffnen" } }, diff --git a/locales/en/common.json b/locales/en/common.json index 64560a18..9a6c8a15 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1858,6 +1858,8 @@ "expand_collapse": "Expand/collapse thread" }, "composer": { + "send": "Send email", + "schedule_send": "Schedule send", "template_picker": "Open template picker" } }, diff --git a/locales/es/common.json b/locales/es/common.json index bb6e9f96..50d05820 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "Expandir/contraer conversación" }, "composer": { + "send": "Enviar correo", + "schedule_send": "Programar envío", "template_picker": "Abrir selector de plantillas" } }, diff --git a/locales/fr/common.json b/locales/fr/common.json index bd206d07..7c37efdf 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "Développer/réduire la conversation" }, "composer": { + "send": "Envoyer l'e-mail", + "schedule_send": "Planifier l'envoi", "template_picker": "Ouvrir le sélecteur de modèles" } }, diff --git a/locales/it/common.json b/locales/it/common.json index 4d117c2c..f444af09 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "Espandi/comprimi conversazione" }, "composer": { + "send": "Invia email", + "schedule_send": "Programma invio", "template_picker": "Apri selettore modelli" } }, diff --git a/locales/ja/common.json b/locales/ja/common.json index f65b506b..a7bf0632 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "スレッドの展開/折りたたみ" }, "composer": { + "send": "メールを送信", + "schedule_send": "送信を予約", "template_picker": "テンプレートピッカーを開く" } }, diff --git a/locales/ko/common.json b/locales/ko/common.json index 4473e25e..e73dbb8d 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "대화 펼치기/접기" }, "composer": { + "send": "이메일 보내기", + "schedule_send": "보내기 예약", "template_picker": "템플릿 선택기 열기" } }, diff --git a/locales/lv/common.json b/locales/lv/common.json index f2b61072..31ad3e5b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "Izvērst/sairt sarunu" }, "composer": { + "send": "Nosūtīt e-pastu", + "schedule_send": "Ieplānot nosūtīšanu", "template_picker": "Atvērt veidņu izvēli" } }, diff --git a/locales/nl/common.json b/locales/nl/common.json index ccd37329..62fc24bb 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "Gesprek uitklappen/inklappen" }, "composer": { + "send": "E-mail verzenden", + "schedule_send": "Verzenden plannen", "template_picker": "Sjabloonkiezer openen" } }, diff --git a/locales/pl/common.json b/locales/pl/common.json index 99d8ca67..40aecb0e 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "Rozwiń/zwiń wątek" }, "composer": { + "send": "Wyślij e-mail", + "schedule_send": "Zaplanuj wysyłkę", "template_picker": "Otwórz wybór szablonu" } }, diff --git a/locales/pt/common.json b/locales/pt/common.json index bbc5510d..976fb689 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "Expandir/recolher conversa" }, "composer": { + "send": "Enviar e-mail", + "schedule_send": "Agendar envio", "template_picker": "Abrir seletor de modelos" } }, diff --git a/locales/ru/common.json b/locales/ru/common.json index 89fd194c..d720cdf7 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "Развернуть/свернуть цепочку" }, "composer": { + "send": "Отправить письмо", + "schedule_send": "Запланировать отправку", "template_picker": "Открыть выбор шаблонов" } }, diff --git a/locales/tr/common.json b/locales/tr/common.json index 461c18c1..5c37b685 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "İleti dizisini genişlet/daralt" }, "composer": { + "send": "E-posta gönder", + "schedule_send": "Göndermeyi planla", "template_picker": "Şablon seçiciyi aç" } }, diff --git a/locales/uk/common.json b/locales/uk/common.json index 04b0926d..87488227 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "Розгорнути/згорнути ланцюжок" }, "composer": { + "send": "Надіслати лист", + "schedule_send": "Запланувати надсилання", "template_picker": "Відкрити засіб вибору шаблону" } }, diff --git a/locales/zh/common.json b/locales/zh/common.json index d4107210..0413e0f5 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1854,6 +1854,8 @@ "expand_collapse": "展开/折叠会话" }, "composer": { + "send": "发送邮件", + "schedule_send": "定时发送", "template_picker": "打开模板选择器" } }, diff --git a/stores/email-store.ts b/stores/email-store.ts index 8a841cde..cf4ddf3c 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -2213,7 +2213,7 @@ export const useEmailStore = create((set, get) => ({ const result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, delayedUntil); const pendingUndoSend = get().pendingUndoSend; if (pendingUndoSend?.submissionId === submissionId) { - set({ pendingUndoSend: { ...pendingUndoSend, sendAt: result.sendAt || delayedUntil } }); + set({ pendingUndoSend: { ...pendingUndoSend, submissionId: result.emailSubmissionId || submissionId, sendAt: result.sendAt || delayedUntil } }); } return result; } finally {