Scheduld Send
This commit is contained in:
@@ -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 } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -72,7 +72,9 @@ interface EmailComposerProps {
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
sendAt?: string;
|
||||
}) => void | Promise<void>;
|
||||
onScheduledSendCreated?: () => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
onDiscardDraft?: (draftId: string) => void;
|
||||
onSaveState?: (data: ComposerDraftData) => void;
|
||||
@@ -113,6 +115,7 @@ type ComposerAttachment = {
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onScheduledSendCreated,
|
||||
onClose,
|
||||
onDiscardDraft,
|
||||
onSaveState,
|
||||
@@ -130,6 +133,7 @@ export function EmailComposer({
|
||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = () => {
|
||||
@@ -256,6 +260,9 @@ export function EmailComposer({
|
||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
||||
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
|
||||
const [scheduleValue, setScheduleValue] = useState('');
|
||||
const [scheduleError, setScheduleError] = useState('');
|
||||
|
||||
const saveTemplateModalRef = useFocusTrap({
|
||||
isActive: showSaveAsTemplate,
|
||||
@@ -855,6 +862,33 @@ export function EmailComposer({
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validateScheduleValue = (value: string): string | null => {
|
||||
if (!value) return t('schedule_send_required');
|
||||
const time = new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) return t('schedule_send_invalid');
|
||||
if (time <= Date.now()) return t('schedule_send_future');
|
||||
if (client) {
|
||||
const maxDelayedSend = client.getMaxDelayedSend();
|
||||
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
|
||||
return t('schedule_send_too_late');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getEffectiveSendAt = async (explicitSendAt?: string): Promise<string | undefined> => {
|
||||
if (explicitSendAt) return explicitSendAt;
|
||||
if (sendDelaySeconds === 0) return undefined;
|
||||
if (client?.hasDelayedSend()) {
|
||||
return new Date(Date.now() + sendDelaySeconds * 1000).toISOString();
|
||||
}
|
||||
const confirmed = window.confirm(t('send_delay_unsupported_confirm'));
|
||||
if (!confirmed) {
|
||||
throw new Error(t('send_delay_unsupported'));
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
|
||||
// references so recipient clients that strip data URIs can still render them.
|
||||
const rewriteInlineImages = (html: string): {
|
||||
@@ -898,7 +932,7 @@ export function EmailComposer({
|
||||
};
|
||||
};
|
||||
|
||||
const handleSend = async (skipAttachmentCheck = false) => {
|
||||
const handleSend = async (skipAttachmentCheck = false, sendAt?: string) => {
|
||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
|
||||
@@ -980,6 +1014,7 @@ export function EmailComposer({
|
||||
const inlineAttachments = rewritten?.attachments ?? [];
|
||||
|
||||
try {
|
||||
const effectiveSendAt = await getEffectiveSendAt(sendAt);
|
||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
|
||||
// 1. Resolve S/MIME key
|
||||
@@ -1096,7 +1131,16 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
// 7. Send via raw email path
|
||||
await sendRawEmail(client, payload, currentIdentity.id);
|
||||
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveSendAt);
|
||||
if (effectiveSendAt && finalDraftId) {
|
||||
client.deleteEmail(finalDraftId).catch(err => {
|
||||
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
|
||||
toast.warning(t('schedule_send_cleanup_warning'));
|
||||
});
|
||||
}
|
||||
if (result.scheduled) {
|
||||
await onScheduledSendCreated?.();
|
||||
}
|
||||
} else {
|
||||
// Standard JMAP send path
|
||||
// Collect uploaded attachment blobIds for the send request
|
||||
@@ -1134,6 +1178,7 @@ export function EmailComposer({
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||
inReplyTo: threadingHeaders?.inReplyTo,
|
||||
references: threadingHeaders?.references,
|
||||
sendAt: effectiveSendAt,
|
||||
});
|
||||
|
||||
if (mode === 'reply' || mode === 'replyAll') {
|
||||
@@ -1157,14 +1202,30 @@ export function EmailComposer({
|
||||
setDraftId(null);
|
||||
setSubAddressTag("");
|
||||
setValidationErrors({});
|
||||
setShowScheduleDialog(false);
|
||||
setScheduleValue('');
|
||||
setScheduleError('');
|
||||
// Clear ref so unmount effect doesn't re-save
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
||||
} catch (err) {
|
||||
debug.error('Failed to send email:', err);
|
||||
toast.error(t('send_failed'));
|
||||
toast.error(err instanceof Error ? err.message : t('send_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleSend = () => {
|
||||
if (!client?.hasDelayedSend()) {
|
||||
setScheduleError(t('schedule_send_unsupported'));
|
||||
return;
|
||||
}
|
||||
const error = validateScheduleValue(scheduleValue);
|
||||
if (error) {
|
||||
setScheduleError(error);
|
||||
return;
|
||||
}
|
||||
handleSend(false, new Date(scheduleValue).toISOString());
|
||||
};
|
||||
|
||||
const cleanClose = () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
@@ -1583,6 +1644,20 @@ export function EmailComposer({
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setScheduleError('');
|
||||
setScheduleValue('');
|
||||
setShowScheduleDialog(true);
|
||||
}}
|
||||
disabled={!client?.hasDelayedSend()}
|
||||
title={client?.hasDelayedSend() ? t('schedule_send') : t('schedule_send_unsupported')}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* S/MIME toggles */}
|
||||
{canSmimeSign && (
|
||||
@@ -1668,6 +1743,29 @@ export function EmailComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showScheduleDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||
<div className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md p-6 animate-in zoom-in-95 duration-200">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{t('schedule_send')}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{t('schedule_send_description')}</p>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={scheduleValue}
|
||||
onChange={(e) => {
|
||||
setScheduleValue(e.target.value);
|
||||
setScheduleError('');
|
||||
}}
|
||||
className={cn(scheduleError && "border-destructive focus-visible:ring-destructive")}
|
||||
/>
|
||||
{scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setShowScheduleDialog(false)}>{tCommon('cancel')}</Button>
|
||||
<Button onClick={handleScheduleSend} disabled={!canSend}>{t('schedule_send')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* S/MIME passphrase prompt */}
|
||||
{smimePassphrasePrompt && (
|
||||
<div
|
||||
@@ -2007,4 +2105,4 @@ function RecipientChipInput({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
EditIcon,
|
||||
CalendarClock,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
@@ -63,6 +65,9 @@ interface EmailContextMenuProps {
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduled?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
onRescheduleScheduled?: () => void;
|
||||
// Batch actions
|
||||
onBatchMarkAsRead?: (read: boolean) => void;
|
||||
onBatchDelete?: () => void;
|
||||
@@ -133,6 +138,9 @@ export function EmailContextMenu({
|
||||
onBatchMarkAsSpam,
|
||||
onBatchUndoSpam,
|
||||
onEditDraft,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const _tColor = useTranslations("email_viewer.color_tag");
|
||||
@@ -143,6 +151,7 @@ export function EmailContextMenu({
|
||||
const currentColors = getCurrentColors(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
const isScheduled = email.isScheduled === true;
|
||||
|
||||
// Build color options from keyword definitions in settings
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
@@ -196,8 +205,36 @@ export function EmailContextMenu({
|
||||
</ContextMenuHeader>
|
||||
)}
|
||||
|
||||
{isScheduled && !showBatchActions && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={CalendarClock}
|
||||
label={t("reschedule_send")}
|
||||
onClick={() => handleAction(onRescheduleScheduled!)}
|
||||
disabled={!onRescheduleScheduled}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={XCircle}
|
||||
label={t("cancel_scheduled_send")}
|
||||
onClick={() => handleAction(onCancelScheduled!)}
|
||||
disabled={!onCancelScheduled}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={EditIcon}
|
||||
label={email.isSmimeScheduled ? t("cancel_and_compose_again") : t("cancel_and_edit")}
|
||||
onClick={() => handleAction(onCancelScheduledForEdit!)}
|
||||
disabled={!onCancelScheduledForEdit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isScheduled && <ContextMenuSeparator />}
|
||||
|
||||
{!isScheduled && (
|
||||
<>
|
||||
|
||||
{/* Edit Draft - only for single draft emails */}
|
||||
{!showBatchActions && isDraft && onEditDraft && (
|
||||
{!isScheduled && !showBatchActions && isDraft && onEditDraft && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={EditIcon}
|
||||
@@ -209,7 +246,7 @@ export function EmailContextMenu({
|
||||
)}
|
||||
|
||||
{/* Single email actions - Reply, Reply All, Forward */}
|
||||
{!showBatchActions && (
|
||||
{!isScheduled && !showBatchActions && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={Reply}
|
||||
@@ -375,6 +412,8 @@ export function EmailContextMenu({
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PluginSlot name="context-menu-email" />
|
||||
</ContextMenu>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 } from "lucide-react";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, XCircle, Edit3 } 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,6 +18,7 @@ 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[];
|
||||
@@ -38,6 +39,11 @@ interface EmailListProps {
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
onEditDraft?: (email: Email) => void;
|
||||
isScheduledView?: boolean;
|
||||
onLoadMoreScheduled?: () => void;
|
||||
onCancelScheduled?: (email: Email) => void | Promise<void>;
|
||||
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
|
||||
onRescheduleScheduled?: (email: Email, sendAt: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function EmailList({
|
||||
@@ -59,8 +65,14 @@ export function EmailList({
|
||||
onUndoSpam,
|
||||
onMoveToMailbox,
|
||||
onEditDraft,
|
||||
isScheduledView = false,
|
||||
onLoadMoreScheduled,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
}: EmailListProps) {
|
||||
const t = useTranslations('email_list');
|
||||
const tComposer = useTranslations('email_composer');
|
||||
const { client } = useAuthStore();
|
||||
const {
|
||||
selectedEmailIds,
|
||||
@@ -93,9 +105,9 @@ export function EmailList({
|
||||
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||
|
||||
const threadGroups = useMemo(() => {
|
||||
const groups = groupEmailsByThread(emails, disableThreading);
|
||||
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
|
||||
return sortThreadGroups(groups);
|
||||
}, [emails, disableThreading]);
|
||||
}, [emails, disableThreading, isScheduledView]);
|
||||
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
@@ -214,10 +226,38 @@ export function EmailList({
|
||||
};
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (isScheduledView) {
|
||||
onLoadMoreScheduled?.();
|
||||
return;
|
||||
}
|
||||
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
|
||||
loadMoreEmails(client);
|
||||
}
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
|
||||
|
||||
const promptForRescheduleSendAt = 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);
|
||||
@@ -274,10 +314,15 @@ export function EmailList({
|
||||
return (
|
||||
<div className={cn("flex flex-col min-h-0", className)}>
|
||||
{/* Batch Actions Toolbar */}
|
||||
{isScheduledView && emails.length > 0 && (
|
||||
<div className="border-b border-border bg-muted/20 px-4 py-2 text-xs text-muted-foreground">
|
||||
{t('scheduled_actions_hint')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
hasSelection ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
hasSelection && !isScheduledView ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
|
||||
@@ -401,17 +446,19 @@ export function EmailList({
|
||||
) : emails.length === 0 && !isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted shadow-lg flex items-center justify-center">
|
||||
{searchQuery || !isFilterEmpty(searchFilters) ? (
|
||||
{isScheduledView ? (
|
||||
<CalendarClock className="w-10 h-10 text-muted-foreground" />
|
||||
) : searchQuery || !isFilterEmpty(searchFilters) ? (
|
||||
<SearchX className="w-10 h-10 text-muted-foreground" />
|
||||
) : (
|
||||
<MailX className="w-10 h-10 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
|
||||
{isScheduledView ? t('no_scheduled_emails') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
|
||||
</p>
|
||||
<p className="text-sm mt-1 text-muted-foreground">
|
||||
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
|
||||
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -456,6 +503,34 @@ export function EmailList({
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||
/>
|
||||
{isScheduledView && thread.latestEmail.isScheduled && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/10 px-4 py-2 text-xs">
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<CalendarClock className="w-3.5 h-3.5" />
|
||||
{new Date(thread.latestEmail.scheduledSendAt || '').toLocaleString()}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => onCancelScheduled?.(thread.latestEmail)}>
|
||||
<XCircle className="w-3.5 h-3.5 mr-1" />
|
||||
{t('cancel_scheduled_send')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => {
|
||||
const sendAt = promptForRescheduleSendAt();
|
||||
if (sendAt) onRescheduleScheduled?.(thread.latestEmail, sendAt);
|
||||
}}
|
||||
>
|
||||
<CalendarClock className="w-3.5 h-3.5 mr-1" />
|
||||
{t('reschedule_send')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => onCancelScheduledForEdit?.(thread.latestEmail)}>
|
||||
<Edit3 className="w-3.5 h-3.5 mr-1" />
|
||||
{thread.latestEmail.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -503,6 +578,12 @@ 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 sendAt = promptForRescheduleSendAt();
|
||||
if (sendAt) onRescheduleScheduled?.(contextMenu.data!, sendAt);
|
||||
}}
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchArchive={async () => {
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
EditIcon,
|
||||
PlayCircle,
|
||||
PenSquare,
|
||||
CalendarClock,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
@@ -117,6 +118,9 @@ interface EmailViewerProps {
|
||||
onNavigatePrev?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduled?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
onRescheduleScheduled?: (sendAt: string) => void;
|
||||
onCompose?: () => void;
|
||||
currentUserEmail?: string;
|
||||
currentUserName?: string;
|
||||
@@ -823,6 +827,9 @@ export function EmailViewer({
|
||||
onNavigatePrev,
|
||||
onShowShortcuts,
|
||||
onEditDraft,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
onCompose,
|
||||
currentUserEmail,
|
||||
currentUserName,
|
||||
@@ -832,6 +839,7 @@ export function EmailViewer({
|
||||
className,
|
||||
}: EmailViewerProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tComposer = useTranslations('email_composer');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tCommon = useTranslations('common');
|
||||
const tSmime = useTranslations('smime');
|
||||
@@ -861,6 +869,7 @@ export function EmailViewer({
|
||||
|
||||
// Detect if the email is a draft
|
||||
const isDraft = email?.keywords?.['$draft'] === true;
|
||||
const isScheduled = email?.isScheduled === true;
|
||||
|
||||
// Color options for email tags (from user-defined keyword settings)
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
@@ -873,6 +882,29 @@ export function EmailViewer({
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const promptForRescheduleSendAt = 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 resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const { startTour } = useTour();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
@@ -3088,7 +3120,32 @@ export function EmailViewer({
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
{isDraft && onEditDraft && (
|
||||
{isScheduled && (
|
||||
<>
|
||||
<Button variant="default" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
|
||||
<X className="w-4 h-4" />
|
||||
<span className="hidden sm:inline text-sm">{t('cancel_scheduled_send')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const sendAt = promptForRescheduleSendAt();
|
||||
if (sendAt) onRescheduleScheduled?.(sendAt);
|
||||
}}
|
||||
className="hidden sm:flex sm:h-8"
|
||||
title={t('reschedule_send')}
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reschedule_send')}</span>}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
|
||||
<EditIcon className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isScheduled && isDraft && onEditDraft && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -3100,7 +3157,7 @@ export function EmailViewer({
|
||||
<span className="text-sm">{t('edit_draft')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{!isDraft && (<>
|
||||
{!isScheduled && !isDraft && (<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3142,6 +3199,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
|
||||
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
|
||||
{!isScheduled && (
|
||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||
{/* Archive */}
|
||||
<Button
|
||||
@@ -3599,6 +3657,7 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3609,13 +3668,13 @@ export function EmailViewer({
|
||||
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
|
||||
>
|
||||
{/* Mobile More menu sidebar overlay */}
|
||||
{isMobile && moreMenuOpen && (
|
||||
{!isScheduled && isMobile && moreMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
|
||||
onClick={() => setMoreMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isMobile && (
|
||||
{!isScheduled && isMobile && (
|
||||
<div className={cn(
|
||||
"fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
@@ -4797,6 +4856,36 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scheduled Banner */}
|
||||
{isScheduled && (
|
||||
<div className="border-b border-border bg-primary/10">
|
||||
<div className="max-w-4xl mx-auto px-6 py-2.5 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const sendAt = promptForRescheduleSendAt();
|
||||
if (sendAt) onRescheduleScheduled?.(sendAt);
|
||||
}}
|
||||
>
|
||||
{t('reschedule_send')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit}>
|
||||
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Draft Banner */}
|
||||
{isDraft && (
|
||||
<div className="border-b border-border bg-warning/10">
|
||||
@@ -4932,7 +5021,7 @@ export function EmailViewer({
|
||||
<PluginSlot name="email-footer" />
|
||||
|
||||
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn(
|
||||
{!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn(
|
||||
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
||||
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
|
||||
)}>
|
||||
@@ -5230,4 +5319,4 @@ export function EmailViewer({
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
FlaskConical,
|
||||
PlayCircle,
|
||||
Loader2,
|
||||
CalendarClock,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
@@ -67,6 +68,7 @@ interface SidebarProps {
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onRefreshMailboxes?: () => void;
|
||||
scheduledTotal?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -637,6 +639,7 @@ export function Sidebar({
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onRefreshMailboxes,
|
||||
scheduledTotal = 0,
|
||||
className,
|
||||
}: SidebarProps) {
|
||||
const router = useRouter();
|
||||
@@ -927,20 +930,31 @@ export function Sidebar({
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
ownTree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
<>
|
||||
{ownTree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
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}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
))
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { SendDelaySeconds } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { Mail, X } from 'lucide-react';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
@@ -26,9 +28,12 @@ export function ComposingSettings() {
|
||||
autoSelectReplyIdentity,
|
||||
attachmentReminderEnabled,
|
||||
attachmentReminderKeywords,
|
||||
sendDelaySeconds,
|
||||
subAddressDelimiter,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { client } = useAuthStore();
|
||||
const delayedSendSupported = client?.hasDelayedSend() ?? false;
|
||||
|
||||
const handleSetDefaultMailProgram = useCallback(() => {
|
||||
try {
|
||||
@@ -50,6 +55,24 @@ export function ComposingSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Select
|
||||
value={String(sendDelaySeconds)}
|
||||
onChange={(value) => updateSetting('sendDelaySeconds', Number(value) as SendDelaySeconds)}
|
||||
options={[
|
||||
{ value: '0', label: t('send_delay.off') },
|
||||
{ value: '10', label: t('send_delay.seconds', { seconds: 10 }) },
|
||||
{ value: '30', label: t('send_delay.seconds', { seconds: 30 }) },
|
||||
{ value: '60', label: t('send_delay.seconds', { seconds: 60 }) },
|
||||
]}
|
||||
/>
|
||||
{sendDelaySeconds > 0 && !delayedSendSupported && (
|
||||
<p className="max-w-64 text-right text-xs text-amber-600 dark:text-amber-400">{t('send_delay.unsupported')}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('sub_address_delimiter.label')}
|
||||
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
||||
|
||||
Reference in New Issue
Block a user