Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0c7e991ff | ||
|
|
d1b2206aa0 | ||
|
|
0137a1a593 | ||
|
|
c4aa0c1772 | ||
|
|
9a302e5183 | ||
|
|
401870c9bb | ||
|
|
91cd087243 | ||
|
|
d04a8e578b | ||
|
|
e6d939ba43 | ||
|
|
5c1d59f38a | ||
|
|
159706a683 | ||
|
|
5928c6f73e | ||
|
|
7dccc2f432 | ||
|
|
dd322d4c9d | ||
|
|
7e15782fb3 | ||
|
|
1e8bd40b93 | ||
|
|
3dc1b4ceee | ||
|
|
1a3d359fee | ||
|
|
891c3250be | ||
|
|
4a3c775b40 | ||
|
|
f45b67fe19 | ||
|
|
c458091698 | ||
|
|
37cd5ca635 | ||
|
|
154ae84247 | ||
|
|
16f719066f | ||
|
|
17577e222c | ||
|
|
48821338c3 |
+355
-84
@@ -75,6 +75,8 @@ import { buildQuoteHeader } from "@/lib/quote-header";
|
|||||||
import { useLocaleStore } from "@/stores/locale-store";
|
import { useLocaleStore } from "@/stores/locale-store";
|
||||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||||
|
|
||||||
|
const SCHEDULED_MAILBOX_ID = '__scheduled__';
|
||||||
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -108,12 +110,39 @@ export default function Home() {
|
|||||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
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 { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
useIdentitySync();
|
useIdentitySync();
|
||||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||||
|
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||||
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
|
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
|
// Load trusted senders address book when feature is enabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
|
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
|
||||||
@@ -270,6 +299,21 @@ export default function Home() {
|
|||||||
fetchTagCounts,
|
fetchTagCounts,
|
||||||
fetchEmailContent,
|
fetchEmailContent,
|
||||||
isUnifiedView,
|
isUnifiedView,
|
||||||
|
scheduledEmails,
|
||||||
|
scheduledTotal,
|
||||||
|
scheduledHasMore,
|
||||||
|
isLoadingScheduled,
|
||||||
|
isScheduledView,
|
||||||
|
setScheduledView,
|
||||||
|
fetchScheduledEmails,
|
||||||
|
loadMoreScheduledEmails,
|
||||||
|
cancelScheduledEmail,
|
||||||
|
cancelScheduledEmailForEdit,
|
||||||
|
rescheduleScheduledEmail,
|
||||||
|
refreshScheduledMetadata,
|
||||||
|
cancelUndoSend,
|
||||||
|
clearPendingUndoSend,
|
||||||
|
pendingUndoSend,
|
||||||
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||||
refreshUnifiedCounts,
|
refreshUnifiedCounts,
|
||||||
exitUnifiedView,
|
exitUnifiedView,
|
||||||
@@ -294,6 +338,10 @@ export default function Home() {
|
|||||||
useProMultiAccountMailboxes();
|
useProMultiAccountMailboxes();
|
||||||
|
|
||||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
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 includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
||||||
const accounts = useAccountStore((s) => s.accounts);
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
const connectedAccountsSignature = useMemo(
|
const connectedAccountsSignature = useMemo(
|
||||||
@@ -329,7 +377,7 @@ export default function Home() {
|
|||||||
conversationThreadId: null as string | null,
|
conversationThreadId: null as string | null,
|
||||||
});
|
});
|
||||||
navRestoreStateRef.current.client = client;
|
navRestoreStateRef.current.client = client;
|
||||||
navRestoreStateRef.current.emails = emails;
|
navRestoreStateRef.current.emails = activeEmails;
|
||||||
navRestoreStateRef.current.mailboxes = mailboxes;
|
navRestoreStateRef.current.mailboxes = mailboxes;
|
||||||
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
|
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
|
||||||
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
|
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
|
||||||
@@ -355,7 +403,24 @@ export default function Home() {
|
|||||||
|
|
||||||
// Restore mailbox selection. selectMailbox clears the current email,
|
// Restore mailbox selection. selectMailbox clears the current email,
|
||||||
// which is fine because we re-apply the saved email below.
|
// 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);
|
selectMailbox(state.mailboxId);
|
||||||
if (ctx.client) {
|
if (ctx.client) {
|
||||||
try {
|
try {
|
||||||
@@ -416,19 +481,19 @@ export default function Home() {
|
|||||||
// Keyboard shortcuts handlers
|
// Keyboard shortcuts handlers
|
||||||
const keyboardHandlers = useMemo(() => ({
|
const keyboardHandlers = useMemo(() => ({
|
||||||
onNextEmail: () => {
|
onNextEmail: () => {
|
||||||
if (emails.length === 0) return;
|
if (activeEmails.length === 0) return;
|
||||||
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
|
const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||||
const nextIndex = currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
|
const nextIndex = currentIndex < activeEmails.length - 1 ? currentIndex + 1 : currentIndex;
|
||||||
if (nextIndex >= 0 && nextIndex < emails.length) {
|
if (nextIndex >= 0 && nextIndex < activeEmails.length) {
|
||||||
handleEmailSelect(emails[nextIndex]);
|
handleEmailSelect(activeEmails[nextIndex]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPreviousEmail: () => {
|
onPreviousEmail: () => {
|
||||||
if (emails.length === 0) return;
|
if (activeEmails.length === 0) return;
|
||||||
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : emails.length;
|
const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : activeEmails.length;
|
||||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
|
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
|
||||||
if (prevIndex >= 0 && prevIndex < emails.length) {
|
if (prevIndex >= 0 && prevIndex < activeEmails.length) {
|
||||||
handleEmailSelect(emails[prevIndex]);
|
handleEmailSelect(activeEmails[prevIndex]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpenEmail: () => {
|
onOpenEmail: () => {
|
||||||
@@ -444,18 +509,23 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onReply: () => {
|
onReply: () => {
|
||||||
|
if (isScheduledView) return;
|
||||||
if (selectedEmail) handleReply();
|
if (selectedEmail) handleReply();
|
||||||
},
|
},
|
||||||
onReplyAll: () => {
|
onReplyAll: () => {
|
||||||
|
if (isScheduledView) return;
|
||||||
if (selectedEmail) handleReplyAll();
|
if (selectedEmail) handleReplyAll();
|
||||||
},
|
},
|
||||||
onForward: () => {
|
onForward: () => {
|
||||||
|
if (isScheduledView) return;
|
||||||
if (selectedEmail) handleForward();
|
if (selectedEmail) handleForward();
|
||||||
},
|
},
|
||||||
onToggleStar: () => {
|
onToggleStar: () => {
|
||||||
|
if (isScheduledView) return;
|
||||||
if (selectedEmail) handleToggleStar();
|
if (selectedEmail) handleToggleStar();
|
||||||
},
|
},
|
||||||
onArchive: async () => {
|
onArchive: async () => {
|
||||||
|
if (isScheduledView) return;
|
||||||
if (selectedEmailIds.size > 0 && client) {
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
try {
|
try {
|
||||||
await batchArchive(client);
|
await batchArchive(client);
|
||||||
@@ -467,6 +537,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDelete: async () => {
|
onDelete: async () => {
|
||||||
|
if (isScheduledView) return;
|
||||||
if (selectedEmailIds.size > 0 && client) {
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
const isInTrash = currentMailbox?.role === 'trash';
|
const isInTrash = currentMailbox?.role === 'trash';
|
||||||
@@ -496,6 +567,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMarkAsUnread: async () => {
|
onMarkAsUnread: async () => {
|
||||||
|
if (isScheduledView) return;
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
if (selectedEmailIds.size > 0) {
|
if (selectedEmailIds.size > 0) {
|
||||||
await batchMarkAsRead(client, false);
|
await batchMarkAsRead(client, false);
|
||||||
@@ -504,6 +576,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMarkAsRead: async () => {
|
onMarkAsRead: async () => {
|
||||||
|
if (isScheduledView) return;
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
if (selectedEmailIds.size > 0) {
|
if (selectedEmailIds.size > 0) {
|
||||||
await batchMarkAsRead(client, true);
|
await batchMarkAsRead(client, true);
|
||||||
@@ -512,6 +585,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToggleSpam: async () => {
|
onToggleSpam: async () => {
|
||||||
|
if (isScheduledView) return;
|
||||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
const isInJunk = currentMailbox?.role === 'junk';
|
const isInJunk = currentMailbox?.role === 'junk';
|
||||||
if (selectedEmailIds.size > 0 && client) {
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
@@ -539,6 +613,7 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
},
|
},
|
||||||
onFocusSearch: () => {
|
onFocusSearch: () => {
|
||||||
|
if (isScheduledView) return;
|
||||||
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
|
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.focus();
|
searchInput.focus();
|
||||||
@@ -550,22 +625,27 @@ export default function Home() {
|
|||||||
},
|
},
|
||||||
onRefresh: async () => {
|
onRefresh: async () => {
|
||||||
if (client && selectedMailbox) {
|
if (client && selectedMailbox) {
|
||||||
await fetchEmails(client, selectedMailbox);
|
if (selectedMailbox === SCHEDULED_MAILBOX_ID) {
|
||||||
|
await fetchScheduledEmails(client);
|
||||||
|
} else {
|
||||||
|
await fetchEmails(client, selectedMailbox);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSelectAll: () => {
|
onSelectAll: () => {
|
||||||
|
if (isScheduledView) return;
|
||||||
selectAllEmails();
|
selectAllEmails();
|
||||||
},
|
},
|
||||||
onDeselectAll: () => {
|
onDeselectAll: () => {
|
||||||
clearSelection();
|
clearSelection();
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
// Initialize keyboard shortcuts
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: isAuthenticated && !showComposer,
|
enabled: isAuthenticated && !showComposer,
|
||||||
emails,
|
emails: activeEmails,
|
||||||
selectedEmailId: selectedEmail?.id,
|
selectedEmailId: selectedEmail?.id,
|
||||||
selectionCount: selectedEmailIds.size,
|
selectionCount: selectedEmailIds.size,
|
||||||
handlers: keyboardHandlers,
|
handlers: keyboardHandlers,
|
||||||
@@ -578,13 +658,36 @@ export default function Home() {
|
|||||||
onRefresh: async () => {
|
onRefresh: async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
const state = useEmailStore.getState();
|
const state = useEmailStore.getState();
|
||||||
await Promise.all([
|
if (state.isScheduledView || state.selectedMailbox === SCHEDULED_MAILBOX_ID) {
|
||||||
state.fetchMailboxes(client),
|
await Promise.all([
|
||||||
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client),
|
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
|
// Update page title based on context
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let title = appName;
|
let title = appName;
|
||||||
@@ -788,50 +891,61 @@ export default function Home() {
|
|||||||
}, [isAuthenticated, client, handleMailtoProtocolRequest]);
|
}, [isAuthenticated, client, handleMailtoProtocolRequest]);
|
||||||
|
|
||||||
// Fallback fetch for paths that didn't go through login()'s prefetch
|
// Fallback fetch for paths that didn't go through login()'s prefetch
|
||||||
// (notably checkAuth on page refresh). The prefetch in auth-store/login()
|
// (notably checkAuth on page refresh). Settings pages can also prefill
|
||||||
// populates mailboxes before this effect first runs, so on the post-login
|
// mailboxes without emails, so bootstrap emails once per client when the
|
||||||
// path this block is a no-op.
|
// mail route mounts even if mailbox data is already present.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && client && mailboxes.length === 0) {
|
if (!isAuthenticated || !client) {
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
initialMailLoadClientRef.current = null;
|
||||||
let cancelled = false;
|
return;
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}, [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
|
// 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
|
// 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
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,7 +1086,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedEmail?.id]);
|
}, [selectedEmail?.id, isScheduledView]);
|
||||||
|
|
||||||
// Handle new email notifications - play sound
|
// Handle new email notifications - play sound
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1013,6 +1127,7 @@ export default function Home() {
|
|||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||||
inReplyTo?: string[];
|
inReplyTo?: string[];
|
||||||
references?: string[];
|
references?: string[];
|
||||||
|
delayedUntil?: string;
|
||||||
}) => {
|
}) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
@@ -1020,8 +1135,13 @@ export default function Home() {
|
|||||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||||
const originalEmailId = selectedEmail?.id;
|
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);
|
setShowComposer(false);
|
||||||
|
if (result.scheduled) {
|
||||||
|
await refreshScheduledMetadata(client);
|
||||||
|
if (isScheduledView) await fetchScheduledEmails(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Mark the original email with $answered or $forwarded keyword
|
// Mark the original email with $answered or $forwarded keyword
|
||||||
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
|
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
|
||||||
@@ -1039,7 +1159,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the current mailbox to update the UI
|
// Refresh the current mailbox to update the UI
|
||||||
await fetchEmails(client, selectedMailbox);
|
if (!isScheduledView) await fetchEmails(client, selectedMailbox);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send email:", error);
|
console.error("Failed to send email:", error);
|
||||||
}
|
}
|
||||||
@@ -1169,6 +1289,45 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
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 () => {
|
const handleReplyAll = async () => {
|
||||||
if (selectedEmail) {
|
if (selectedEmail) {
|
||||||
const ok = await emailHooks.onBeforeReplyAll.intercept({
|
const ok = await emailHooks.onBeforeReplyAll.intercept({
|
||||||
@@ -1435,7 +1594,29 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMailboxSelect = async (mailboxId: string) => {
|
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)) {
|
if (isUnifiedMailboxId(mailboxId)) {
|
||||||
|
setScheduledView(false);
|
||||||
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
||||||
if (!role) return;
|
if (!role) return;
|
||||||
|
|
||||||
@@ -1459,6 +1640,7 @@ export default function Home() {
|
|||||||
if (isUnifiedView) {
|
if (isUnifiedView) {
|
||||||
exitUnifiedView();
|
exitUnifiedView();
|
||||||
}
|
}
|
||||||
|
setScheduledView(false);
|
||||||
|
|
||||||
selectMailbox(mailboxId);
|
selectMailbox(mailboxId);
|
||||||
selectEmail(null); // Clear selected email when switching mailboxes
|
selectEmail(null); // Clear selected email when switching mailboxes
|
||||||
@@ -1485,6 +1667,7 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTagSelect = async (keywordId: string | null) => {
|
const handleTagSelect = async (keywordId: string | null) => {
|
||||||
|
setScheduledView(false);
|
||||||
selectKeyword(keywordId);
|
selectKeyword(keywordId);
|
||||||
|
|
||||||
// On mobile, close sidebar and go to list view
|
// On mobile, close sidebar and go to list view
|
||||||
@@ -1899,6 +2082,16 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const originalEmailId = selectedEmail.id;
|
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).
|
// RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234).
|
||||||
const threading = computeReplyThreadingHeaders({
|
const threading = computeReplyThreadingHeaders({
|
||||||
@@ -1907,7 +2100,7 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send reply with just the body text
|
// Send reply with just the body text
|
||||||
await sendEmail(
|
const result = await sendEmail(
|
||||||
client,
|
client,
|
||||||
[sender.email],
|
[sender.email],
|
||||||
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
||||||
@@ -1922,9 +2115,15 @@ export default function Home() {
|
|||||||
undefined,
|
undefined,
|
||||||
threading?.inReplyTo,
|
threading?.inReplyTo,
|
||||||
threading?.references,
|
threading?.references,
|
||||||
|
delayedUntil,
|
||||||
envelopeMailFrom,
|
envelopeMailFrom,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (result.scheduled) {
|
||||||
|
await refreshScheduledMetadata(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Mark the original email as answered
|
// Mark the original email as answered
|
||||||
try {
|
try {
|
||||||
await client.setKeyword(originalEmailId, '$answered');
|
await client.setKeyword(originalEmailId, '$answered');
|
||||||
@@ -1949,7 +2148,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current mailbox name for mobile header
|
// 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 isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||||
@@ -1966,9 +2165,8 @@ export default function Home() {
|
|||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the list stub immediately so subject/sender render without
|
|
||||||
// waiting for the body fetch - avoids the loading flicker.
|
// 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) {
|
if (listEmail) {
|
||||||
selectEmail(listEmail);
|
selectEmail(listEmail);
|
||||||
}
|
}
|
||||||
@@ -2005,6 +2203,14 @@ export default function Home() {
|
|||||||
|
|
||||||
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||||
if (fullEmail) {
|
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) {
|
if (emailAccountId) {
|
||||||
fullEmail.accountId = emailAccountId;
|
fullEmail.accountId = emailAccountId;
|
||||||
fullEmail.accountLabel = listEmail?.accountLabel;
|
fullEmail.accountLabel = listEmail?.accountLabel;
|
||||||
@@ -2037,14 +2243,14 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Navigate to next/previous email in the list
|
// 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
|
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < activeEmails.length - 1
|
||||||
? () => handleEmailSelect(emails[selectedEmailIndex + 1])
|
? () => handleEmailSelect(activeEmails[selectedEmailIndex + 1])
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handleNavigatePrev = selectedEmailIndex > 0
|
const handleNavigatePrev = selectedEmailIndex > 0
|
||||||
? () => handleEmailSelect(emails[selectedEmailIndex - 1])
|
? () => handleEmailSelect(activeEmails[selectedEmailIndex - 1])
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Handle opening conversation view on mobile
|
// Handle opening conversation view on mobile
|
||||||
@@ -2199,6 +2405,8 @@ export default function Home() {
|
|||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
selectedKeyword={selectedKeyword}
|
selectedKeyword={selectedKeyword}
|
||||||
|
scheduledTotal={scheduledTotal}
|
||||||
|
showScheduledMailbox={delayedSendSupported}
|
||||||
onMailboxSelect={handleMailboxSelect}
|
onMailboxSelect={handleMailboxSelect}
|
||||||
onTagSelect={handleTagSelect}
|
onTagSelect={handleTagSelect}
|
||||||
onUnreadFilterClick={handleUnreadFilterClick}
|
onUnreadFilterClick={handleUnreadFilterClick}
|
||||||
@@ -2280,26 +2488,27 @@ export default function Home() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selectedEmailIds.size > 0) {
|
if (selectedEmailIds.size > 0) {
|
||||||
if (selectedEmailIds.size === emails.length) {
|
if (selectedEmailIds.size === activeEmails.length) {
|
||||||
clearSelection();
|
clearSelection();
|
||||||
} else {
|
} else {
|
||||||
selectAllEmails();
|
selectAllEmails();
|
||||||
}
|
}
|
||||||
} else if (emails.length > 0) {
|
} else if (activeEmails.length > 0) {
|
||||||
const currentId = selectedEmail?.id;
|
const currentId = selectedEmail?.id;
|
||||||
const target = currentId && emails.some((e) => e.id === currentId)
|
const target = currentId && activeEmails.some((e) => e.id === currentId)
|
||||||
? currentId
|
? currentId
|
||||||
: emails[0].id;
|
: activeEmails[0].id;
|
||||||
toggleEmailSelection(target);
|
toggleEmailSelection(target);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
disabled={isScheduledView}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 p-2 rounded-md transition-colors",
|
"flex-shrink-0 p-2 rounded-md transition-colors",
|
||||||
selectedEmailIds.size > 0
|
selectedEmailIds.size > 0
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
: "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 ? (
|
{selectedEmailIds.size > 0 ? (
|
||||||
<CheckSquare className="w-4 h-4" />
|
<CheckSquare className="w-4 h-4" />
|
||||||
@@ -2317,6 +2526,8 @@ export default function Home() {
|
|||||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||||
data-search-input
|
data-search-input
|
||||||
data-tour="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 && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -2332,13 +2543,15 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAdvancedSearch}
|
onClick={toggleAdvancedSearch}
|
||||||
|
disabled={isUnifiedView || isScheduledView}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||||
|
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
|
||||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
: "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" />
|
<Filter className="w-4 h-4" />
|
||||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||||
@@ -2480,11 +2693,17 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
|
||||||
{hasMoreEmails
|
{activeHasMore
|
||||||
? t("advanced_search.results_found_more", { count: emails.length })
|
? t("advanced_search.results_found_more", { count: activeEmails.length })
|
||||||
: t("advanced_search.results_found", { count: emails.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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2493,9 +2712,35 @@ export default function Home() {
|
|||||||
|
|
||||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||||
<EmailList
|
<EmailList
|
||||||
emails={emails}
|
emails={activeEmails}
|
||||||
selectedEmailId={selectedEmail?.id}
|
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}
|
onEmailSelect={handleEmailSelect}
|
||||||
onEmailDoubleClick={isEmbedded ? ((email) => {
|
onEmailDoubleClick={isEmbedded ? ((email) => {
|
||||||
useProTabStore.getState().openEmailTab({
|
useProTabStore.getState().openEmailTab({
|
||||||
@@ -2656,6 +2901,14 @@ export default function Home() {
|
|||||||
await handleEmailSend(data);
|
await handleEmailSend(data);
|
||||||
setPendingDraft(null);
|
setPendingDraft(null);
|
||||||
}}
|
}}
|
||||||
|
onScheduledSendCreated={async () => {
|
||||||
|
if (client) {
|
||||||
|
await refreshScheduledMetadata(client);
|
||||||
|
if (isScheduledView) await fetchScheduledEmails(client);
|
||||||
|
}
|
||||||
|
setShowComposer(false);
|
||||||
|
setPendingDraft(null);
|
||||||
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
@@ -2751,6 +3004,24 @@ export default function Home() {
|
|||||||
onNavigatePrev={handleNavigatePrev}
|
onNavigatePrev={handleNavigatePrev}
|
||||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||||
onEditDraft={handleEditDraft}
|
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={() => {
|
onCompose={() => {
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const ACCOUNT_ID = 'dev-account-001';
|
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
|
// Mailboxes
|
||||||
@@ -1533,6 +1535,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
|
|||||||
bodyValues: {},
|
bodyValues: {},
|
||||||
};
|
};
|
||||||
emails.unshift(newEmail);
|
emails.unshift(newEmail);
|
||||||
|
emailCreationIds.set(key, newId);
|
||||||
created[key] = { id: 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];
|
return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult {
|
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];
|
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 {
|
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/get': handleIdentityGet,
|
||||||
'Identity/set': handleIdentitySet,
|
'Identity/set': handleIdentitySet,
|
||||||
'EmailSubmission/set': handleEmailSubmissionSet,
|
'EmailSubmission/set': handleEmailSubmissionSet,
|
||||||
|
'EmailSubmission/query': handleEmailSubmissionQuery,
|
||||||
|
'EmailSubmission/get': handleEmailSubmissionGet,
|
||||||
'Quota/get': handleQuotaGet,
|
'Quota/get': handleQuotaGet,
|
||||||
'VacationResponse/get': handleVacationResponseGet,
|
'VacationResponse/get': handleVacationResponseGet,
|
||||||
'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId],
|
'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,
|
isReadOnly: false,
|
||||||
accountCapabilities: {
|
accountCapabilities: {
|
||||||
'urn:ietf:params:jmap:mail': {},
|
'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:quota': {},
|
||||||
'urn:ietf:params:jmap:vacationresponse': {},
|
'urn:ietf:params:jmap:vacationresponse': {},
|
||||||
'urn:ietf:params:jmap:contacts': {},
|
'urn:ietf:params:jmap:contacts': {},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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, ChevronDown } from "lucide-react";
|
||||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
@@ -88,7 +88,9 @@ interface EmailComposerProps {
|
|||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||||
inReplyTo?: string[];
|
inReplyTo?: string[];
|
||||||
references?: string[];
|
references?: string[];
|
||||||
|
delayedUntil?: string;
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
|
onScheduledSendCreated?: () => void | Promise<void>;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onDiscardDraft?: (draftId: string) => void;
|
onDiscardDraft?: (draftId: string) => void;
|
||||||
onSaveState?: (data: ComposerDraftData) => void;
|
onSaveState?: (data: ComposerDraftData) => void;
|
||||||
@@ -168,8 +170,21 @@ function buildEmbeddedSignatureHtml(
|
|||||||
return '';
|
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({
|
export function EmailComposer({
|
||||||
onSend,
|
onSend,
|
||||||
|
onScheduledSendCreated,
|
||||||
onClose,
|
onClose,
|
||||||
onDiscardDraft,
|
onDiscardDraft,
|
||||||
onSaveState,
|
onSaveState,
|
||||||
@@ -187,6 +202,7 @@ export function EmailComposer({
|
|||||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||||
|
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||||
const activeIdentities = useIdentityStore((s) => s.identities);
|
const activeIdentities = useIdentityStore((s) => s.identities);
|
||||||
@@ -390,6 +406,12 @@ export function EmailComposer({
|
|||||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||||
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||||
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
||||||
|
const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>();
|
||||||
|
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
|
||||||
|
const [scheduleValue, setScheduleValue] = useState('');
|
||||||
|
const [scheduleError, setScheduleError] = useState('');
|
||||||
|
const [showSendMenu, setShowSendMenu] = useState(false);
|
||||||
|
const sendMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const saveTemplateModalRef = useFocusTrap({
|
const saveTemplateModalRef = useFocusTrap({
|
||||||
isActive: showSaveAsTemplate,
|
isActive: showSaveAsTemplate,
|
||||||
@@ -495,6 +517,23 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!autoSelectReplyIdentity) return;
|
if (!autoSelectReplyIdentity) return;
|
||||||
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||||
@@ -1184,6 +1223,33 @@ export function EmailComposer({
|
|||||||
return undefined;
|
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 (composerClient) {
|
||||||
|
const maxDelayedSend = composerClient.getMaxDelayedSend();
|
||||||
|
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
|
||||||
|
return t('schedule_send_too_late');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDelayedUntil = async (requestedDelayedUntil?: string): Promise<string | undefined> => {
|
||||||
|
if (requestedDelayedUntil) return requestedDelayedUntil;
|
||||||
|
if (sendDelaySeconds === 0) return undefined;
|
||||||
|
if (composerClient?.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:
|
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
|
||||||
// references so recipient clients that strip data URIs can still render them.
|
// references so recipient clients that strip data URIs can still render them.
|
||||||
const rewriteInlineImages = (html: string): {
|
const rewriteInlineImages = (html: string): {
|
||||||
@@ -1227,7 +1293,7 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = async (skipAttachmentCheck = false) => {
|
const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
|
||||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
|
|
||||||
@@ -1255,6 +1321,7 @@ export function EmailComposer({
|
|||||||
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
||||||
if (matched) {
|
if (matched) {
|
||||||
setAttachmentWarningKeyword(matched);
|
setAttachmentWarningKeyword(matched);
|
||||||
|
setAttachmentWarningDelayedUntil(delayedUntil);
|
||||||
setShowAttachmentWarning(true);
|
setShowAttachmentWarning(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1343,6 +1410,7 @@ export function EmailComposer({
|
|||||||
const inlineAttachments = rewritten?.attachments ?? [];
|
const inlineAttachments = rewritten?.attachments ?? [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil);
|
||||||
// Let plugins veto the send (external-mail warning, mistyped-domain
|
// Let plugins veto the send (external-mail warning, mistyped-domain
|
||||||
// guards, etc.). Returning false from any handler aborts before either
|
// guards, etc.). Returning false from any handler aborts before either
|
||||||
// the S/MIME or standard JMAP path runs.
|
// the S/MIME or standard JMAP path runs.
|
||||||
@@ -1492,7 +1560,16 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Send via raw email path
|
// 7. Send via raw email path
|
||||||
await sendRawEmail(client, payload, currentIdentity.id);
|
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses]);
|
||||||
|
if (effectiveDelayedUntil && finalDraftId) {
|
||||||
|
client.deleteEmail(finalDraftId).catch(err => {
|
||||||
|
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
|
||||||
|
toast.warning(t('schedule_send_cleanup_warning'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.scheduled) {
|
||||||
|
await onScheduledSendCreated?.();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Standard JMAP send path
|
// Standard JMAP send path
|
||||||
// Collect uploaded attachment blobIds for the send request
|
// Collect uploaded attachment blobIds for the send request
|
||||||
@@ -1542,6 +1619,7 @@ export function EmailComposer({
|
|||||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||||
inReplyTo: threadingHeaders?.inReplyTo,
|
inReplyTo: threadingHeaders?.inReplyTo,
|
||||||
references: threadingHeaders?.references,
|
references: threadingHeaders?.references,
|
||||||
|
delayedUntil: effectiveDelayedUntil,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mode === 'reply' || mode === 'replyAll') {
|
if (mode === 'reply' || mode === 'replyAll') {
|
||||||
@@ -1566,14 +1644,30 @@ export function EmailComposer({
|
|||||||
setDraftId(null);
|
setDraftId(null);
|
||||||
setSubAddressTag("");
|
setSubAddressTag("");
|
||||||
setValidationErrors({});
|
setValidationErrors({});
|
||||||
|
setShowScheduleDialog(false);
|
||||||
|
setScheduleValue('');
|
||||||
|
setScheduleError('');
|
||||||
// Clear ref so unmount effect doesn't re-save
|
// 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, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
debug.error('Failed to send email:', 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 (!composerClient?.hasDelayedSend()) {
|
||||||
|
setScheduleError(t('schedule_send_unsupported'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const error = validateScheduleValue(scheduleValue);
|
||||||
|
if (error) {
|
||||||
|
setScheduleError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSend(false, new Date(scheduleValue).toISOString());
|
||||||
|
};
|
||||||
|
|
||||||
// Ctrl+Enter (Win/Linux) / Cmd+Enter (macOS) sends the open compose
|
// Ctrl+Enter (Win/Linux) / Cmd+Enter (macOS) sends the open compose
|
||||||
// draft. Scoped to events whose target lives inside this composer's
|
// draft. Scoped to events whose target lives inside this composer's
|
||||||
// DOM tree — in Pro mode multiple composer tabs can be mounted at
|
// DOM tree — in Pro mode multiple composer tabs can be mounted at
|
||||||
@@ -1636,6 +1730,48 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleComposerKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
|
||||||
|
const isPlainEscape = e.key === 'Escape' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
|
||||||
|
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 ||
|
||||||
|
showSaveAsTemplate ||
|
||||||
|
showScheduleDialog ||
|
||||||
|
smimePassphrasePrompt ||
|
||||||
|
showAttachmentWarning ||
|
||||||
|
showCloseDialog
|
||||||
|
) return;
|
||||||
|
|
||||||
|
if (isPlainEscape) {
|
||||||
|
if (activeAutoField) return;
|
||||||
|
e.preventDefault();
|
||||||
|
handleClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSendShortcut) {
|
||||||
|
if (e.repeat) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isScheduleShortcut) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!e.repeat && composerClient?.hasDelayedSend()) openScheduleDialog();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
|
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
|
||||||
<PluginSlot
|
<PluginSlot
|
||||||
@@ -1650,6 +1786,7 @@ export function EmailComposer({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
onKeyDown={handleComposerKeyDown}
|
||||||
>
|
>
|
||||||
{/* Drag overlay */}
|
{/* Drag overlay */}
|
||||||
{isDraggingOver && (
|
{isDraggingOver && (
|
||||||
@@ -2077,7 +2214,6 @@ export function EmailComposer({
|
|||||||
>
|
>
|
||||||
<BookmarkPlus className="w-4 h-4" />
|
<BookmarkPlus className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* S/MIME toggles */}
|
{/* S/MIME toggles */}
|
||||||
{canSmimeSign && (
|
{canSmimeSign && (
|
||||||
<>
|
<>
|
||||||
@@ -2115,15 +2251,56 @@ export function EmailComposer({
|
|||||||
>
|
>
|
||||||
{t('discard')}
|
{t('discard')}
|
||||||
</button>
|
</button>
|
||||||
<Button
|
{composerClient?.hasDelayedSend() ? (
|
||||||
onClick={() => handleSend()}
|
<div ref={sendMenuRef} className="relative hidden md:inline-flex">
|
||||||
disabled={!canSend}
|
<Button
|
||||||
title={getSendTooltip()}
|
onClick={() => handleSend()}
|
||||||
className="hidden md:inline-flex"
|
disabled={!canSend}
|
||||||
>
|
title={getSendTooltip()}
|
||||||
<Send className="w-4 h-4 mr-2" />
|
className="rounded-r-none border-r border-primary-foreground/20"
|
||||||
{t('send')}
|
>
|
||||||
</Button>
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
{t('send')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSendMenu((open) => !open)}
|
||||||
|
disabled={!canSend}
|
||||||
|
title={t('schedule_send')}
|
||||||
|
className="rounded-l-none px-2"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={showSendMenu}
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{showSendMenu && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={openScheduleDialog}
|
||||||
|
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<CalendarClock className="w-4 h-4" />
|
||||||
|
{t('schedule_send')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleSend()}
|
||||||
|
disabled={!canSend}
|
||||||
|
title={getSendTooltip()}
|
||||||
|
className="hidden md:inline-flex"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
{t('send')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2162,6 +2339,29 @@ export function EmailComposer({
|
|||||||
</div>
|
</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 */}
|
{/* S/MIME passphrase prompt */}
|
||||||
{smimePassphrasePrompt && (
|
{smimePassphrasePrompt && (
|
||||||
<div
|
<div
|
||||||
@@ -2238,7 +2438,7 @@ export function EmailComposer({
|
|||||||
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
|
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
|
||||||
{t('forgot_attachment.back')}
|
{t('forgot_attachment.back')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
|
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true, attachmentWarningDelayedUntil); setAttachmentWarningDelayedUntil(undefined); }}>
|
||||||
{t('forgot_attachment.send_anyway')}
|
{t('forgot_attachment.send_anyway')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2485,6 +2685,11 @@ function RecipientChipInput({
|
|||||||
aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined}
|
aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined}
|
||||||
aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
|
aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
|
||||||
aria-invalid={validationError || undefined}
|
aria-invalid={validationError || undefined}
|
||||||
|
data-bwignore="true"
|
||||||
|
data-1p-ignore
|
||||||
|
data-op-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-form-type="other"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{validationError && validationMessage && (
|
{validationError && validationMessage && (
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
|
CalendarClock,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
@@ -63,6 +65,9 @@ interface EmailContextMenuProps {
|
|||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
onUndoSpam?: () => void;
|
onUndoSpam?: () => void;
|
||||||
onEditDraft?: () => void;
|
onEditDraft?: () => void;
|
||||||
|
onCancelScheduled?: () => void;
|
||||||
|
onCancelScheduledForEdit?: () => void;
|
||||||
|
onRescheduleScheduled?: () => void;
|
||||||
// Batch actions
|
// Batch actions
|
||||||
onBatchMarkAsRead?: (read: boolean) => void;
|
onBatchMarkAsRead?: (read: boolean) => void;
|
||||||
onBatchDelete?: () => void;
|
onBatchDelete?: () => void;
|
||||||
@@ -133,6 +138,9 @@ export function EmailContextMenu({
|
|||||||
onBatchMarkAsSpam,
|
onBatchMarkAsSpam,
|
||||||
onBatchUndoSpam,
|
onBatchUndoSpam,
|
||||||
onEditDraft,
|
onEditDraft,
|
||||||
|
onCancelScheduled,
|
||||||
|
onCancelScheduledForEdit,
|
||||||
|
onRescheduleScheduled,
|
||||||
}: EmailContextMenuProps) {
|
}: EmailContextMenuProps) {
|
||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
const _tColor = useTranslations("email_viewer.color_tag");
|
const _tColor = useTranslations("email_viewer.color_tag");
|
||||||
@@ -143,6 +151,8 @@ export function EmailContextMenu({
|
|||||||
const currentColors = getCurrentColors(email.keywords);
|
const currentColors = getCurrentColors(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
|
const isScheduled = email.isScheduled === true;
|
||||||
|
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||||
|
|
||||||
// Build color options from keyword definitions in settings
|
// Build color options from keyword definitions in settings
|
||||||
const colorOptions = emailKeywords.map((kw) => ({
|
const colorOptions = emailKeywords.map((kw) => ({
|
||||||
@@ -196,8 +206,36 @@ export function EmailContextMenu({
|
|||||||
</ContextMenuHeader>
|
</ContextMenuHeader>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isScheduled && !showBatchActions && canCancelScheduled && (
|
||||||
|
<>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCancelScheduled && <ContextMenuSeparator />}
|
||||||
|
|
||||||
|
{!isScheduled && (
|
||||||
|
<>
|
||||||
|
|
||||||
{/* Edit Draft - only for single draft emails */}
|
{/* Edit Draft - only for single draft emails */}
|
||||||
{!showBatchActions && isDraft && onEditDraft && (
|
{!isScheduled && !showBatchActions && isDraft && onEditDraft && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={EditIcon}
|
icon={EditIcon}
|
||||||
@@ -209,7 +247,7 @@ export function EmailContextMenu({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Single email actions - Reply, Reply All, Forward */}
|
{/* Single email actions - Reply, Reply All, Forward */}
|
||||||
{!showBatchActions && (
|
{!isScheduled && !showBatchActions && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Reply}
|
icon={Reply}
|
||||||
@@ -375,6 +413,8 @@ export function EmailContextMenu({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<PluginSlot name="context-menu-email" />
|
<PluginSlot name="context-menu-email" />
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
|||||||
import { ThreadListItem } from "./thread-list-item";
|
import { ThreadListItem } from "./thread-list-item";
|
||||||
import { EmailContextMenu } from "./email-context-menu";
|
import { EmailContextMenu } from "./email-context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
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 } from "lucide-react";
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
@@ -27,6 +27,8 @@ interface EmailListProps {
|
|||||||
onEmailDoubleClick?: (email: Email) => void;
|
onEmailDoubleClick?: (email: Email) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
hasMore?: boolean;
|
||||||
|
isLoadingMoreItems?: boolean;
|
||||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||||
onReply?: (email: Email) => void;
|
onReply?: (email: Email) => void;
|
||||||
onReplyAll?: (email: Email) => void;
|
onReplyAll?: (email: Email) => void;
|
||||||
@@ -40,6 +42,11 @@ interface EmailListProps {
|
|||||||
onMarkAsSpam?: (email: Email) => void;
|
onMarkAsSpam?: (email: Email) => void;
|
||||||
onUndoSpam?: (email: Email) => void;
|
onUndoSpam?: (email: Email) => void;
|
||||||
onEditDraft?: (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) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailList({
|
export function EmailList({
|
||||||
@@ -49,6 +56,8 @@ export function EmailList({
|
|||||||
onEmailDoubleClick,
|
onEmailDoubleClick,
|
||||||
className,
|
className,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
hasMore,
|
||||||
|
isLoadingMoreItems,
|
||||||
onOpenConversation,
|
onOpenConversation,
|
||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
@@ -62,6 +71,11 @@ export function EmailList({
|
|||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
onMoveToMailbox,
|
onMoveToMailbox,
|
||||||
onEditDraft,
|
onEditDraft,
|
||||||
|
isScheduledView = false,
|
||||||
|
onLoadMoreScheduled,
|
||||||
|
onCancelScheduled,
|
||||||
|
onCancelScheduledForEdit,
|
||||||
|
onRescheduleScheduled,
|
||||||
}: EmailListProps) {
|
}: EmailListProps) {
|
||||||
const t = useTranslations('email_list');
|
const t = useTranslations('email_list');
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
@@ -96,9 +110,9 @@ export function EmailList({
|
|||||||
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||||
|
|
||||||
const threadGroups = useMemo(() => {
|
const threadGroups = useMemo(() => {
|
||||||
const groups = groupEmailsByThread(emails, disableThreading);
|
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
|
||||||
return sortThreadGroups(groups);
|
return sortThreadGroups(groups);
|
||||||
}, [emails, disableThreading]);
|
}, [emails, disableThreading, isScheduledView]);
|
||||||
|
|
||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
@@ -108,6 +122,8 @@ export function EmailList({
|
|||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const footerHasMore = hasMore ?? hasMoreEmails;
|
||||||
|
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
// Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
|
// Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
|
||||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||||
@@ -219,10 +235,14 @@ export function EmailList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadMore = useCallback(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
|
if (isScheduledView) {
|
||||||
|
onLoadMoreScheduled?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
|
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
|
||||||
loadMoreEmails(client);
|
loadMoreEmails(client);
|
||||||
}
|
}
|
||||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
|
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
|
||||||
|
|
||||||
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
|
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
|
||||||
const isExpanded = expandedThreadIds.has(threadId);
|
const isExpanded = expandedThreadIds.has(threadId);
|
||||||
@@ -282,7 +302,7 @@ export function EmailList({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
"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">
|
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
|
||||||
@@ -406,17 +426,19 @@ export function EmailList({
|
|||||||
) : emails.length === 0 && !isLoading ? (
|
) : emails.length === 0 && !isLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full py-12">
|
<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">
|
<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" />
|
<SearchX className="w-10 h-10 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<MailX className="w-10 h-10 text-muted-foreground" />
|
<MailX className="w-10 h-10 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base font-medium text-foreground">
|
<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>
|
||||||
<p className="text-sm mt-1 text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -468,13 +490,13 @@ export function EmailList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="py-4 flex justify-center">
|
<div className="py-4 flex justify-center">
|
||||||
{isLoadingMore && hasMoreEmails && (
|
{footerIsLoadingMore && footerHasMore && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
<span>{t('loading_more')}</span>
|
<span>{t('loading_more')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasMoreEmails && emails.length > 0 && (
|
{!footerHasMore && emails.length > 0 && (
|
||||||
<div className="text-sm text-muted-foreground border-t border-border pt-6">
|
<div className="text-sm text-muted-foreground border-t border-border pt-6">
|
||||||
{t('no_more_emails')}
|
{t('no_more_emails')}
|
||||||
</div>
|
</div>
|
||||||
@@ -509,6 +531,9 @@ export function EmailList({
|
|||||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
||||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
||||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
||||||
|
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
|
||||||
|
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
|
||||||
|
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
|
||||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||||
onBatchDelete={() => client && batchDelete(client)}
|
onBatchDelete={() => client && batchDelete(client)}
|
||||||
onBatchArchive={async () => {
|
onBatchArchive={async () => {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import {
|
|||||||
EditIcon,
|
EditIcon,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
PenSquare,
|
PenSquare,
|
||||||
|
CalendarClock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
@@ -126,6 +127,9 @@ interface EmailViewerProps {
|
|||||||
onNavigatePrev?: () => void;
|
onNavigatePrev?: () => void;
|
||||||
onShowShortcuts?: () => void;
|
onShowShortcuts?: () => void;
|
||||||
onEditDraft?: () => void;
|
onEditDraft?: () => void;
|
||||||
|
onCancelScheduled?: () => void;
|
||||||
|
onCancelScheduledForEdit?: () => void;
|
||||||
|
onRescheduleScheduled?: (delayedUntil: string) => void;
|
||||||
onCompose?: () => void;
|
onCompose?: () => void;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
@@ -875,6 +879,9 @@ export function EmailViewer({
|
|||||||
onNavigatePrev,
|
onNavigatePrev,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onEditDraft,
|
onEditDraft,
|
||||||
|
onCancelScheduled,
|
||||||
|
onCancelScheduledForEdit,
|
||||||
|
onRescheduleScheduled,
|
||||||
onCompose,
|
onCompose,
|
||||||
currentUserEmail,
|
currentUserEmail,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
@@ -884,6 +891,7 @@ export function EmailViewer({
|
|||||||
className,
|
className,
|
||||||
}: EmailViewerProps) {
|
}: EmailViewerProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
|
const tComposer = useTranslations('email_composer');
|
||||||
const tNotifications = useTranslations('notifications');
|
const tNotifications = useTranslations('notifications');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tSmime = useTranslations('smime');
|
const tSmime = useTranslations('smime');
|
||||||
@@ -934,6 +942,8 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Detect if the email is a draft
|
// Detect if the email is a draft
|
||||||
const isDraft = email?.keywords?.['$draft'] === true;
|
const isDraft = email?.keywords?.['$draft'] === true;
|
||||||
|
const isScheduled = email?.isScheduled === true;
|
||||||
|
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
||||||
|
|
||||||
// Color options for email tags (from user-defined keyword settings)
|
// Color options for email tags (from user-defined keyword settings)
|
||||||
const colorOptions = emailKeywords.map((kw) => ({
|
const colorOptions = emailKeywords.map((kw) => ({
|
||||||
@@ -947,6 +957,29 @@ export function EmailViewer({
|
|||||||
const { tabletListVisible } = useUIStore();
|
const { tabletListVisible } = useUIStore();
|
||||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||||
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
|
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
|
||||||
|
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 resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const { startTour } = useTour();
|
const { startTour } = useTour();
|
||||||
const isEmbedded = useIsEmbedded();
|
const isEmbedded = useIsEmbedded();
|
||||||
@@ -3358,7 +3391,32 @@ export function EmailViewer({
|
|||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isDraft && onEditDraft && (
|
{isScheduled && canCancelScheduled && (
|
||||||
|
<>
|
||||||
|
<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 delayedUntil = promptForRescheduleDelayedUntil();
|
||||||
|
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
|
||||||
|
}}
|
||||||
|
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
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3370,7 +3428,7 @@ export function EmailViewer({
|
|||||||
<span className="text-sm">{t('edit_draft')}</span>
|
<span className="text-sm">{t('edit_draft')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isDraft && (<>
|
{!isScheduled && !isDraft && (<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3412,6 +3470,7 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
|
{/* 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">
|
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||||
{/* Archive */}
|
{/* Archive */}
|
||||||
<Button
|
<Button
|
||||||
@@ -3869,6 +3928,7 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3879,13 +3939,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)}
|
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 */}
|
{/* Mobile More menu sidebar overlay */}
|
||||||
{isMobile && moreMenuOpen && (
|
{!isScheduled && isMobile && moreMenuOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
|
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
|
||||||
onClick={() => setMoreMenuOpen(false)}
|
onClick={() => setMoreMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isMobile && (
|
{!isScheduled && isMobile && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden",
|
"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",
|
"transform transition-transform duration-300 ease-in-out",
|
||||||
@@ -4817,10 +4877,44 @@ export function EmailViewer({
|
|||||||
</div>
|
</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">
|
||||||
|
{canCancelScheduled && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const delayedUntil = promptForRescheduleDelayedUntil();
|
||||||
|
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 */}
|
{/* Draft Banner */}
|
||||||
{isDraft && (
|
{isDraft && (
|
||||||
<div className="border-b border-border bg-warning/10">
|
<div className="border-b border-border bg-warning/10">
|
||||||
<div className="max-w-4xl mx-auto px-6 py-2.5 flex items-center justify-between">
|
<div className="px-6 py-2.5 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-warning">
|
<div className="flex items-center gap-2 text-warning">
|
||||||
<File className="w-4 h-4" />
|
<File className="w-4 h-4" />
|
||||||
<span className="text-sm font-medium">{t('draft_banner')}</span>
|
<span className="text-sm font-medium">{t('draft_banner')}</span>
|
||||||
@@ -5289,7 +5383,7 @@ export function EmailViewer({
|
|||||||
<PluginSlot name="email-footer" />
|
<PluginSlot name="email-footer" />
|
||||||
|
|
||||||
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||||
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
{!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock } from "lucide-react";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
@@ -67,6 +67,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||||
@@ -78,6 +79,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||||
|
const scheduledSendLabel = email.isScheduled && email.scheduledSendAt
|
||||||
|
? formatDateTime(email.scheduledSendAt, timeFormat)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||||
const tagIds = getEmailColorTags(email.keywords);
|
const tagIds = getEmailColorTags(email.keywords);
|
||||||
@@ -241,12 +245,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{resolvedKeywordDefs.map((kd) => (
|
{resolvedKeywordDefs.map((kd) => (
|
||||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||||
))}
|
))}
|
||||||
<span className={cn(
|
{scheduledSendLabel ? (
|
||||||
'text-xs tabular-nums',
|
<span
|
||||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||||
)}>
|
title={scheduledSendLabel}
|
||||||
{formatDate(email.receivedAt)}
|
>
|
||||||
</span>
|
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">{scheduledSendLabel}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs tabular-nums',
|
||||||
|
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -299,14 +313,24 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{kd.label}
|
{kd.label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span className={cn(
|
{scheduledSendLabel ? (
|
||||||
"text-xs tabular-nums",
|
<span
|
||||||
isUnread
|
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||||
? "text-foreground font-semibold"
|
title={scheduledSendLabel}
|
||||||
: "text-muted-foreground"
|
>
|
||||||
)}>
|
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||||
{formatDate(email.receivedAt)}
|
<span className="truncate">{scheduledSendLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
isUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -335,16 +359,18 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
{!email.isScheduled && (
|
||||||
email={email}
|
<EmailHoverActions
|
||||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
email={email}
|
||||||
onToggleStar={onToggleStar}
|
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onToggleStar={onToggleStar}
|
||||||
onDelete={onDelete}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onArchive={onArchive}
|
onDelete={onDelete}
|
||||||
onSetColorTag={onSetColorTag}
|
onArchive={onArchive}
|
||||||
onMarkAsSpam={onMarkAsSpam}
|
onSetColorTag={onSetColorTag}
|
||||||
/>
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -374,6 +400,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
@@ -381,6 +408,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||||
|
const scheduledSendLabel = latestEmail.isScheduled && latestEmail.scheduledSendAt
|
||||||
|
? formatDateTime(latestEmail.scheduledSendAt, timeFormat)
|
||||||
|
: null;
|
||||||
|
|
||||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
@@ -646,12 +676,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{keywordDef && (
|
{keywordDef && (
|
||||||
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
||||||
)}
|
)}
|
||||||
<span className={cn(
|
{scheduledSendLabel ? (
|
||||||
'text-xs tabular-nums',
|
<span
|
||||||
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||||
)}>
|
title={scheduledSendLabel}
|
||||||
{formatDate(latestEmail.receivedAt)}
|
>
|
||||||
</span>
|
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">{scheduledSendLabel}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs tabular-nums',
|
||||||
|
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(latestEmail.receivedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -716,14 +756,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{keywordDef.label}
|
{keywordDef.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={cn(
|
{scheduledSendLabel ? (
|
||||||
"text-xs tabular-nums",
|
<span
|
||||||
hasUnread
|
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||||
? "text-foreground font-semibold"
|
title={scheduledSendLabel}
|
||||||
: "text-muted-foreground"
|
>
|
||||||
)}>
|
<CalendarClock className="h-3 w-3 shrink-0" />
|
||||||
{formatDate(latestEmail.receivedAt)}
|
<span className="truncate">{scheduledSendLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
hasUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(latestEmail.receivedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -752,16 +802,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover Quick Actions for thread header */}
|
{/* Hover Quick Actions for thread header */}
|
||||||
<EmailHoverActions
|
{!latestEmail.isScheduled && (
|
||||||
email={latestEmail}
|
<EmailHoverActions
|
||||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
email={latestEmail}
|
||||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||||
/>
|
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ interface SidebarProps {
|
|||||||
onDeleteFolder?: (mailboxId: string) => void;
|
onDeleteFolder?: (mailboxId: string) => void;
|
||||||
onImportEmail?: (mailboxId: string) => void;
|
onImportEmail?: (mailboxId: string) => void;
|
||||||
onRefreshMailboxes?: () => void;
|
onRefreshMailboxes?: () => void;
|
||||||
|
scheduledTotal?: number;
|
||||||
|
showScheduledMailbox?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
|
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
|
||||||
@@ -674,6 +676,8 @@ export function Sidebar({
|
|||||||
onDeleteFolder,
|
onDeleteFolder,
|
||||||
onImportEmail,
|
onImportEmail,
|
||||||
onRefreshMailboxes,
|
onRefreshMailboxes,
|
||||||
|
scheduledTotal = 0,
|
||||||
|
showScheduledMailbox = false,
|
||||||
className,
|
className,
|
||||||
multiAccountMode = false,
|
multiAccountMode = false,
|
||||||
accountMailboxes,
|
accountMailboxes,
|
||||||
@@ -1027,22 +1031,35 @@ export function Sidebar({
|
|||||||
{!isCollapsed && t("loading_mailboxes")}
|
{!isCollapsed && t("loading_mailboxes")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
tree.map((node) => (
|
<>
|
||||||
<MailboxTreeItem
|
{tree.map((node) => (
|
||||||
key={node.id}
|
<MailboxTreeItem
|
||||||
node={node}
|
key={node.id}
|
||||||
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
node={node}
|
||||||
expandedFolders={expandedFolders}
|
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
||||||
onMailboxSelect={(mailboxId) =>
|
expandedFolders={expandedFolders}
|
||||||
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
|
onMailboxSelect={(mailboxId) =>
|
||||||
}
|
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
|
||||||
onToggleExpand={handleToggleExpand}
|
}
|
||||||
isCollapsed={isCollapsed}
|
onToggleExpand={handleToggleExpand}
|
||||||
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
|
isCollapsed={isCollapsed}
|
||||||
colorful={colorfulSidebarIcons}
|
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
|
||||||
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
|
colorful={colorfulSidebarIcons}
|
||||||
/>
|
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
|
||||||
))
|
/>
|
||||||
|
))}
|
||||||
|
{isActive && showScheduledMailbox && (
|
||||||
|
<SidebarRow
|
||||||
|
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||||
|
label={t('scheduled')}
|
||||||
|
depth={0}
|
||||||
|
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
|
||||||
|
total={scheduledTotal}
|
||||||
|
onClick={() => onMailboxSelect?.('__scheduled__')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1067,20 +1084,33 @@ export function Sidebar({
|
|||||||
{!isCollapsed && t("loading_mailboxes")}
|
{!isCollapsed && t("loading_mailboxes")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
ownTree.map((node) => (
|
<>
|
||||||
<MailboxTreeItem
|
{ownTree.map((node) => (
|
||||||
key={node.id}
|
<MailboxTreeItem
|
||||||
node={node}
|
key={node.id}
|
||||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
node={node}
|
||||||
expandedFolders={expandedFolders}
|
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||||
onMailboxSelect={onMailboxSelect}
|
expandedFolders={expandedFolders}
|
||||||
onToggleExpand={handleToggleExpand}
|
onMailboxSelect={onMailboxSelect}
|
||||||
isCollapsed={isCollapsed}
|
onToggleExpand={handleToggleExpand}
|
||||||
onUnreadFilterClick={onUnreadFilterClick}
|
isCollapsed={isCollapsed}
|
||||||
colorful={colorfulSidebarIcons}
|
onUnreadFilterClick={onUnreadFilterClick}
|
||||||
onContextMenu={handleMailboxContextMenu}
|
colorful={colorfulSidebarIcons}
|
||||||
/>
|
onContextMenu={handleMailboxContextMenu}
|
||||||
))
|
/>
|
||||||
|
))}
|
||||||
|
{showScheduledMailbox && (
|
||||||
|
<SidebarRow
|
||||||
|
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||||
|
label={t('scheduled')}
|
||||||
|
depth={0}
|
||||||
|
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
|
||||||
|
total={scheduledTotal}
|
||||||
|
onClick={() => onMailboxSelect?.('__scheduled__')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
const client = useAuthStore((s) => s.client);
|
const client = useAuthStore((s) => s.client);
|
||||||
const sendEmail = useEmailStore((s) => s.sendEmail);
|
const sendEmail = useEmailStore((s) => s.sendEmail);
|
||||||
const fetchEmails = useEmailStore((s) => s.fetchEmails);
|
const fetchEmails = useEmailStore((s) => s.fetchEmails);
|
||||||
|
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
|
||||||
|
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
|
||||||
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||||
|
const isScheduledView = useEmailStore((s) => s.isScheduledView);
|
||||||
const closeTab = useProTabStore((s) => s.closeTab);
|
const closeTab = useProTabStore((s) => s.closeTab);
|
||||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||||
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
|
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
|
||||||
@@ -36,10 +39,18 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
const tabIdRef = useRef(tabId);
|
const tabIdRef = useRef(tabId);
|
||||||
tabIdRef.current = tabId;
|
tabIdRef.current = tabId;
|
||||||
|
|
||||||
|
const handleScheduledSendCreated = useCallback(async () => {
|
||||||
|
if (client) {
|
||||||
|
await refreshScheduledMetadata(client);
|
||||||
|
if (isScheduledView) await fetchScheduledEmails(client);
|
||||||
|
}
|
||||||
|
closeTab(tabIdRef.current);
|
||||||
|
}, [client, refreshScheduledMetadata, isScheduledView, fetchScheduledEmails, closeTab]);
|
||||||
|
|
||||||
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
|
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
try {
|
try {
|
||||||
await sendEmail(
|
const result = await sendEmail(
|
||||||
client,
|
client,
|
||||||
sendData.to,
|
sendData.to,
|
||||||
sendData.subject,
|
sendData.subject,
|
||||||
@@ -54,9 +65,15 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
sendData.attachments,
|
sendData.attachments,
|
||||||
sendData.inReplyTo,
|
sendData.inReplyTo,
|
||||||
sendData.references,
|
sendData.references,
|
||||||
|
sendData.delayedUntil,
|
||||||
sendData.envelopeMailFrom,
|
sendData.envelopeMailFrom,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (result.scheduled) {
|
||||||
|
await handleScheduledSendCreated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Mark the original message as $answered / $forwarded so the standard
|
// Mark the original message as $answered / $forwarded so the standard
|
||||||
// viewer and list reflect the action (same behaviour as inline compose).
|
// viewer and list reflect the action (same behaviour as inline compose).
|
||||||
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
|
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
|
||||||
@@ -81,7 +98,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
console.error('Failed to send email:', error);
|
console.error('Failed to send email:', error);
|
||||||
toast.error(t('notifications.error_sending'));
|
toast.error(t('notifications.error_sending'));
|
||||||
}
|
}
|
||||||
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t]);
|
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
closeTab(tabIdRef.current);
|
closeTab(tabIdRef.current);
|
||||||
@@ -125,6 +142,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
initialDraftText={data.initialDraftText}
|
initialDraftText={data.initialDraftText}
|
||||||
initialData={data.initialData}
|
initialData={data.initialData}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
onScheduledSendCreated={handleScheduledSendCreated}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onDiscardDraft={handleDiscardDraft}
|
onDiscardDraft={handleDiscardDraft}
|
||||||
onSaveState={handleSaveState}
|
onSaveState={handleSaveState}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
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 { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -22,11 +24,14 @@ export function ComposingSettings() {
|
|||||||
autoSelectReplyIdentity,
|
autoSelectReplyIdentity,
|
||||||
attachmentReminderEnabled,
|
attachmentReminderEnabled,
|
||||||
attachmentReminderKeywords,
|
attachmentReminderKeywords,
|
||||||
|
sendDelaySeconds,
|
||||||
subAddressDelimiter,
|
subAddressDelimiter,
|
||||||
signaturePosition,
|
signaturePosition,
|
||||||
signatureSeparatorEnabled,
|
signatureSeparatorEnabled,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
|
const { client } = useAuthStore();
|
||||||
|
const delayedSendSupported = client?.hasDelayedSend() ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
@@ -37,6 +42,24 @@ export function ComposingSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</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('signature_position.label')} description={t('signature_position.description')}>
|
<SettingItem label={t('signature_position.label')} description={t('signature_position.description')}>
|
||||||
<Select
|
<Select
|
||||||
value={signaturePosition}
|
value={signaturePosition}
|
||||||
|
|||||||
@@ -289,6 +289,8 @@ export const KEYBOARD_SHORTCUTS = {
|
|||||||
{ key: "x", description: "shortcuts.threads.expand_collapse" },
|
{ key: "x", description: "shortcuts.threads.expand_collapse" },
|
||||||
],
|
],
|
||||||
composer: [
|
composer: [
|
||||||
|
{ key: "Ctrl + Enter", description: "shortcuts.composer.send" },
|
||||||
|
{ key: "Ctrl + Shift + Enter", description: "shortcuts.composer.schedule_send" },
|
||||||
{ key: "t", description: "shortcuts.composer.template_picker" },
|
{ key: "t", description: "shortcuts.composer.template_picker" },
|
||||||
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
|
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,6 +11,34 @@ function createClient(): JMAPClient {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enableDelayedSend(client: JMAPClient) {
|
||||||
|
Object.assign(client, {
|
||||||
|
capabilities: {
|
||||||
|
'urn:ietf:params:jmap:core': {},
|
||||||
|
'urn:ietf:params:jmap:mail': {},
|
||||||
|
'urn:ietf:params:jmap:submission': {},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
primaryAccounts: {
|
||||||
|
'urn:ietf:params:jmap:mail': 'account-1',
|
||||||
|
'urn:ietf:params:jmap:submission': 'submission-account-1',
|
||||||
|
},
|
||||||
|
accounts: {
|
||||||
|
'account-1': {
|
||||||
|
accountCapabilities: {
|
||||||
|
'urn:ietf:params:jmap:mail': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'submission-account-1': {
|
||||||
|
accountCapabilities: {
|
||||||
|
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface JMAPMethodCall {
|
interface JMAPMethodCall {
|
||||||
0: string;
|
0: string;
|
||||||
1: Record<string, unknown>;
|
1: Record<string, unknown>;
|
||||||
@@ -62,7 +90,7 @@ function mockSendEmailFlow() {
|
|||||||
payload = {
|
payload = {
|
||||||
methodResponses: [
|
methodResponses: [
|
||||||
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'],
|
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'],
|
||||||
['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'],
|
['EmailSubmission/set', { created: { '1': { id: 'sub-1', sendAt: '2026-05-08T18:00:00Z' } } }, '1'],
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -167,4 +195,72 @@ describe('JMAPClient.sendEmail threading headers', () => {
|
|||||||
expect(draft.inReplyTo).toEqual(['real@example.com']);
|
expect(draft.inReplyTo).toEqual(['real@example.com']);
|
||||||
expect(draft.references).toBeUndefined();
|
expect(draft.references).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses FUTURERELEASE envelope and submission capability for scheduled sends', async () => {
|
||||||
|
const client = createClient();
|
||||||
|
enableDelayedSend(client);
|
||||||
|
const captured = mockSendEmailFlow();
|
||||||
|
const delayedUntil = new Date(Date.now() + 60_000).toISOString();
|
||||||
|
|
||||||
|
const result = await client.sendEmail(
|
||||||
|
['recipient@example.com'],
|
||||||
|
'Scheduled test',
|
||||||
|
'body',
|
||||||
|
undefined, undefined, 'identity-1', 'user@example.com',
|
||||||
|
undefined, undefined, undefined, undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
delayedUntil,
|
||||||
|
);
|
||||||
|
|
||||||
|
const identityRequest = captured[1];
|
||||||
|
expect(identityRequest.using).toContain('urn:ietf:params:jmap:submission');
|
||||||
|
const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set');
|
||||||
|
expect(submissionCall?.[1].accountId).toBe('submission-account-1');
|
||||||
|
expect(submissionCall?.[1].create).toEqual({
|
||||||
|
'1': {
|
||||||
|
emailId: expect.stringMatching(/^#send-/),
|
||||||
|
identityId: 'identity-1',
|
||||||
|
envelope: {
|
||||||
|
mailFrom: {
|
||||||
|
email: 'user@example.com',
|
||||||
|
parameters: { HOLDFOR: expect.stringMatching(/^\d+$/) },
|
||||||
|
},
|
||||||
|
rcptTo: [{ email: 'recipient@example.com' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(submissionCall?.[1].create)).not.toContain('sendAt');
|
||||||
|
expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: '2026-05-08T18:00:00Z' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up replacement submission if canceling the original fails during reschedule', async () => {
|
||||||
|
const client = createClient();
|
||||||
|
enableDelayedSend(client);
|
||||||
|
vi.spyOn(client, 'getMailboxes').mockResolvedValue([
|
||||||
|
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
|
||||||
|
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
|
||||||
|
] as never);
|
||||||
|
vi.spyOn(client, 'getIdentities').mockResolvedValue([
|
||||||
|
{ id: 'identity-1', name: 'User', email: 'user@example.com', mayDelete: false },
|
||||||
|
]);
|
||||||
|
const requestSpy = vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
|
||||||
|
.mockImplementation(async (methodCalls) => {
|
||||||
|
const args = methodCalls[0][1] as { create?: unknown; update?: Record<string, unknown> };
|
||||||
|
if (args.create) {
|
||||||
|
return { methodResponses: [['EmailSubmission/set', { created: { replacement: { id: 'sub-new' } } }, '0']] };
|
||||||
|
}
|
||||||
|
if (args.update?.['sub-old']) {
|
||||||
|
return { methodResponses: [['EmailSubmission/set', { notUpdated: { 'sub-old': { type: 'cannotUnsend' } } }, '0']] };
|
||||||
|
}
|
||||||
|
return { methodResponses: [['EmailSubmission/set', { updated: { 'sub-new': null } }, '0']] };
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.rescheduleEmailSubmission('sub-old', 'email-1', 'identity-1', new Date(Date.now() + 60_000).toISOString()))
|
||||||
|
.rejects.toThrow('could not cancel the original');
|
||||||
|
|
||||||
|
expect(requestSpy).toHaveBeenCalledWith(expect.arrayContaining([
|
||||||
|
expect.arrayContaining(['EmailSubmission/set', expect.objectContaining({ update: { 'sub-new': { undoStatus: 'canceled' } } })]),
|
||||||
|
]));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+98
-8
@@ -1,5 +1,5 @@
|
|||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from '@/lib/jmap/types';
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult } from '@/lib/jmap/types';
|
||||||
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||||
import { getDemoData, type DemoData } from './demo-data';
|
import { getDemoData, type DemoData } from './demo-data';
|
||||||
import { generateDemoId } from './demo-utils';
|
import { generateDemoId } from './demo-utils';
|
||||||
@@ -11,6 +11,7 @@ import { generateDemoId } from './demo-utils';
|
|||||||
export class DemoJMAPClient implements IJMAPClient {
|
export class DemoJMAPClient implements IJMAPClient {
|
||||||
private data: DemoData;
|
private data: DemoData;
|
||||||
private blobStore = new Map<string, Blob>();
|
private blobStore = new Map<string, Blob>();
|
||||||
|
private scheduledSubmissions = new Map<string, { id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled'; isSmime: boolean }>();
|
||||||
private connectionCallback: ((connected: boolean) => void) | null = null;
|
private connectionCallback: ((connected: boolean) => void) | null = null;
|
||||||
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
||||||
private lastStates: AccountStates = {};
|
private lastStates: AccountStates = {};
|
||||||
@@ -49,15 +50,15 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
// ── Capabilities ──────────────────────────────────────────────
|
// ── Capabilities ──────────────────────────────────────────────
|
||||||
|
|
||||||
hasAccountCapability(_capability: string, _accountId?: string): boolean {
|
hasAccountCapability(capability: string, _accountId?: string): boolean {
|
||||||
return false;
|
return capability === 'urn:ietf:params:jmap:submission';
|
||||||
}
|
}
|
||||||
|
|
||||||
getCapabilities(): Record<string, unknown> {
|
getCapabilities(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
|
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
|
||||||
'urn:ietf:params:jmap:mail': {},
|
'urn:ietf:params:jmap:mail': {},
|
||||||
'urn:ietf:params:jmap:submission': {},
|
'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } },
|
||||||
'urn:ietf:params:jmap:vacationresponse': {},
|
'urn:ietf:params:jmap:vacationresponse': {},
|
||||||
'urn:ietf:params:jmap:contacts': {},
|
'urn:ietf:params:jmap:contacts': {},
|
||||||
'urn:ietf:params:jmap:calendars': {},
|
'urn:ietf:params:jmap:calendars': {},
|
||||||
@@ -70,6 +71,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
getMaxSizeUpload(): number { return 50_000_000; }
|
getMaxSizeUpload(): number { return 50_000_000; }
|
||||||
getMaxCallsInRequest(): number { return 16; }
|
getMaxCallsInRequest(): number { return 16; }
|
||||||
getMaxObjectsInGet(): number { return 500; }
|
getMaxObjectsInGet(): number { return 500; }
|
||||||
|
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
|
||||||
|
hasDelayedSend(): boolean { return true; }
|
||||||
getEventSourceUrl(): string | null { return null; }
|
getEventSourceUrl(): string | null { return null; }
|
||||||
supportsEmailSubmission(): boolean { return true; }
|
supportsEmailSubmission(): boolean { return true; }
|
||||||
supportsQuota(): boolean { return true; }
|
supportsQuota(): boolean { return true; }
|
||||||
@@ -293,6 +296,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
emails: Array<{ id: string; receivedAt: string }>,
|
emails: Array<{ id: string; receivedAt: string }>,
|
||||||
archiveMailboxId: string,
|
archiveMailboxId: string,
|
||||||
mode: 'single' | 'year' | 'month',
|
mode: 'single' | 'year' | 'month',
|
||||||
|
_existingMailboxes: Mailbox[],
|
||||||
|
_accountId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (emails.length === 0) return;
|
if (emails.length === 0) return;
|
||||||
if (mode === 'single') {
|
if (mode === 'single') {
|
||||||
@@ -456,7 +461,9 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||||
inReplyTo?: string[],
|
inReplyTo?: string[],
|
||||||
references?: string[],
|
references?: string[],
|
||||||
): Promise<void> {
|
delayedUntil?: string,
|
||||||
|
_envelopeMailFrom?: string,
|
||||||
|
): Promise<SendEmailResult> {
|
||||||
// Remove draft if updating
|
// Remove draft if updating
|
||||||
if (draftId) {
|
if (draftId) {
|
||||||
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
|
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
|
||||||
@@ -464,8 +471,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
|
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
|
||||||
const email: Email = {
|
const email: Email = {
|
||||||
id: generateDemoId('email'), threadId: generateDemoId('thread'),
|
id: generateDemoId('email'), threadId: generateDemoId('thread'),
|
||||||
mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true },
|
mailboxIds: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: true },
|
||||||
keywords: { $seen: true },
|
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
|
||||||
size: body.length + (htmlBody?.length || 0),
|
size: body.length + (htmlBody?.length || 0),
|
||||||
receivedAt: new Date().toISOString(),
|
receivedAt: new Date().toISOString(),
|
||||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
@@ -485,7 +492,22 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
references: references?.length ? references : undefined,
|
references: references?.length ? references : undefined,
|
||||||
};
|
};
|
||||||
this.data.emails.push(email);
|
this.data.emails.push(email);
|
||||||
|
let emailSubmissionId: string | undefined;
|
||||||
|
if (delayedUntil) {
|
||||||
|
emailSubmissionId = generateDemoId('submission');
|
||||||
|
this.scheduledSubmissions.set(emailSubmissionId, {
|
||||||
|
id: emailSubmissionId,
|
||||||
|
emailId: email.id,
|
||||||
|
identityId: _identityId || 'demo-identity',
|
||||||
|
sendAt: delayedUntil,
|
||||||
|
undoStatus: 'pending',
|
||||||
|
isSmime: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
this.recalcMailboxCounts();
|
this.recalcMailboxCounts();
|
||||||
|
return delayedUntil
|
||||||
|
? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt: delayedUntil }
|
||||||
|
: { scheduled: false, emailId: email.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendImipReply(): Promise<void> { /* no-op in demo */ }
|
async sendImipReply(): Promise<void> { /* no-op in demo */ }
|
||||||
@@ -918,7 +940,75 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
||||||
async submitEmail(): Promise<void> { /* no-op */ }
|
async submitEmail(): Promise<void> { /* no-op */ }
|
||||||
async sendRawEmail(): Promise<void> { /* no-op */ }
|
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise<SendEmailResult> {
|
||||||
|
const emailId = generateDemoId('email');
|
||||||
|
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
|
||||||
|
const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent');
|
||||||
|
const email: Email = {
|
||||||
|
id: emailId,
|
||||||
|
threadId: generateDemoId('thread'),
|
||||||
|
mailboxIds: { [(delayedUntil ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true },
|
||||||
|
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
|
||||||
|
size: 1024,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
to: [],
|
||||||
|
subject: 'S/MIME message',
|
||||||
|
preview: 'Signed/encrypted demo message',
|
||||||
|
hasAttachment: false,
|
||||||
|
};
|
||||||
|
this.data.emails.push(email);
|
||||||
|
let emailSubmissionId: string | undefined;
|
||||||
|
if (delayedUntil) {
|
||||||
|
emailSubmissionId = generateDemoId('submission');
|
||||||
|
this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: true });
|
||||||
|
}
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
|
||||||
|
const pending = Array.from(this.scheduledSubmissions.values())
|
||||||
|
.filter(s => s.undoStatus === 'pending')
|
||||||
|
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
|
||||||
|
const page = pending.slice(position, position + limit);
|
||||||
|
const emails = page.map((submission) => {
|
||||||
|
const email = this.data.emails.find(e => e.id === submission.emailId);
|
||||||
|
if (!email) return null;
|
||||||
|
return {
|
||||||
|
...email,
|
||||||
|
scheduledSendAt: submission.sendAt,
|
||||||
|
emailSubmissionId: submission.id,
|
||||||
|
scheduledIdentityId: submission.identityId,
|
||||||
|
scheduledUndoStatus: submission.undoStatus,
|
||||||
|
isScheduled: true,
|
||||||
|
isSmimeScheduled: submission.isSmime,
|
||||||
|
} satisfies ScheduledEmail;
|
||||||
|
}).filter((email): email is ScheduledEmail => email !== null);
|
||||||
|
const nextPosition = position + page.length;
|
||||||
|
return { emails, hasMore: nextPosition < pending.length, total: pending.length, nextPosition };
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelEmailSubmission(submissionId: string): Promise<void> {
|
||||||
|
const submission = this.scheduledSubmissions.get(submissionId);
|
||||||
|
if (submission) submission.undoStatus = 'canceled';
|
||||||
|
}
|
||||||
|
|
||||||
|
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
|
||||||
|
await this.cancelEmailSubmission(submissionId);
|
||||||
|
const replacement = generateDemoId('submission');
|
||||||
|
this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: false });
|
||||||
|
return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt: delayedUntil };
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (!email) return;
|
||||||
|
email.mailboxIds[draftMailboxId] = true;
|
||||||
|
if (sentMailboxId) delete email.mailboxIds[sentMailboxId];
|
||||||
|
email.keywords.$draft = true;
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal helpers ──────────────────────────────────────────
|
// ── Internal helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription } from "./types";
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types";
|
||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +31,8 @@ export interface IJMAPClient {
|
|||||||
getMaxSizeUpload(): number;
|
getMaxSizeUpload(): number;
|
||||||
getMaxCallsInRequest(): number;
|
getMaxCallsInRequest(): number;
|
||||||
getMaxObjectsInGet(): number;
|
getMaxObjectsInGet(): number;
|
||||||
|
getMaxDelayedSend(accountId?: string): number;
|
||||||
|
hasDelayedSend(accountId?: string): boolean;
|
||||||
getEventSourceUrl(): string | null;
|
getEventSourceUrl(): string | null;
|
||||||
supportsEmailSubmission(): boolean;
|
supportsEmailSubmission(): boolean;
|
||||||
supportsQuota(): boolean;
|
supportsQuota(): boolean;
|
||||||
@@ -146,8 +148,15 @@ export interface IJMAPClient {
|
|||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||||
inReplyTo?: string[],
|
inReplyTo?: string[],
|
||||||
references?: string[],
|
references?: string[],
|
||||||
|
delayedUntil?: string,
|
||||||
envelopeMailFrom?: string,
|
envelopeMailFrom?: string,
|
||||||
): Promise<void>;
|
): Promise<SendEmailResult>;
|
||||||
|
|
||||||
|
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
|
||||||
|
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
|
||||||
|
cancelEmailSubmission(submissionId: string): Promise<void>;
|
||||||
|
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
|
||||||
|
restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void>;
|
||||||
|
|
||||||
sendImipReply(opts: {
|
sendImipReply(opts: {
|
||||||
organizerEmail: string;
|
organizerEmail: string;
|
||||||
@@ -285,5 +294,4 @@ export interface IJMAPClient {
|
|||||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
||||||
submitEmail(emailId: string, identityId: string): Promise<void>;
|
submitEmail(emailId: string, identityId: string): Promise<void>;
|
||||||
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+385
-17
@@ -1,4 +1,4 @@
|
|||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription } from "./types";
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types";
|
||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
import type { IJMAPClient } from "./client-interface";
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
import { toWildcardQuery } from "./search-utils";
|
||||||
@@ -61,6 +61,12 @@ interface JMAPEmailHeader {
|
|||||||
|
|
||||||
type JMAPMethodCall = [string, Record<string, unknown>, string];
|
type JMAPMethodCall = [string, Record<string, unknown>, string];
|
||||||
|
|
||||||
|
const SUBMISSION_USING = [
|
||||||
|
'urn:ietf:params:jmap:core',
|
||||||
|
'urn:ietf:params:jmap:mail',
|
||||||
|
'urn:ietf:params:jmap:submission',
|
||||||
|
] as const;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type JMAPResponseResult = Record<string, any>;
|
type JMAPResponseResult = Record<string, any>;
|
||||||
|
|
||||||
@@ -312,6 +318,27 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
|
|||||||
return emailCount === limit;
|
return emailCount === limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean {
|
||||||
|
return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSmimeEmail(email: Email): boolean {
|
||||||
|
const types: string[] = [];
|
||||||
|
const collect = (part: Email['bodyStructure']): void => {
|
||||||
|
if (!part) return;
|
||||||
|
if (part.type) types.push(part.type.toLowerCase());
|
||||||
|
part.subParts?.forEach(collect);
|
||||||
|
};
|
||||||
|
collect(email.bodyStructure);
|
||||||
|
email.attachments?.forEach(att => types.push((att.type || '').toLowerCase()));
|
||||||
|
return types.some(type =>
|
||||||
|
type.includes('pkcs7') ||
|
||||||
|
type.includes('x-pkcs7') ||
|
||||||
|
type === 'application/pkcs7-mime' ||
|
||||||
|
type === 'application/pkcs7-signature'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fold a single iCalendar content line per RFC 5545 §3.1.
|
* Fold a single iCalendar content line per RFC 5545 §3.1.
|
||||||
* Lines longer than 75 octets MUST be split with CRLF + a single linear white space character.
|
* Lines longer than 75 octets MUST be split with CRLF + a single linear white space character.
|
||||||
@@ -347,6 +374,33 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string {
|
|||||||
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
|
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEnvelopeRecipients(recipients?: Array<string | EmailAddress>): Array<{ email: string }> {
|
||||||
|
return (recipients || [])
|
||||||
|
.map((recipient) => typeof recipient === 'string' ? recipient : recipient.email)
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((email) => ({ email }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: number, recipients?: Array<string | EmailAddress>): Record<string, unknown> | undefined {
|
||||||
|
if (!holdForSeconds) return undefined;
|
||||||
|
const rcptTo = normalizeEnvelopeRecipients(recipients);
|
||||||
|
return {
|
||||||
|
mailFrom: {
|
||||||
|
email: fromEmail,
|
||||||
|
parameters: {
|
||||||
|
HOLDFOR: String(holdForSeconds),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rcptTo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmissionCapability = {
|
||||||
|
maxDelayedSend?: number;
|
||||||
|
submissionExtensions?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export class JMAPClient implements IJMAPClient {
|
export class JMAPClient implements IJMAPClient {
|
||||||
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
||||||
|
|
||||||
@@ -703,7 +757,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
using: using || (hasSubmissionMethod(methodCalls) ? [...SUBMISSION_USING] : ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]),
|
||||||
methodCalls,
|
methodCalls,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2094,8 +2148,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||||
inReplyTo?: string[],
|
inReplyTo?: string[],
|
||||||
references?: string[],
|
references?: string[],
|
||||||
|
delayedUntil?: string,
|
||||||
envelopeMailFrom?: string
|
envelopeMailFrom?: string
|
||||||
): Promise<void> {
|
): Promise<SendEmailResult> {
|
||||||
|
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||||||
const emailId = `send-${Date.now()}`;
|
const emailId = `send-${Date.now()}`;
|
||||||
const mailboxes = await this.getMailboxes();
|
const mailboxes = await this.getMailboxes();
|
||||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
@@ -2197,12 +2253,19 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
// e.g. sending from a domain-catch-all alias without a dedicated Identity),
|
// e.g. sending from a domain-catch-all alias without a dedicated Identity),
|
||||||
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
|
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
|
||||||
// is omitted the server derives mailFrom from the Identity.
|
// is omitted the server derives mailFrom from the Identity.
|
||||||
const submissionCreate = (submissionId: string): Record<string, unknown> => {
|
const buildSubmissionCreate = (submissionId: string): Record<string, unknown> => {
|
||||||
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
|
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
|
||||||
if (envelopeMailFrom) {
|
if (holdForSeconds || envelopeMailFrom) {
|
||||||
|
const envelopeRecipients = [...to, ...(cc || []), ...(bcc || [])]
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((email) => ({ email }));
|
||||||
create.envelope = {
|
create.envelope = {
|
||||||
mailFrom: { email: envelopeMailFrom },
|
mailFrom: {
|
||||||
rcptTo: [...to, ...(cc || []), ...(bcc || [])].map((email) => ({ email })),
|
email: envelopeMailFrom || fromEmail || this.username,
|
||||||
|
...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}),
|
||||||
|
},
|
||||||
|
rcptTo: envelopeRecipients,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { [submissionId]: create };
|
return { [submissionId]: create };
|
||||||
@@ -2219,8 +2282,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
create: { [emailId]: emailCreate },
|
create: { [emailId]: emailCreate },
|
||||||
}, "1"]);
|
}, "1"]);
|
||||||
methodCalls.push(["EmailSubmission/set", {
|
methodCalls.push(["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(),
|
||||||
create: submissionCreate("1"),
|
create: buildSubmissionCreate("1"),
|
||||||
onSuccessUpdateEmail,
|
onSuccessUpdateEmail,
|
||||||
}, "2"]);
|
}, "2"]);
|
||||||
} else {
|
} else {
|
||||||
@@ -2229,14 +2292,18 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
create: { [emailId]: emailCreate },
|
create: { [emailId]: emailCreate },
|
||||||
}, "0"]);
|
}, "0"]);
|
||||||
methodCalls.push(["EmailSubmission/set", {
|
methodCalls.push(["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(),
|
||||||
create: submissionCreate("1"),
|
create: buildSubmissionCreate("1"),
|
||||||
onSuccessUpdateEmail,
|
onSuccessUpdateEmail,
|
||||||
}, "1"]);
|
}, "1"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.request(methodCalls);
|
const response = await this.request(methodCalls);
|
||||||
|
|
||||||
|
let createdEmailId: string | undefined;
|
||||||
|
let emailSubmissionId: string | undefined;
|
||||||
|
let serverSendAt: string | undefined;
|
||||||
|
|
||||||
if (response.methodResponses) {
|
if (response.methodResponses) {
|
||||||
for (const [methodName, result] of response.methodResponses) {
|
for (const [methodName, result] of response.methodResponses) {
|
||||||
if (methodName.endsWith('/error')) {
|
if (methodName.endsWith('/error')) {
|
||||||
@@ -2268,8 +2335,24 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
`${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`,
|
`${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (methodName === 'Email/set' && result.created?.[emailId]?.id) {
|
||||||
|
createdEmailId = result.created[emailId].id;
|
||||||
|
}
|
||||||
|
if (methodName === 'EmailSubmission/set' && result.created?.['1']?.id) {
|
||||||
|
emailSubmissionId = result.created['1'].id;
|
||||||
|
serverSendAt = result.created['1'].sendAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (delayedUntil && emailSubmissionId && !serverSendAt) {
|
||||||
|
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return delayedUntil
|
||||||
|
? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt }
|
||||||
|
: { scheduled: false, emailId: createdEmailId, emailSubmissionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2431,7 +2514,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
create: { [emailId]: emailCreate },
|
create: { [emailId]: emailCreate },
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["EmailSubmission/set", {
|
["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(),
|
||||||
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
||||||
onSuccessUpdateEmail: {
|
onSuccessUpdateEmail: {
|
||||||
"#sub-1": {
|
"#sub-1": {
|
||||||
@@ -2609,7 +2692,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
create: { [emailId]: emailCreate },
|
create: { [emailId]: emailCreate },
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["EmailSubmission/set", {
|
["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(),
|
||||||
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
||||||
onSuccessUpdateEmail: {
|
onSuccessUpdateEmail: {
|
||||||
"#sub-1": {
|
"#sub-1": {
|
||||||
@@ -2761,7 +2844,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
create: { [emailId]: emailCreate },
|
create: { [emailId]: emailCreate },
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["EmailSubmission/set", {
|
["EmailSubmission/set", {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(),
|
||||||
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
||||||
onSuccessUpdateEmail: {
|
onSuccessUpdateEmail: {
|
||||||
"#sub-1": {
|
"#sub-1": {
|
||||||
@@ -2975,6 +3058,85 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return coreCapability?.maxObjectsInGet || 500;
|
return coreCapability?.maxObjectsInGet || 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMaxDelayedSend(accountId?: string): number {
|
||||||
|
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
|
||||||
|
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDelayedSend(accountId?: string): boolean {
|
||||||
|
const submissionCapability = this.getSubmissionCapability(accountId);
|
||||||
|
const submissionExtensions = submissionCapability?.submissionExtensions;
|
||||||
|
const hasFutureRelease = this.hasSubmissionExtension(submissionExtensions, 'FUTURERELEASE');
|
||||||
|
|
||||||
|
return !!submissionCapability
|
||||||
|
&& hasFutureRelease
|
||||||
|
&& this.getMaxDelayedSend(accountId) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateDelayedUntil(delayedUntil: string, accountId?: string): number {
|
||||||
|
const time = new Date(delayedUntil).getTime();
|
||||||
|
if (!Number.isFinite(time)) {
|
||||||
|
throw new Error('Scheduled send time is invalid');
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (time <= now) {
|
||||||
|
throw new Error('Scheduled send time must be in the future');
|
||||||
|
}
|
||||||
|
const maxDelayedSend = this.getMaxDelayedSend(accountId);
|
||||||
|
if (!this.hasDelayedSend(accountId) || maxDelayedSend <= 0) {
|
||||||
|
throw new Error('Scheduled send is not supported for this account');
|
||||||
|
}
|
||||||
|
if (time > now + maxDelayedSend * 1000) {
|
||||||
|
throw new Error('Scheduled send time is later than the server allows');
|
||||||
|
}
|
||||||
|
return Math.ceil((time - now) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEmailSubmissionSendAt(submissionId: string): Promise<string | undefined> {
|
||||||
|
const response = await this.request([
|
||||||
|
['EmailSubmission/get', {
|
||||||
|
accountId: this.getSubmissionAccountId(),
|
||||||
|
ids: [submissionId],
|
||||||
|
properties: ['sendAt', 'undoStatus'],
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { sendAt?: string } | undefined;
|
||||||
|
return submission?.sendAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEmailSubmissionEnvelope(submissionId: string): Promise<{ rcptTo?: Array<{ email: string }> } | undefined> {
|
||||||
|
const response = await this.request([
|
||||||
|
['EmailSubmission/get', {
|
||||||
|
accountId: this.getSubmissionAccountId(),
|
||||||
|
ids: [submissionId],
|
||||||
|
properties: ['envelope'],
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { envelope?: { rcptTo?: Array<{ email: string }> } } | undefined;
|
||||||
|
return submission?.envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSubmissionAccountId(accountId?: string): string {
|
||||||
|
return accountId || this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'] || this.accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined {
|
||||||
|
const submissionAccountId = this.getSubmissionAccountId(accountId);
|
||||||
|
return this.session?.accounts?.[submissionAccountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission'] as SubmissionCapability | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasSubmissionExtension(submissionExtensions: unknown, extension: string): boolean {
|
||||||
|
const target = extension.toUpperCase();
|
||||||
|
if (Array.isArray(submissionExtensions)) {
|
||||||
|
return submissionExtensions.some(item => typeof item === 'string' && item.toUpperCase() === target);
|
||||||
|
}
|
||||||
|
if (submissionExtensions && typeof submissionExtensions === 'object') {
|
||||||
|
return Object.entries(submissionExtensions as Record<string, unknown>)
|
||||||
|
.some(([key, value]) => key.toUpperCase() === target && value !== false && value != null);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
getEventSourceUrl(): string | null {
|
getEventSourceUrl(): string | null {
|
||||||
if (!this.session) return null;
|
if (!this.session) return null;
|
||||||
|
|
||||||
@@ -5430,7 +5592,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
async submitEmail(emailId: string, identityId: string): Promise<void> {
|
async submitEmail(emailId: string, identityId: string): Promise<void> {
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
['EmailSubmission/set', {
|
['EmailSubmission/set', {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(),
|
||||||
create: { 'smime-submit': { emailId, identityId } },
|
create: { 'smime-submit': { emailId, identityId } },
|
||||||
}, '0'],
|
}, '0'],
|
||||||
]);
|
]);
|
||||||
@@ -5451,7 +5613,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
identityId: string,
|
identityId: string,
|
||||||
sentMailboxId: string,
|
sentMailboxId: string,
|
||||||
draftMailboxId?: string,
|
draftMailboxId?: string,
|
||||||
): Promise<void> {
|
delayedUntil?: string,
|
||||||
|
envelopeRecipients?: string[],
|
||||||
|
): Promise<SendEmailResult> {
|
||||||
|
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||||||
// Upload the raw message
|
// Upload the raw message
|
||||||
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
||||||
const { blobId } = await this.uploadBlob(file);
|
const { blobId } = await this.uploadBlob(file);
|
||||||
@@ -5459,6 +5624,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
// Import into Drafts first, then move to Sent after submission succeeds.
|
// Import into Drafts first, then move to Sent after submission succeeds.
|
||||||
// This avoids encrypt-on-append affecting the SMTP send. See #188.
|
// This avoids encrypt-on-append affecting the SMTP send. See #188.
|
||||||
const importMailboxId = draftMailboxId || sentMailboxId;
|
const importMailboxId = draftMailboxId || sentMailboxId;
|
||||||
|
const identities = await this.getIdentities();
|
||||||
|
const identity = identities.find(item => item.id === identityId);
|
||||||
|
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
|
||||||
|
|
||||||
const methodCalls: [string, Record<string, unknown>, string][] = [
|
const methodCalls: [string, Record<string, unknown>, string][] = [
|
||||||
['Email/import', {
|
['Email/import', {
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
@@ -5471,11 +5640,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
},
|
},
|
||||||
}, '0'],
|
}, '0'],
|
||||||
['EmailSubmission/set', {
|
['EmailSubmission/set', {
|
||||||
accountId: this.accountId,
|
accountId: this.getSubmissionAccountId(),
|
||||||
create: {
|
create: {
|
||||||
'raw-submit': {
|
'raw-submit': {
|
||||||
emailId: '#raw-import',
|
emailId: '#raw-import',
|
||||||
identityId,
|
identityId,
|
||||||
|
...(envelope ? { envelope } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(draftMailboxId ? {
|
...(draftMailboxId ? {
|
||||||
@@ -5491,6 +5661,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const response = await this.request(methodCalls);
|
const response = await this.request(methodCalls);
|
||||||
|
let emailId: string | undefined;
|
||||||
|
let emailSubmissionId: string | undefined;
|
||||||
|
let serverSendAt: string | undefined;
|
||||||
|
|
||||||
// Check for errors
|
// Check for errors
|
||||||
for (const [methodName, result] of response.methodResponses ?? []) {
|
for (const [methodName, result] of response.methodResponses ?? []) {
|
||||||
@@ -5502,6 +5675,201 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const firstErr = Object.values(r.notCreated)[0];
|
const firstErr = Object.values(r.notCreated)[0];
|
||||||
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
|
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
|
||||||
}
|
}
|
||||||
|
if (methodName === 'Email/import') {
|
||||||
|
emailId = (result as { created?: Record<string, { id?: string }> }).created?.['raw-import']?.id;
|
||||||
|
}
|
||||||
|
if (methodName === 'EmailSubmission/set') {
|
||||||
|
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['raw-submit'];
|
||||||
|
emailSubmissionId = created?.id;
|
||||||
|
serverSendAt = created?.sendAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delayedUntil && emailSubmissionId && !serverSendAt) {
|
||||||
|
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return delayedUntil
|
||||||
|
? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, isSmime: true }
|
||||||
|
: { scheduled: false, emailId, emailSubmissionId, isSmime: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
|
||||||
|
if (!this.hasDelayedSend()) {
|
||||||
|
return { emails: [], hasMore: false, total: 0, nextPosition: position };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const pageSize = Math.max(limit, 50);
|
||||||
|
const submissions: EmailSubmission[] = [];
|
||||||
|
let rawPosition = 0;
|
||||||
|
let rawTotal = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const queryResponse = await this.request([
|
||||||
|
['EmailSubmission/query', {
|
||||||
|
accountId: this.getSubmissionAccountId(),
|
||||||
|
limit: pageSize,
|
||||||
|
position: rawPosition,
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const query = queryResponse.methodResponses?.[0]?.[1] as { ids?: string[]; total?: number; position?: number } | undefined;
|
||||||
|
const ids = query?.ids ?? [];
|
||||||
|
rawTotal = query?.total ?? rawPosition + ids.length;
|
||||||
|
if (ids.length === 0) break;
|
||||||
|
|
||||||
|
const submissionResponse = await this.request([
|
||||||
|
['EmailSubmission/get', {
|
||||||
|
accountId: this.getSubmissionAccountId(),
|
||||||
|
ids,
|
||||||
|
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus', 'deliveryStatus'],
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
submissions.push(...((submissionResponse.methodResponses?.[0]?.[1]?.list ?? []) as EmailSubmission[])
|
||||||
|
.filter(submission => {
|
||||||
|
if (submission.undoStatus !== 'pending' || !submission.sendAt) return false;
|
||||||
|
const sendAtTime = new Date(submission.sendAt).getTime();
|
||||||
|
return Number.isFinite(sendAtTime) && sendAtTime > now;
|
||||||
|
}));
|
||||||
|
|
||||||
|
rawPosition += ids.length;
|
||||||
|
} while (rawPosition < rawTotal);
|
||||||
|
|
||||||
|
submissions.sort((a, b) => new Date(a.sendAt || '').getTime() - new Date(b.sendAt || '').getTime());
|
||||||
|
const total = submissions.length;
|
||||||
|
const pageSubmissions = submissions.slice(position, position + limit);
|
||||||
|
const nextPosition = position + pageSubmissions.length;
|
||||||
|
|
||||||
|
if (pageSubmissions.length === 0) {
|
||||||
|
return { emails: [], hasMore: false, total, nextPosition };
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailResponse = await this.request([
|
||||||
|
['Email/get', {
|
||||||
|
accountId: this.accountId,
|
||||||
|
ids: pageSubmissions.map(submission => submission.emailId),
|
||||||
|
properties: [
|
||||||
|
'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'bcc', 'replyTo',
|
||||||
|
'subject', 'preview', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'hasAttachment', 'sentAt',
|
||||||
|
'messageId', 'inReplyTo', 'references', 'headers', 'blobId', 'bodyStructure',
|
||||||
|
],
|
||||||
|
fetchTextBodyValues: true,
|
||||||
|
fetchHTMLBodyValues: true,
|
||||||
|
fetchAllBodyValues: true,
|
||||||
|
maxBodyValueBytes: 256000,
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const emailById = new Map(((emailResponse.methodResponses?.[0]?.[1]?.list ?? []) as Email[]).map(email => [email.id, email]));
|
||||||
|
const emails = pageSubmissions
|
||||||
|
.map((submission): ScheduledEmail | null => {
|
||||||
|
const email = emailById.get(submission.emailId);
|
||||||
|
if (!email || !submission.sendAt) return null;
|
||||||
|
return {
|
||||||
|
...email,
|
||||||
|
threadId: submission.threadId || email.threadId,
|
||||||
|
scheduledSendAt: submission.sendAt,
|
||||||
|
emailSubmissionId: submission.id,
|
||||||
|
scheduledIdentityId: submission.identityId,
|
||||||
|
scheduledUndoStatus: submission.undoStatus,
|
||||||
|
scheduledDeliveryStatus: submission.deliveryStatus,
|
||||||
|
isScheduled: true,
|
||||||
|
isSmimeScheduled: isSmimeEmail(email),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((email): email is ScheduledEmail => email !== null)
|
||||||
|
.sort((a, b) => new Date(a.scheduledSendAt).getTime() - new Date(b.scheduledSendAt).getTime());
|
||||||
|
|
||||||
|
return { emails, hasMore: nextPosition < total, total, nextPosition };
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelEmailSubmission(submissionId: string): Promise<void> {
|
||||||
|
const response = await this.request([
|
||||||
|
['EmailSubmission/set', {
|
||||||
|
accountId: this.getSubmissionAccountId(),
|
||||||
|
update: { [submissionId]: { undoStatus: 'canceled' } },
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
const error = result?.notUpdated?.[submissionId];
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.description || error.type || 'Failed to cancel scheduled send');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
|
||||||
|
const holdForSeconds = this.validateDelayedUntil(delayedUntil);
|
||||||
|
const mailboxes = await this.getMailboxes();
|
||||||
|
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||||||
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
|
const identities = await this.getIdentities();
|
||||||
|
const identity = identities.find(item => item.id === identityId);
|
||||||
|
const existingEnvelope = await this.getEmailSubmissionEnvelope(submissionId);
|
||||||
|
const email = existingEnvelope?.rcptTo?.length ? undefined : await this.getEmail(emailId);
|
||||||
|
const envelopeRecipients = existingEnvelope?.rcptTo?.length
|
||||||
|
? existingEnvelope.rcptTo
|
||||||
|
: [...(email?.to || []), ...(email?.cc || []), ...(email?.bcc || [])];
|
||||||
|
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
|
||||||
|
const response = await this.request([
|
||||||
|
['EmailSubmission/set', {
|
||||||
|
accountId: this.getSubmissionAccountId(),
|
||||||
|
create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } },
|
||||||
|
...(draftsMailbox && sentMailbox ? {
|
||||||
|
onSuccessUpdateEmail: {
|
||||||
|
'#replacement': {
|
||||||
|
[`mailboxIds/${draftsMailbox.id}`]: null,
|
||||||
|
[`mailboxIds/${sentMailbox.id}`]: true,
|
||||||
|
'keywords/$draft': null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
const createError = result?.notCreated?.replacement;
|
||||||
|
if (createError) {
|
||||||
|
throw new Error(createError.description || createError.type || 'Failed to reschedule email');
|
||||||
|
}
|
||||||
|
const replacementId = result?.created?.replacement?.id;
|
||||||
|
const serverSendAt = result?.created?.replacement?.sendAt;
|
||||||
|
if (!replacementId) {
|
||||||
|
throw new Error('Server did not return a replacement scheduled send ID');
|
||||||
|
}
|
||||||
|
const finalSendAt = serverSendAt || await this.getEmailSubmissionSendAt(replacementId);
|
||||||
|
try {
|
||||||
|
await this.cancelEmailSubmission(submissionId);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await this.cancelEmailSubmission(replacementId);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error('Failed to clean up replacement scheduled send after reschedule failure:', cleanupError);
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to cancel original scheduled send';
|
||||||
|
throw new Error(`Reschedule created a replacement but could not cancel the original: ${message}`);
|
||||||
|
}
|
||||||
|
return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
|
||||||
|
const update: Record<string, unknown> = {
|
||||||
|
[`mailboxIds/${draftMailboxId}`]: true,
|
||||||
|
'keywords/$draft': true,
|
||||||
|
};
|
||||||
|
if (sentMailboxId) {
|
||||||
|
update[`mailboxIds/${sentMailboxId}`] = null;
|
||||||
|
}
|
||||||
|
const response = await this.request([
|
||||||
|
['Email/set', {
|
||||||
|
accountId: this.accountId,
|
||||||
|
update: { [emailId]: update },
|
||||||
|
}, '0'],
|
||||||
|
]);
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
const error = result?.notUpdated?.[emailId];
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.description || error.type || 'Failed to restore email to drafts');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,32 @@ export interface Email {
|
|||||||
// Unified mailbox support - set when displaying emails from multiple accounts
|
// Unified mailbox support - set when displaying emails from multiple accounts
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountLabel?: string;
|
accountLabel?: string;
|
||||||
|
// Client-only scheduled-send metadata, populated from EmailSubmission/query.
|
||||||
|
scheduledSendAt?: string;
|
||||||
|
emailSubmissionId?: string;
|
||||||
|
scheduledIdentityId?: string;
|
||||||
|
scheduledUndoStatus?: 'pending' | 'final' | 'canceled';
|
||||||
|
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
|
||||||
|
isScheduled?: boolean;
|
||||||
|
isSmimeScheduled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendEmailResult {
|
||||||
|
scheduled: boolean;
|
||||||
|
emailId?: string;
|
||||||
|
emailSubmissionId?: string;
|
||||||
|
sendAt?: string;
|
||||||
|
isSmime?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledEmail extends Email {
|
||||||
|
scheduledSendAt: string;
|
||||||
|
emailSubmissionId: string;
|
||||||
|
scheduledIdentityId: string;
|
||||||
|
scheduledUndoStatus: 'pending' | 'final' | 'canceled';
|
||||||
|
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
|
||||||
|
isScheduled: true;
|
||||||
|
isSmimeScheduled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthenticationResults {
|
export interface AuthenticationResults {
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"shared": "Sdílené",
|
"shared": "Sdílené",
|
||||||
"mail": "Pošta",
|
"mail": "Pošta",
|
||||||
"nav_label": "Navigace",
|
"nav_label": "Navigace",
|
||||||
"add_app": "Aplikace"
|
"add_app": "Aplikace",
|
||||||
|
"scheduled": "Naplánováno"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Výchozí aplikace",
|
"title": "Výchozí aplikace",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Vyprázdnit složku",
|
"confirm_button": "Vyprázdnit složku",
|
||||||
"junk_hint": "Můžete vyprázdnit složku Spam a trvale odstranit všechny zprávy.",
|
"junk_hint": "Můžete vyprázdnit složku Spam a trvale odstranit všechny zprávy.",
|
||||||
"trash_hint": "Můžete vyprázdnit Koš a trvale odstranit všechny zprávy."
|
"trash_hint": "Můžete vyprázdnit Koš a trvale odstranit všechny zprávy."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "Žádné naplánované e-maily",
|
||||||
|
"no_scheduled_emails_description": "Zprávy naplánované na později se zobrazí zde.",
|
||||||
|
"scheduled_count": "{count, plural, one {1 naplánovaný e-mail} few {# naplánované e-maily} other {# naplánovaných e-mailů}}",
|
||||||
|
"scheduled_actions_hint": "Naplánované zprávy můžete zrušit, přeplánovat nebo upravit pomocí akcí plánování.",
|
||||||
|
"cancel_scheduled_send": "Zrušit odeslání",
|
||||||
|
"reschedule_send": "Přeplánovat",
|
||||||
|
"cancel_and_edit": "Zrušit a upravit",
|
||||||
|
"cancel_and_compose_again": "Zrušit a napsat znovu",
|
||||||
|
"reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Není vybrána žádná zpráva",
|
"no_email_selected": "Není vybrána žádná zpráva",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "Verdikt AI",
|
"ai_verdict": "Verdikt AI",
|
||||||
"account": "Účet",
|
"account": "Účet",
|
||||||
"no_subject": "(bez předmětu)"
|
"no_subject": "(bez předmětu)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "Naplánováno k odeslání {date}",
|
||||||
|
"scheduled_send_created": "E-mail byl naplánován k odeslání",
|
||||||
|
"cancel_scheduled_send": "Zrušit odeslání",
|
||||||
|
"reschedule_send": "Přeplánovat",
|
||||||
|
"cancel_and_edit": "Zrušit a upravit",
|
||||||
|
"cancel_and_compose_again": "Zrušit a napsat znovu",
|
||||||
|
"reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "Zobrazení Naplánováno podporuje pouze akce plánování",
|
||||||
|
"undo_send_scheduled": "Zpráva je naplánována k odeslání",
|
||||||
|
"undo_send": "Vrátit odeslání"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Nová zpráva",
|
"new_message": "Nová zpráva",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "Přidat odkaz",
|
"add_link": "Přidat odkaz",
|
||||||
"link_url_prompt": "Zadejte URL",
|
"link_url_prompt": "Zadejte URL",
|
||||||
"sending": "Odesílání..."
|
"sending": "Odesílání...",
|
||||||
|
"schedule_send": "Naplánovat odeslání",
|
||||||
|
"schedule_send_description": "Vyberte, kdy má server tuto zprávu odeslat.",
|
||||||
|
"schedule_send_required": "Vyberte datum a čas.",
|
||||||
|
"schedule_send_invalid": "Zadejte platné datum a čas.",
|
||||||
|
"schedule_send_future": "Vyberte budoucí datum a čas.",
|
||||||
|
"schedule_send_too_late": "Tento čas je později, než server dovoluje.",
|
||||||
|
"schedule_send_unsupported": "Naplánované odeslání není pro tento účet podporováno.",
|
||||||
|
"schedule_send_cleanup_warning": "Naplánované odeslání bylo vytvořeno, ale vyčištění konceptu selhalo.",
|
||||||
|
"send_delay_unsupported": "Prodleva odeslání není pro tento účet podporována.",
|
||||||
|
"send_delay_unsupported_confirm": "Tento účet nepodporuje prodlevu odeslání. Odeslat ihned?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Potvrdit",
|
"confirm": "Potvrdit",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Zobrazit náhledy obrázků v přílohách",
|
"label": "Zobrazit náhledy obrázků v přílohách",
|
||||||
"description": "Zobrazovat obrázkové přílohy jako miniatury místo obecných ikon souborů"
|
"description": "Zobrazovat obrázkové přílohy jako miniatury místo obecných ikon souborů"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Vrátit odeslání / prodleva odeslání",
|
||||||
|
"description": "Pozdržet běžné odeslání krátkým oknem na serveru.",
|
||||||
|
"off": "Vypnuto",
|
||||||
|
"seconds": "{seconds} sekund",
|
||||||
|
"unsupported": "Aktuální účet neoznamuje podporu odloženého odeslání. Nastavení zůstane uloženo pro jiné účty."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "Štítek",
|
"color_tag": "Štítek",
|
||||||
"remove_color": "Odebrat štítek",
|
"remove_color": "Odebrat štítek",
|
||||||
"items_selected": "{count} vybraných zpráv",
|
"items_selected": "{count} vybraných zpráv",
|
||||||
"edit_draft": "Upravit koncept"
|
"edit_draft": "Upravit koncept",
|
||||||
|
"cancel_scheduled_send": "Zrušit odeslání",
|
||||||
|
"reschedule_send": "Přeplánovat",
|
||||||
|
"cancel_and_edit": "Zrušit a upravit",
|
||||||
|
"cancel_and_compose_again": "Zrušit a napsat znovu"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Označit složku jako přečtenou",
|
"mark_folder_read": "Označit složku jako přečtenou",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "Rozbalit/sbalit vlákno"
|
"expand_collapse": "Rozbalit/sbalit vlákno"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "Odeslat e-mail",
|
||||||
|
"schedule_send": "Naplánovat odeslání",
|
||||||
"template_picker": "Otevřít výběr šablon"
|
"template_picker": "Otevřít výběr šablon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+48
-5
@@ -132,7 +132,8 @@
|
|||||||
"shared": "Delt",
|
"shared": "Delt",
|
||||||
"mail": "Mail",
|
"mail": "Mail",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
"add_app": "Apps"
|
"add_app": "Apps",
|
||||||
|
"scheduled": "Planlagt"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Standardapps",
|
"title": "Standardapps",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Tøm mappe",
|
"confirm_button": "Tøm mappe",
|
||||||
"junk_hint": "Du kan tømme spam-mappen for permanent at fjerne alle beskeder.",
|
"junk_hint": "Du kan tømme spam-mappen for permanent at fjerne alle beskeder.",
|
||||||
"trash_hint": "Du kan tømme papirkurven for permanent at fjerne alle beskeder."
|
"trash_hint": "Du kan tømme papirkurven for permanent at fjerne alle beskeder."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "Ingen planlagte e-mails",
|
||||||
|
"no_scheduled_emails_description": "Beskeder planlagt til senere vises her.",
|
||||||
|
"scheduled_count": "{count, plural, one {1 planlagt e-mail} other {# planlagte e-mails}}",
|
||||||
|
"scheduled_actions_hint": "Planlagte beskeder kan annulleres, planlægges om eller redigeres fra deres planlagte handlinger.",
|
||||||
|
"cancel_scheduled_send": "Annuller afsendelse",
|
||||||
|
"reschedule_send": "Planlæg om",
|
||||||
|
"cancel_and_edit": "Annuller og redigér",
|
||||||
|
"cancel_and_compose_again": "Annuller og skriv igen",
|
||||||
|
"reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Ingen e-mail valgt",
|
"no_email_selected": "Ingen e-mail valgt",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"collapse": "Skjul detaljer"
|
"collapse": "Skjul detaljer"
|
||||||
},
|
},
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"more": "mere"
|
"more": "mere",
|
||||||
|
"scheduled_banner": "Planlagt til afsendelse {date}",
|
||||||
|
"scheduled_send_created": "E-mail planlagt til afsendelse",
|
||||||
|
"cancel_scheduled_send": "Annuller afsendelse",
|
||||||
|
"reschedule_send": "Planlæg om",
|
||||||
|
"cancel_and_edit": "Annuller og redigér",
|
||||||
|
"cancel_and_compose_again": "Annuller og skriv igen",
|
||||||
|
"reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "Planlagt-visningen understøtter kun planlagte handlinger",
|
||||||
|
"undo_send_scheduled": "Besked planlagt til afsendelse",
|
||||||
|
"undo_send": "Fortryd afsendelse"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Ny besked",
|
"new_message": "Ny besked",
|
||||||
@@ -607,7 +627,17 @@
|
|||||||
"send_anyway": "Send alligevel",
|
"send_anyway": "Send alligevel",
|
||||||
"back": "Tilbage til redigering"
|
"back": "Tilbage til redigering"
|
||||||
},
|
},
|
||||||
"link_url_prompt": "Indtast URL'en"
|
"link_url_prompt": "Indtast URL'en",
|
||||||
|
"schedule_send": "Planlæg afsendelse",
|
||||||
|
"schedule_send_description": "Vælg hvornår serveren skal frigive denne besked.",
|
||||||
|
"schedule_send_required": "Vælg en dato og et tidspunkt.",
|
||||||
|
"schedule_send_invalid": "Indtast en gyldig dato og tid.",
|
||||||
|
"schedule_send_future": "Vælg en fremtidig dato og tid.",
|
||||||
|
"schedule_send_too_late": "Dette tidspunkt er senere end serveren tillader.",
|
||||||
|
"schedule_send_unsupported": "Planlagt afsendelse understøttes ikke for denne konto.",
|
||||||
|
"schedule_send_cleanup_warning": "Planlagt afsendelse blev oprettet, men oprydning af kladde mislykkedes.",
|
||||||
|
"send_delay_unsupported": "Sendeforsinkelse understøttes ikke for denne konto.",
|
||||||
|
"send_delay_unsupported_confirm": "Denne konto understøtter ikke sendeforsinkelse. Send straks i stedet?"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Bekræft",
|
"confirm": "Bekræft",
|
||||||
@@ -1137,6 +1167,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Vis billedforhåndsvisninger i vedhæftninger",
|
"label": "Vis billedforhåndsvisninger i vedhæftninger",
|
||||||
"description": "Vis billedvedhæftninger som miniaturekort i stedet for generiske filikoner"
|
"description": "Vis billedvedhæftninger som miniaturekort i stedet for generiske filikoner"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Fortryd afsendelse / sendeforsinkelse",
|
||||||
|
"description": "Forsink normale afsendelser med et kort serverstyret tidsrum.",
|
||||||
|
"off": "Fra",
|
||||||
|
"seconds": "{seconds} sekunder",
|
||||||
|
"unsupported": "Den aktuelle konto annoncerer ikke understøttelse af forsinket afsendelse. Indstillingen gemmes stadig for andre konti."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1840,11 @@
|
|||||||
"color_tag": "Tag",
|
"color_tag": "Tag",
|
||||||
"remove_color": "Fjern tag",
|
"remove_color": "Fjern tag",
|
||||||
"items_selected": "{count} e-mails valgt",
|
"items_selected": "{count} e-mails valgt",
|
||||||
"edit_draft": "Redigér kladde"
|
"edit_draft": "Redigér kladde",
|
||||||
|
"cancel_scheduled_send": "Annuller afsendelse",
|
||||||
|
"reschedule_send": "Planlæg om",
|
||||||
|
"cancel_and_edit": "Annuller og redigér",
|
||||||
|
"cancel_and_compose_again": "Annuller og skriv igen"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Markér mappe som læst",
|
"mark_folder_read": "Markér mappe som læst",
|
||||||
@@ -1881,6 +1922,8 @@
|
|||||||
"expand_collapse": "Udvid/skjul tråd"
|
"expand_collapse": "Udvid/skjul tråd"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "Send e-mail",
|
||||||
|
"schedule_send": "Planlæg afsendelse",
|
||||||
"template_picker": "Åbn skabelonvælger"
|
"template_picker": "Åbn skabelonvælger"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "E-Mail",
|
"mail": "E-Mail",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
"add_app": "Apps",
|
"add_app": "Apps",
|
||||||
"shared": "Geteilt"
|
"shared": "Geteilt",
|
||||||
|
"scheduled": "Geplant"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Standard-Apps",
|
"title": "Standard-Apps",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Ordner leeren",
|
"confirm_button": "Ordner leeren",
|
||||||
"junk_hint": "Sie können den Spam-Ordner leeren, um alle Nachrichten dauerhaft zu entfernen.",
|
"junk_hint": "Sie können den Spam-Ordner leeren, um alle Nachrichten dauerhaft zu entfernen.",
|
||||||
"trash_hint": "Sie können den Papierkorb leeren, um alle Nachrichten dauerhaft zu entfernen."
|
"trash_hint": "Sie können den Papierkorb leeren, um alle Nachrichten dauerhaft zu entfernen."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "Keine geplanten E-Mails",
|
||||||
|
"no_scheduled_emails_description": "Nachrichten, die für später geplant sind, erscheinen hier.",
|
||||||
|
"scheduled_count": "{count, plural, one {1 geplante E-Mail} other {# geplante E-Mails}}",
|
||||||
|
"scheduled_actions_hint": "Geplante Nachrichten können über die Planungsaktionen abgebrochen, neu geplant oder bearbeitet werden.",
|
||||||
|
"cancel_scheduled_send": "Senden abbrechen",
|
||||||
|
"reschedule_send": "Neu planen",
|
||||||
|
"cancel_and_edit": "Abbrechen und bearbeiten",
|
||||||
|
"cancel_and_compose_again": "Abbrechen und neu verfassen",
|
||||||
|
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Keine E-Mail ausgewählt",
|
"no_email_selected": "Keine E-Mail ausgewählt",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "KI-Urteil",
|
"ai_verdict": "KI-Urteil",
|
||||||
"account": "Konto",
|
"account": "Konto",
|
||||||
"no_subject": "(kein Betreff)"
|
"no_subject": "(kein Betreff)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "Geplant zum Senden um {date}",
|
||||||
|
"scheduled_send_created": "E-Mail wurde zum Senden geplant",
|
||||||
|
"cancel_scheduled_send": "Senden abbrechen",
|
||||||
|
"reschedule_send": "Neu planen",
|
||||||
|
"cancel_and_edit": "Abbrechen und bearbeiten",
|
||||||
|
"cancel_and_compose_again": "Abbrechen und neu verfassen",
|
||||||
|
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "Die Geplant-Ansicht unterstützt nur Planungsaktionen",
|
||||||
|
"undo_send_scheduled": "Nachricht ist zum Senden geplant",
|
||||||
|
"undo_send": "Senden rückgängig"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Neue Nachricht",
|
"new_message": "Neue Nachricht",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "Link hinzufügen",
|
"add_link": "Link hinzufügen",
|
||||||
"link_url_prompt": "URL eingeben",
|
"link_url_prompt": "URL eingeben",
|
||||||
"sending": "Wird gesendet..."
|
"sending": "Wird gesendet...",
|
||||||
|
"schedule_send": "Senden planen",
|
||||||
|
"schedule_send_description": "Wählen Sie, wann der Server diese Nachricht freigeben soll.",
|
||||||
|
"schedule_send_required": "Wählen Sie Datum und Uhrzeit.",
|
||||||
|
"schedule_send_invalid": "Geben Sie ein gültiges Datum und eine gültige Uhrzeit ein.",
|
||||||
|
"schedule_send_future": "Wählen Sie ein Datum und eine Uhrzeit in der Zukunft.",
|
||||||
|
"schedule_send_too_late": "Dieser Zeitpunkt liegt später, als der Server erlaubt.",
|
||||||
|
"schedule_send_unsupported": "Geplantes Senden wird für dieses Konto nicht unterstützt.",
|
||||||
|
"schedule_send_cleanup_warning": "Geplantes Senden wurde erstellt, aber das Bereinigen des Entwurfs ist fehlgeschlagen.",
|
||||||
|
"send_delay_unsupported": "Sendeverzögerung wird für dieses Konto nicht unterstützt.",
|
||||||
|
"send_delay_unsupported_confirm": "Dieses Konto unterstützt keine Sendeverzögerung. Stattdessen sofort senden?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Bildvorschau in Anhängen anzeigen",
|
"label": "Bildvorschau in Anhängen anzeigen",
|
||||||
"description": "Bildanhänge als Miniaturansichten statt als generische Dateisymbole anzeigen"
|
"description": "Bildanhänge als Miniaturansichten statt als generische Dateisymbole anzeigen"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Senden rückgängig / Sendeverzögerung",
|
||||||
|
"description": "Normales Senden serverseitig kurz verzögern.",
|
||||||
|
"off": "Aus",
|
||||||
|
"seconds": "{seconds} Sekunden",
|
||||||
|
"unsupported": "Das aktuelle Konto meldet keine Unterstützung für verzögertes Senden. Die Einstellung bleibt für andere Konten gespeichert."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "Label",
|
"color_tag": "Label",
|
||||||
"remove_color": "Label entfernen",
|
"remove_color": "Label entfernen",
|
||||||
"items_selected": "{count} E-Mails ausgewählt",
|
"items_selected": "{count} E-Mails ausgewählt",
|
||||||
"edit_draft": "Entwurf bearbeiten"
|
"edit_draft": "Entwurf bearbeiten",
|
||||||
|
"cancel_scheduled_send": "Senden abbrechen",
|
||||||
|
"reschedule_send": "Neu planen",
|
||||||
|
"cancel_and_edit": "Abbrechen und bearbeiten",
|
||||||
|
"cancel_and_compose_again": "Abbrechen und neu verfassen"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Ordner als gelesen markieren",
|
"mark_folder_read": "Ordner als gelesen markieren",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "Unterhaltung erweitern/einklappen"
|
"expand_collapse": "Unterhaltung erweitern/einklappen"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "E-Mail senden",
|
||||||
|
"schedule_send": "Senden planen",
|
||||||
"template_picker": "Vorlagenauswahl öffnen"
|
"template_picker": "Vorlagenauswahl öffnen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+49
-7
@@ -132,7 +132,8 @@
|
|||||||
"shared": "Shared",
|
"shared": "Shared",
|
||||||
"mail": "Mail",
|
"mail": "Mail",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
"add_app": "Apps"
|
"add_app": "Apps",
|
||||||
|
"scheduled": "Scheduled"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Default apps",
|
"title": "Default apps",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Empty folder",
|
"confirm_button": "Empty folder",
|
||||||
"junk_hint": "You can empty the Junk folder to permanently remove all messages.",
|
"junk_hint": "You can empty the Junk folder to permanently remove all messages.",
|
||||||
"trash_hint": "You can empty the Trash folder to permanently remove all messages."
|
"trash_hint": "You can empty the Trash folder to permanently remove all messages."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "No scheduled emails",
|
||||||
|
"no_scheduled_emails_description": "Messages scheduled for later will appear here.",
|
||||||
|
"scheduled_count": "{count, plural, one {1 scheduled email} other {# scheduled emails}}",
|
||||||
|
"scheduled_actions_hint": "Scheduled messages can be canceled, rescheduled, or edited from their scheduled actions.",
|
||||||
|
"cancel_scheduled_send": "Cancel send",
|
||||||
|
"reschedule_send": "Reschedule",
|
||||||
|
"cancel_and_edit": "Cancel and edit",
|
||||||
|
"cancel_and_compose_again": "Cancel and compose again",
|
||||||
|
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "No email selected",
|
"no_email_selected": "No email selected",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"collapse": "Hide details"
|
"collapse": "Hide details"
|
||||||
},
|
},
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"more": "more"
|
"more": "more",
|
||||||
|
"scheduled_banner": "Scheduled to send at {date}",
|
||||||
|
"scheduled_send_created": "Email scheduled for sending",
|
||||||
|
"cancel_scheduled_send": "Cancel send",
|
||||||
|
"reschedule_send": "Reschedule",
|
||||||
|
"cancel_and_edit": "Cancel and edit",
|
||||||
|
"cancel_and_compose_again": "Cancel and compose again",
|
||||||
|
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "Scheduled view supports scheduled actions only",
|
||||||
|
"undo_send_scheduled": "Message scheduled for sending",
|
||||||
|
"undo_send": "Undo send"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "New Message",
|
"new_message": "New Message",
|
||||||
@@ -607,7 +627,17 @@
|
|||||||
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||||
"send_anyway": "Send anyway",
|
"send_anyway": "Send anyway",
|
||||||
"back": "Back to editing"
|
"back": "Back to editing"
|
||||||
}
|
},
|
||||||
|
"schedule_send": "Schedule send",
|
||||||
|
"schedule_send_description": "Choose when the server should release this message.",
|
||||||
|
"schedule_send_required": "Choose a date and time.",
|
||||||
|
"schedule_send_invalid": "Enter a valid date and time.",
|
||||||
|
"schedule_send_future": "Choose a future date and time.",
|
||||||
|
"schedule_send_too_late": "This time is later than the server allows.",
|
||||||
|
"schedule_send_unsupported": "Scheduled send is not supported for this account.",
|
||||||
|
"schedule_send_cleanup_warning": "Scheduled send was created, but draft cleanup failed.",
|
||||||
|
"send_delay_unsupported": "Send delay is not supported for this account.",
|
||||||
|
"send_delay_unsupported_confirm": "This account does not support send delay. Send immediately instead?"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
@@ -1138,6 +1168,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Show image previews in attachments",
|
"label": "Show image previews in attachments",
|
||||||
"description": "Render image attachments as thumbnail cards instead of generic file icons"
|
"description": "Render image attachments as thumbnail cards instead of generic file icons"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Undo send / send delay",
|
||||||
|
"description": "Delay normal sends by a short server-side window.",
|
||||||
|
"off": "Off",
|
||||||
|
"seconds": "{seconds} seconds",
|
||||||
|
"unsupported": "The current account does not advertise delayed-send support. The setting is still saved for other accounts."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1804,7 +1841,11 @@
|
|||||||
"color_tag": "Tag",
|
"color_tag": "Tag",
|
||||||
"remove_color": "Remove tag",
|
"remove_color": "Remove tag",
|
||||||
"items_selected": "{count} emails selected",
|
"items_selected": "{count} emails selected",
|
||||||
"edit_draft": "Edit Draft"
|
"edit_draft": "Edit Draft",
|
||||||
|
"cancel_scheduled_send": "Cancel send",
|
||||||
|
"reschedule_send": "Reschedule",
|
||||||
|
"cancel_and_edit": "Cancel and edit",
|
||||||
|
"cancel_and_compose_again": "Cancel and compose again"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Mark folder as read",
|
"mark_folder_read": "Mark folder as read",
|
||||||
@@ -1882,8 +1923,9 @@
|
|||||||
"expand_collapse": "Expand/collapse thread"
|
"expand_collapse": "Expand/collapse thread"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"template_picker": "Open template picker",
|
"send": "Send email",
|
||||||
"send": "Send email"
|
"schedule_send": "Schedule send",
|
||||||
|
"template_picker": "Open template picker"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"threads": {
|
"threads": {
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "Correo",
|
"mail": "Correo",
|
||||||
"nav_label": "Navegación",
|
"nav_label": "Navegación",
|
||||||
"add_app": "Apps",
|
"add_app": "Apps",
|
||||||
"shared": "Compartido"
|
"shared": "Compartido",
|
||||||
|
"scheduled": "Programados"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Aplicaciones predeterminadas",
|
"title": "Aplicaciones predeterminadas",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Vaciar carpeta",
|
"confirm_button": "Vaciar carpeta",
|
||||||
"junk_hint": "Puede vaciar la carpeta de Spam para eliminar permanentemente todos los mensajes.",
|
"junk_hint": "Puede vaciar la carpeta de Spam para eliminar permanentemente todos los mensajes.",
|
||||||
"trash_hint": "Puede vaciar la Papelera para eliminar permanentemente todos los mensajes."
|
"trash_hint": "Puede vaciar la Papelera para eliminar permanentemente todos los mensajes."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "No hay correos programados",
|
||||||
|
"no_scheduled_emails_description": "Los mensajes programados para más tarde aparecerán aquí.",
|
||||||
|
"scheduled_count": "{count, plural, one {1 correo programado} other {# correos programados}}",
|
||||||
|
"scheduled_actions_hint": "Los mensajes programados se pueden cancelar, reprogramar o editar desde sus acciones.",
|
||||||
|
"cancel_scheduled_send": "Cancelar envío",
|
||||||
|
"reschedule_send": "Reprogramar",
|
||||||
|
"cancel_and_edit": "Cancelar y editar",
|
||||||
|
"cancel_and_compose_again": "Cancelar y redactar de nuevo",
|
||||||
|
"reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Ningún correo seleccionado",
|
"no_email_selected": "Ningún correo seleccionado",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "Veredicto de IA",
|
"ai_verdict": "Veredicto de IA",
|
||||||
"account": "Cuenta",
|
"account": "Cuenta",
|
||||||
"no_subject": "(sin asunto)"
|
"no_subject": "(sin asunto)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "Programado para enviarse el {date}",
|
||||||
|
"scheduled_send_created": "Correo programado para envío",
|
||||||
|
"cancel_scheduled_send": "Cancelar envío",
|
||||||
|
"reschedule_send": "Reprogramar",
|
||||||
|
"cancel_and_edit": "Cancelar y editar",
|
||||||
|
"cancel_and_compose_again": "Cancelar y redactar de nuevo",
|
||||||
|
"reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "La vista Programados solo admite acciones de programación",
|
||||||
|
"undo_send_scheduled": "Mensaje programado para envío",
|
||||||
|
"undo_send": "Deshacer envío"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Nuevo Mensaje",
|
"new_message": "Nuevo Mensaje",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "Añadir enlace",
|
"add_link": "Añadir enlace",
|
||||||
"link_url_prompt": "Introduce la URL",
|
"link_url_prompt": "Introduce la URL",
|
||||||
"sending": "Enviando..."
|
"sending": "Enviando...",
|
||||||
|
"schedule_send": "Programar envío",
|
||||||
|
"schedule_send_description": "Elige cuándo debe liberar el servidor este mensaje.",
|
||||||
|
"schedule_send_required": "Elige una fecha y hora.",
|
||||||
|
"schedule_send_invalid": "Introduce una fecha y hora válidas.",
|
||||||
|
"schedule_send_future": "Elige una fecha y hora futuras.",
|
||||||
|
"schedule_send_too_late": "Esta hora es posterior a lo que permite el servidor.",
|
||||||
|
"schedule_send_unsupported": "El envío programado no es compatible con esta cuenta.",
|
||||||
|
"schedule_send_cleanup_warning": "Se creó el envío programado, pero falló la limpieza del borrador.",
|
||||||
|
"send_delay_unsupported": "La demora de envío no es compatible con esta cuenta.",
|
||||||
|
"send_delay_unsupported_confirm": "Esta cuenta no admite demora de envío. ¿Enviar inmediatamente?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Mostrar vistas previas de imágenes en adjuntos",
|
"label": "Mostrar vistas previas de imágenes en adjuntos",
|
||||||
"description": "Mostrar las imágenes adjuntas como tarjetas de miniatura en lugar de iconos de archivo genéricos"
|
"description": "Mostrar las imágenes adjuntas como tarjetas de miniatura en lugar de iconos de archivo genéricos"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Deshacer envío / demora de envío",
|
||||||
|
"description": "Retrasa envíos normales con una breve ventana del servidor.",
|
||||||
|
"off": "Desactivado",
|
||||||
|
"seconds": "{seconds} segundos",
|
||||||
|
"unsupported": "La cuenta actual no anuncia compatibilidad con envío demorado. La opción seguirá guardada para otras cuentas."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "Etiqueta",
|
"color_tag": "Etiqueta",
|
||||||
"remove_color": "Eliminar etiqueta",
|
"remove_color": "Eliminar etiqueta",
|
||||||
"items_selected": "{count} correos seleccionados",
|
"items_selected": "{count} correos seleccionados",
|
||||||
"edit_draft": "Editar borrador"
|
"edit_draft": "Editar borrador",
|
||||||
|
"cancel_scheduled_send": "Cancelar envío",
|
||||||
|
"reschedule_send": "Reprogramar",
|
||||||
|
"cancel_and_edit": "Cancelar y editar",
|
||||||
|
"cancel_and_compose_again": "Cancelar y redactar de nuevo"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Marcar carpeta como leída",
|
"mark_folder_read": "Marcar carpeta como leída",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "Expandir/contraer conversación"
|
"expand_collapse": "Expandir/contraer conversación"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "Enviar correo",
|
||||||
|
"schedule_send": "Programar envío",
|
||||||
"template_picker": "Abrir selector de plantillas"
|
"template_picker": "Abrir selector de plantillas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "Messagerie",
|
"mail": "Messagerie",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
"add_app": "Apps",
|
"add_app": "Apps",
|
||||||
"shared": "Partagé"
|
"shared": "Partagé",
|
||||||
|
"scheduled": "Planifiés"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Applications par défaut",
|
"title": "Applications par défaut",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Vider le dossier",
|
"confirm_button": "Vider le dossier",
|
||||||
"junk_hint": "Vous pouvez vider le dossier Indésirables pour supprimer définitivement tous les messages.",
|
"junk_hint": "Vous pouvez vider le dossier Indésirables pour supprimer définitivement tous les messages.",
|
||||||
"trash_hint": "Vous pouvez vider la corbeille pour supprimer définitivement tous les messages."
|
"trash_hint": "Vous pouvez vider la corbeille pour supprimer définitivement tous les messages."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "Aucun e-mail planifié",
|
||||||
|
"no_scheduled_emails_description": "Les messages planifiés pour plus tard apparaîtront ici.",
|
||||||
|
"scheduled_count": "{count, plural, one {1 e-mail planifié} other {# e-mails planifiés}}",
|
||||||
|
"scheduled_actions_hint": "Les messages planifiés peuvent être annulés, replanifiés ou modifiés depuis leurs actions.",
|
||||||
|
"cancel_scheduled_send": "Annuler l’envoi",
|
||||||
|
"reschedule_send": "Replanifier",
|
||||||
|
"cancel_and_edit": "Annuler et modifier",
|
||||||
|
"cancel_and_compose_again": "Annuler et rédiger à nouveau",
|
||||||
|
"reschedule_prompt": "Saisissez une nouvelle date/heure, p. ex. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Aucun email sélectionné",
|
"no_email_selected": "Aucun email sélectionné",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "Verdict IA",
|
"ai_verdict": "Verdict IA",
|
||||||
"account": "Compte",
|
"account": "Compte",
|
||||||
"no_subject": "(sans objet)"
|
"no_subject": "(sans objet)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "Envoi planifié à {date}",
|
||||||
|
"scheduled_send_created": "E-mail planifié pour envoi",
|
||||||
|
"cancel_scheduled_send": "Annuler l’envoi",
|
||||||
|
"reschedule_send": "Replanifier",
|
||||||
|
"cancel_and_edit": "Annuler et modifier",
|
||||||
|
"cancel_and_compose_again": "Annuler et rédiger à nouveau",
|
||||||
|
"reschedule_prompt": "Saisissez une nouvelle date/heure, p. ex. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "La vue Planifiés prend uniquement en charge les actions de planification",
|
||||||
|
"undo_send_scheduled": "Message planifié pour envoi",
|
||||||
|
"undo_send": "Annuler l’envoi"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Nouveau message",
|
"new_message": "Nouveau message",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "Ajouter un lien",
|
"add_link": "Ajouter un lien",
|
||||||
"link_url_prompt": "Saisissez l'URL",
|
"link_url_prompt": "Saisissez l'URL",
|
||||||
"sending": "Envoi..."
|
"sending": "Envoi...",
|
||||||
|
"schedule_send": "Planifier l’envoi",
|
||||||
|
"schedule_send_description": "Choisissez quand le serveur doit libérer ce message.",
|
||||||
|
"schedule_send_required": "Choisissez une date et une heure.",
|
||||||
|
"schedule_send_invalid": "Saisissez une date et une heure valides.",
|
||||||
|
"schedule_send_future": "Choisissez une date et une heure futures.",
|
||||||
|
"schedule_send_too_late": "Cette heure est au-delà de ce que le serveur autorise.",
|
||||||
|
"schedule_send_unsupported": "L’envoi planifié n’est pas pris en charge pour ce compte.",
|
||||||
|
"schedule_send_cleanup_warning": "L’envoi planifié a été créé, mais le nettoyage du brouillon a échoué.",
|
||||||
|
"send_delay_unsupported": "Le délai d’envoi n’est pas pris en charge pour ce compte.",
|
||||||
|
"send_delay_unsupported_confirm": "Ce compte ne prend pas en charge le délai d’envoi. Envoyer immédiatement ?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Confirmer",
|
"confirm": "Confirmer",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Afficher les aperçus d'images dans les pièces jointes",
|
"label": "Afficher les aperçus d'images dans les pièces jointes",
|
||||||
"description": "Afficher les images jointes sous forme de vignettes plutôt qu'avec des icônes de fichier génériques"
|
"description": "Afficher les images jointes sous forme de vignettes plutôt qu'avec des icônes de fichier génériques"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Annuler l’envoi / délai d’envoi",
|
||||||
|
"description": "Retarde les envois normaux avec une courte fenêtre côté serveur.",
|
||||||
|
"off": "Désactivé",
|
||||||
|
"seconds": "{seconds} secondes",
|
||||||
|
"unsupported": "Le compte actuel n’annonce pas la prise en charge de l’envoi différé. Le réglage reste enregistré pour d’autres comptes."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "Étiquette",
|
"color_tag": "Étiquette",
|
||||||
"remove_color": "Supprimer l'étiquette",
|
"remove_color": "Supprimer l'étiquette",
|
||||||
"items_selected": "{count} emails sélectionnés",
|
"items_selected": "{count} emails sélectionnés",
|
||||||
"edit_draft": "Modifier le brouillon"
|
"edit_draft": "Modifier le brouillon",
|
||||||
|
"cancel_scheduled_send": "Annuler l’envoi",
|
||||||
|
"reschedule_send": "Replanifier",
|
||||||
|
"cancel_and_edit": "Annuler et modifier",
|
||||||
|
"cancel_and_compose_again": "Annuler et rédiger à nouveau"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Marquer le dossier comme lu",
|
"mark_folder_read": "Marquer le dossier comme lu",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "Développer/réduire la conversation"
|
"expand_collapse": "Développer/réduire la conversation"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "Envoyer l'e-mail",
|
||||||
|
"schedule_send": "Planifier l'envoi",
|
||||||
"template_picker": "Ouvrir le sélecteur de modèles"
|
"template_picker": "Ouvrir le sélecteur de modèles"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "Posta",
|
"mail": "Posta",
|
||||||
"nav_label": "Navigazione",
|
"nav_label": "Navigazione",
|
||||||
"add_app": "App",
|
"add_app": "App",
|
||||||
"shared": "Condiviso"
|
"shared": "Condiviso",
|
||||||
|
"scheduled": "Programmate"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "App predefinite",
|
"title": "App predefinite",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Svuota cartella",
|
"confirm_button": "Svuota cartella",
|
||||||
"junk_hint": "Puoi svuotare la cartella Spam per rimuovere definitivamente tutti i messaggi.",
|
"junk_hint": "Puoi svuotare la cartella Spam per rimuovere definitivamente tutti i messaggi.",
|
||||||
"trash_hint": "Puoi svuotare il Cestino per rimuovere definitivamente tutti i messaggi."
|
"trash_hint": "Puoi svuotare il Cestino per rimuovere definitivamente tutti i messaggi."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "Nessuna e-mail programmata",
|
||||||
|
"no_scheduled_emails_description": "I messaggi programmati per più tardi appariranno qui.",
|
||||||
|
"scheduled_count": "{count, plural, one {1 e-mail programmata} other {# e-mail programmate}}",
|
||||||
|
"scheduled_actions_hint": "I messaggi programmati possono essere annullati, riprogrammati o modificati dalle azioni di programmazione.",
|
||||||
|
"cancel_scheduled_send": "Annulla invio",
|
||||||
|
"reschedule_send": "Riprogramma",
|
||||||
|
"cancel_and_edit": "Annulla e modifica",
|
||||||
|
"cancel_and_compose_again": "Annulla e componi di nuovo",
|
||||||
|
"reschedule_prompt": "Inserisci una nuova data/ora, ad es. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Nessun messaggio selezionato",
|
"no_email_selected": "Nessun messaggio selezionato",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "Verdetto IA",
|
"ai_verdict": "Verdetto IA",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"no_subject": "(nessun oggetto)"
|
"no_subject": "(nessun oggetto)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "Programmato per l’invio il {date}",
|
||||||
|
"scheduled_send_created": "E-mail programmata per l’invio",
|
||||||
|
"cancel_scheduled_send": "Annulla invio",
|
||||||
|
"reschedule_send": "Riprogramma",
|
||||||
|
"cancel_and_edit": "Annulla e modifica",
|
||||||
|
"cancel_and_compose_again": "Annulla e componi di nuovo",
|
||||||
|
"reschedule_prompt": "Inserisci una nuova data/ora, ad es. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "La vista Programmate supporta solo azioni di programmazione",
|
||||||
|
"undo_send_scheduled": "Messaggio programmato per l’invio",
|
||||||
|
"undo_send": "Annulla invio"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Nuovo messaggio",
|
"new_message": "Nuovo messaggio",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "Aggiungi link",
|
"add_link": "Aggiungi link",
|
||||||
"link_url_prompt": "Inserisci l'URL",
|
"link_url_prompt": "Inserisci l'URL",
|
||||||
"sending": "Invio in corso..."
|
"sending": "Invio in corso...",
|
||||||
|
"schedule_send": "Programma invio",
|
||||||
|
"schedule_send_description": "Scegli quando il server deve rilasciare questo messaggio.",
|
||||||
|
"schedule_send_required": "Scegli una data e un’ora.",
|
||||||
|
"schedule_send_invalid": "Inserisci una data e un’ora valide.",
|
||||||
|
"schedule_send_future": "Scegli una data e un’ora future.",
|
||||||
|
"schedule_send_too_late": "Questo orario supera il limite consentito dal server.",
|
||||||
|
"schedule_send_unsupported": "L’invio programmato non è supportato per questo account.",
|
||||||
|
"schedule_send_cleanup_warning": "L’invio programmato è stato creato, ma la pulizia della bozza non è riuscita.",
|
||||||
|
"send_delay_unsupported": "Il ritardo di invio non è supportato per questo account.",
|
||||||
|
"send_delay_unsupported_confirm": "Questo account non supporta il ritardo di invio. Inviare subito?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Conferma",
|
"confirm": "Conferma",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Mostra anteprime delle immagini negli allegati",
|
"label": "Mostra anteprime delle immagini negli allegati",
|
||||||
"description": "Mostra gli allegati immagine come miniature anziché con icone di file generiche"
|
"description": "Mostra gli allegati immagine come miniature anziché con icone di file generiche"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Annulla invio / ritardo di invio",
|
||||||
|
"description": "Ritarda gli invii normali con una breve finestra lato server.",
|
||||||
|
"off": "Disattivato",
|
||||||
|
"seconds": "{seconds} secondi",
|
||||||
|
"unsupported": "L’account corrente non dichiara il supporto all’invio ritardato. L’impostazione resta salvata per altri account."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "Etichetta",
|
"color_tag": "Etichetta",
|
||||||
"remove_color": "Rimuovi etichetta",
|
"remove_color": "Rimuovi etichetta",
|
||||||
"items_selected": "{count} messaggi selezionati",
|
"items_selected": "{count} messaggi selezionati",
|
||||||
"edit_draft": "Modifica bozza"
|
"edit_draft": "Modifica bozza",
|
||||||
|
"cancel_scheduled_send": "Annulla invio",
|
||||||
|
"reschedule_send": "Riprogramma",
|
||||||
|
"cancel_and_edit": "Annulla e modifica",
|
||||||
|
"cancel_and_compose_again": "Annulla e componi di nuovo"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Segna cartella come letta",
|
"mark_folder_read": "Segna cartella come letta",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "Espandi/comprimi conversazione"
|
"expand_collapse": "Espandi/comprimi conversazione"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "Invia email",
|
||||||
|
"schedule_send": "Programma invio",
|
||||||
"template_picker": "Apri selettore modelli"
|
"template_picker": "Apri selettore modelli"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "メール",
|
"mail": "メール",
|
||||||
"nav_label": "ナビゲーション",
|
"nav_label": "ナビゲーション",
|
||||||
"add_app": "アプリ",
|
"add_app": "アプリ",
|
||||||
"shared": "共有"
|
"shared": "共有",
|
||||||
|
"scheduled": "予約済み"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "既定のアプリ",
|
"title": "既定のアプリ",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "フォルダを空にする",
|
"confirm_button": "フォルダを空にする",
|
||||||
"junk_hint": "迷惑メールフォルダを空にして、すべてのメールを完全に削除できます。",
|
"junk_hint": "迷惑メールフォルダを空にして、すべてのメールを完全に削除できます。",
|
||||||
"trash_hint": "ゴミ箱を空にして、すべてのメールを完全に削除できます。"
|
"trash_hint": "ゴミ箱を空にして、すべてのメールを完全に削除できます。"
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "予約送信はありません",
|
||||||
|
"no_scheduled_emails_description": "後で送信するよう予約したメッセージがここに表示されます。",
|
||||||
|
"scheduled_count": "{count, plural, one {1 件の予約送信} other {# 件の予約送信}}",
|
||||||
|
"scheduled_actions_hint": "予約メッセージは予約アクションからキャンセル、再予約、編集できます。",
|
||||||
|
"cancel_scheduled_send": "送信をキャンセル",
|
||||||
|
"reschedule_send": "再予約",
|
||||||
|
"cancel_and_edit": "キャンセルして編集",
|
||||||
|
"cancel_and_compose_again": "キャンセルして新規作成",
|
||||||
|
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "メールが選択されていません",
|
"no_email_selected": "メールが選択されていません",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "AIの判定",
|
"ai_verdict": "AIの判定",
|
||||||
"account": "アカウント",
|
"account": "アカウント",
|
||||||
"no_subject": "(件名なし)"
|
"no_subject": "(件名なし)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "{date} に送信予定",
|
||||||
|
"scheduled_send_created": "メールを送信予約しました",
|
||||||
|
"cancel_scheduled_send": "送信をキャンセル",
|
||||||
|
"reschedule_send": "再予約",
|
||||||
|
"cancel_and_edit": "キャンセルして編集",
|
||||||
|
"cancel_and_compose_again": "キャンセルして新規作成",
|
||||||
|
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "予約済みビューでは予約アクションのみ利用できます",
|
||||||
|
"undo_send_scheduled": "メッセージは送信予約されています",
|
||||||
|
"undo_send": "送信を取り消す"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "新規メッセージ",
|
"new_message": "新規メッセージ",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "リンクを追加",
|
"add_link": "リンクを追加",
|
||||||
"link_url_prompt": "URLを入力してください",
|
"link_url_prompt": "URLを入力してください",
|
||||||
"sending": "送信中..."
|
"sending": "送信中...",
|
||||||
|
"schedule_send": "送信を予約",
|
||||||
|
"schedule_send_description": "サーバーがこのメッセージを送信する日時を選択します。",
|
||||||
|
"schedule_send_required": "日時を選択してください。",
|
||||||
|
"schedule_send_invalid": "有効な日時を入力してください。",
|
||||||
|
"schedule_send_future": "未来の日時を選択してください。",
|
||||||
|
"schedule_send_too_late": "この日時はサーバーが許可する範囲を超えています。",
|
||||||
|
"schedule_send_unsupported": "このアカウントでは予約送信はサポートされていません。",
|
||||||
|
"schedule_send_cleanup_warning": "予約送信は作成されましたが、下書きのクリーンアップに失敗しました。",
|
||||||
|
"send_delay_unsupported": "このアカウントでは送信遅延はサポートされていません。",
|
||||||
|
"send_delay_unsupported_confirm": "このアカウントは送信遅延に対応していません。すぐに送信しますか?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "添付ファイル内の画像プレビューを表示",
|
"label": "添付ファイル内の画像プレビューを表示",
|
||||||
"description": "画像の添付ファイルを汎用ファイルアイコンではなくサムネイルカードとして表示します"
|
"description": "画像の添付ファイルを汎用ファイルアイコンではなくサムネイルカードとして表示します"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "送信取り消し / 送信遅延",
|
||||||
|
"description": "通常の送信をサーバー側で短時間遅延します。",
|
||||||
|
"off": "オフ",
|
||||||
|
"seconds": "{seconds} 秒",
|
||||||
|
"unsupported": "現在のアカウントは遅延送信のサポートを通知していません。この設定は他のアカウント用に保存されます。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "ラベル",
|
"color_tag": "ラベル",
|
||||||
"remove_color": "ラベルを削除",
|
"remove_color": "ラベルを削除",
|
||||||
"items_selected": "{count}件のメールを選択",
|
"items_selected": "{count}件のメールを選択",
|
||||||
"edit_draft": "下書きを編集"
|
"edit_draft": "下書きを編集",
|
||||||
|
"cancel_scheduled_send": "送信をキャンセル",
|
||||||
|
"reschedule_send": "再予約",
|
||||||
|
"cancel_and_edit": "キャンセルして編集",
|
||||||
|
"cancel_and_compose_again": "キャンセルして新規作成"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "フォルダーを既読にする",
|
"mark_folder_read": "フォルダーを既読にする",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "スレッドの展開/折りたたみ"
|
"expand_collapse": "スレッドの展開/折りたたみ"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "メールを送信",
|
||||||
|
"schedule_send": "送信を予約",
|
||||||
"template_picker": "テンプレートピッカーを開く"
|
"template_picker": "テンプレートピッカーを開く"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "메일",
|
"mail": "메일",
|
||||||
"nav_label": "내비게이션",
|
"nav_label": "내비게이션",
|
||||||
"add_app": "앱",
|
"add_app": "앱",
|
||||||
"shared": "공유됨"
|
"shared": "공유됨",
|
||||||
|
"scheduled": "예약됨"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "기본 앱",
|
"title": "기본 앱",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "폴더 비우기",
|
"confirm_button": "폴더 비우기",
|
||||||
"junk_hint": "스팸함을 비워 모든 메시지를 영구적으로 삭제할 수 있어요.",
|
"junk_hint": "스팸함을 비워 모든 메시지를 영구적으로 삭제할 수 있어요.",
|
||||||
"trash_hint": "휴지통을 비워 모든 메시지를 영구적으로 삭제할 수 있어요."
|
"trash_hint": "휴지통을 비워 모든 메시지를 영구적으로 삭제할 수 있어요."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "예약된 메일 없음",
|
||||||
|
"no_scheduled_emails_description": "나중에 보내도록 예약한 메시지가 여기에 표시됩니다.",
|
||||||
|
"scheduled_count": "{count, plural, one {예약된 메일 1개} other {예약된 메일 #개}}",
|
||||||
|
"scheduled_actions_hint": "예약된 메시지는 예약 작업에서 취소, 재예약 또는 편집할 수 있습니다.",
|
||||||
|
"cancel_scheduled_send": "보내기 취소",
|
||||||
|
"reschedule_send": "다시 예약",
|
||||||
|
"cancel_and_edit": "취소하고 편집",
|
||||||
|
"cancel_and_compose_again": "취소하고 다시 작성",
|
||||||
|
"reschedule_prompt": "새 날짜/시간을 입력하세요. 예: 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "메일이 선택되지 않았어요",
|
"no_email_selected": "메일이 선택되지 않았어요",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "AI 판정",
|
"ai_verdict": "AI 판정",
|
||||||
"account": "계정",
|
"account": "계정",
|
||||||
"no_subject": "(제목 없음)"
|
"no_subject": "(제목 없음)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "{date}에 보내도록 예약됨",
|
||||||
|
"scheduled_send_created": "메일 보내기가 예약되었습니다",
|
||||||
|
"cancel_scheduled_send": "보내기 취소",
|
||||||
|
"reschedule_send": "다시 예약",
|
||||||
|
"cancel_and_edit": "취소하고 편집",
|
||||||
|
"cancel_and_compose_again": "취소하고 다시 작성",
|
||||||
|
"reschedule_prompt": "새 날짜/시간을 입력하세요. 예: 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "예약됨 보기에서는 예약 작업만 사용할 수 있습니다",
|
||||||
|
"undo_send_scheduled": "메시지가 보내기로 예약되었습니다",
|
||||||
|
"undo_send": "보내기 실행 취소"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "새 메시지",
|
"new_message": "새 메시지",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "링크 추가",
|
"add_link": "링크 추가",
|
||||||
"link_url_prompt": "URL을 입력하세요",
|
"link_url_prompt": "URL을 입력하세요",
|
||||||
"sending": "전송 중..."
|
"sending": "전송 중...",
|
||||||
|
"schedule_send": "보내기 예약",
|
||||||
|
"schedule_send_description": "서버가 이 메시지를 보낼 시간을 선택하세요.",
|
||||||
|
"schedule_send_required": "날짜와 시간을 선택하세요.",
|
||||||
|
"schedule_send_invalid": "유효한 날짜와 시간을 입력하세요.",
|
||||||
|
"schedule_send_future": "미래의 날짜와 시간을 선택하세요.",
|
||||||
|
"schedule_send_too_late": "이 시간은 서버가 허용하는 범위를 벗어납니다.",
|
||||||
|
"schedule_send_unsupported": "이 계정은 예약 보내기를 지원하지 않습니다.",
|
||||||
|
"schedule_send_cleanup_warning": "예약 보내기는 생성되었지만 초안 정리에 실패했습니다.",
|
||||||
|
"send_delay_unsupported": "이 계정은 보내기 지연을 지원하지 않습니다.",
|
||||||
|
"send_delay_unsupported_confirm": "이 계정은 보내기 지연을 지원하지 않습니다. 바로 보낼까요?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "확인",
|
"confirm": "확인",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "첨부 파일에 이미지 미리보기 표시",
|
"label": "첨부 파일에 이미지 미리보기 표시",
|
||||||
"description": "이미지 첨부 파일을 일반 파일 아이콘 대신 썸네일 카드로 표시합니다"
|
"description": "이미지 첨부 파일을 일반 파일 아이콘 대신 썸네일 카드로 표시합니다"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "보내기 취소 / 보내기 지연",
|
||||||
|
"description": "일반 보내기를 서버 측 짧은 시간 동안 지연합니다.",
|
||||||
|
"off": "끔",
|
||||||
|
"seconds": "{seconds}초",
|
||||||
|
"unsupported": "현재 계정은 지연 보내기 지원을 알리지 않습니다. 설정은 다른 계정을 위해 계속 저장됩니다."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "태그",
|
"color_tag": "태그",
|
||||||
"remove_color": "태그 제거",
|
"remove_color": "태그 제거",
|
||||||
"items_selected": "{count}개의 메일 선택됨",
|
"items_selected": "{count}개의 메일 선택됨",
|
||||||
"edit_draft": "임시보관 메일 수정"
|
"edit_draft": "임시보관 메일 수정",
|
||||||
|
"cancel_scheduled_send": "보내기 취소",
|
||||||
|
"reschedule_send": "다시 예약",
|
||||||
|
"cancel_and_edit": "취소하고 편집",
|
||||||
|
"cancel_and_compose_again": "취소하고 다시 작성"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "폴더를 읽음으로 표시",
|
"mark_folder_read": "폴더를 읽음으로 표시",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "대화 펼치기/접기"
|
"expand_collapse": "대화 펼치기/접기"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "이메일 보내기",
|
||||||
|
"schedule_send": "보내기 예약",
|
||||||
"template_picker": "템플릿 선택기 열기"
|
"template_picker": "템플릿 선택기 열기"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "Pasts",
|
"mail": "Pasts",
|
||||||
"nav_label": "Navigācija",
|
"nav_label": "Navigācija",
|
||||||
"add_app": "Lietotnes",
|
"add_app": "Lietotnes",
|
||||||
"shared": "Koplietots"
|
"shared": "Koplietots",
|
||||||
|
"scheduled": "Ieplānots"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Noklusējuma lietotnes",
|
"title": "Noklusējuma lietotnes",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Iztīrīt mapi",
|
"confirm_button": "Iztīrīt mapi",
|
||||||
"junk_hint": "Jūs varat iztīrīt mapi Mēstules, lai neatgriezeniski dzēstu visus ziņojumus.",
|
"junk_hint": "Jūs varat iztīrīt mapi Mēstules, lai neatgriezeniski dzēstu visus ziņojumus.",
|
||||||
"trash_hint": "Jūs varat iztīrīt Atkritni, lai neatgriezeniski dzēstu visus ziņojumus."
|
"trash_hint": "Jūs varat iztīrīt Atkritni, lai neatgriezeniski dzēstu visus ziņojumus."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "Nav ieplānotu e-pastu",
|
||||||
|
"no_scheduled_emails_description": "Ziņojumi, kas ieplānoti vēlākai nosūtīšanai, būs redzami šeit.",
|
||||||
|
"scheduled_count": "{count, plural, one {1 ieplānots e-pasts} other {# ieplānoti e-pasti}}",
|
||||||
|
"scheduled_actions_hint": "Ieplānotos ziņojumus var atcelt, pārplānot vai rediģēt no ieplānotajām darbībām.",
|
||||||
|
"cancel_scheduled_send": "Atcelt sūtīšanu",
|
||||||
|
"reschedule_send": "Pārplānot",
|
||||||
|
"cancel_and_edit": "Atcelt un rediģēt",
|
||||||
|
"cancel_and_compose_again": "Atcelt un rakstīt no jauna",
|
||||||
|
"reschedule_prompt": "Ievadiet jaunu datumu/laiku, piem. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Nav atlasīta neviena vēstule",
|
"no_email_selected": "Nav atlasīta neviena vēstule",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "AI vērtējums",
|
"ai_verdict": "AI vērtējums",
|
||||||
"account": "Konts",
|
"account": "Konts",
|
||||||
"no_subject": "(bez temata)"
|
"no_subject": "(bez temata)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "Ieplānots nosūtīšanai {date}",
|
||||||
|
"scheduled_send_created": "E-pasts ieplānots nosūtīšanai",
|
||||||
|
"cancel_scheduled_send": "Atcelt sūtīšanu",
|
||||||
|
"reschedule_send": "Pārplānot",
|
||||||
|
"cancel_and_edit": "Atcelt un rediģēt",
|
||||||
|
"cancel_and_compose_again": "Atcelt un rakstīt no jauna",
|
||||||
|
"reschedule_prompt": "Ievadiet jaunu datumu/laiku, piem. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "Ieplānoto skatā ir pieejamas tikai ieplānošanas darbības",
|
||||||
|
"undo_send_scheduled": "Ziņojums ir ieplānots nosūtīšanai",
|
||||||
|
"undo_send": "Atsaukt sūtīšanu"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Jauns ziņojums",
|
"new_message": "Jauns ziņojums",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "Pievienot saiti",
|
"add_link": "Pievienot saiti",
|
||||||
"link_url_prompt": "Ievadiet URL",
|
"link_url_prompt": "Ievadiet URL",
|
||||||
"sending": "Sūta..."
|
"sending": "Sūta...",
|
||||||
|
"schedule_send": "Ieplānot sūtīšanu",
|
||||||
|
"schedule_send_description": "Izvēlieties, kad serverim jānosūta šis ziņojums.",
|
||||||
|
"schedule_send_required": "Izvēlieties datumu un laiku.",
|
||||||
|
"schedule_send_invalid": "Ievadiet derīgu datumu un laiku.",
|
||||||
|
"schedule_send_future": "Izvēlieties datumu un laiku nākotnē.",
|
||||||
|
"schedule_send_too_late": "Šis laiks ir vēlāk, nekā serveris atļauj.",
|
||||||
|
"schedule_send_unsupported": "Šim kontam ieplānota sūtīšana netiek atbalstīta.",
|
||||||
|
"schedule_send_cleanup_warning": "Ieplānotā sūtīšana tika izveidota, bet melnraksta tīrīšana neizdevās.",
|
||||||
|
"send_delay_unsupported": "Šim kontam sūtīšanas aizture netiek atbalstīta.",
|
||||||
|
"send_delay_unsupported_confirm": "Šis konts neatbalsta sūtīšanas aizturi. Sūtīt uzreiz?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Apstiprināt",
|
"confirm": "Apstiprināt",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Rādīt attēlu priekšskatījumus pielikumos",
|
"label": "Rādīt attēlu priekšskatījumus pielikumos",
|
||||||
"description": "Rādīt attēlu pielikumus kā sīktēlu kartītes, nevis vispārīgas failu ikonas"
|
"description": "Rādīt attēlu pielikumus kā sīktēlu kartītes, nevis vispārīgas failu ikonas"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Atsaukt sūtīšanu / sūtīšanas aizture",
|
||||||
|
"description": "Aizturiet parastu sūtīšanu īsā servera puses logā.",
|
||||||
|
"off": "Izslēgts",
|
||||||
|
"seconds": "{seconds} sekundes",
|
||||||
|
"unsupported": "Pašreizējais konts neziņo par aizturētas sūtīšanas atbalstu. Iestatījums joprojām tiks saglabāts citiem kontiem."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "Tags",
|
"color_tag": "Tags",
|
||||||
"remove_color": "Noņemt tagu",
|
"remove_color": "Noņemt tagu",
|
||||||
"items_selected": "{count} vēstules atlasītas",
|
"items_selected": "{count} vēstules atlasītas",
|
||||||
"edit_draft": "Rediģēt melnrakstu"
|
"edit_draft": "Rediģēt melnrakstu",
|
||||||
|
"cancel_scheduled_send": "Atcelt sūtīšanu",
|
||||||
|
"reschedule_send": "Pārplānot",
|
||||||
|
"cancel_and_edit": "Atcelt un rediģēt",
|
||||||
|
"cancel_and_compose_again": "Atcelt un rakstīt no jauna"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Atzīmēt mapi kā lasītu",
|
"mark_folder_read": "Atzīmēt mapi kā lasītu",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "Izvērst/sairt sarunu"
|
"expand_collapse": "Izvērst/sairt sarunu"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "Nosūtīt e-pastu",
|
||||||
|
"schedule_send": "Ieplānot nosūtīšanu",
|
||||||
"template_picker": "Atvērt veidņu izvēli"
|
"template_picker": "Atvērt veidņu izvēli"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navigatie",
|
"nav_label": "Navigatie",
|
||||||
"add_app": "Apps",
|
"add_app": "Apps",
|
||||||
"shared": "Gedeeld"
|
"shared": "Gedeeld",
|
||||||
|
"scheduled": "Gepland"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Standaardapps",
|
"title": "Standaardapps",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Map legen",
|
"confirm_button": "Map legen",
|
||||||
"junk_hint": "U kunt de map Spam legen om alle berichten permanent te verwijderen.",
|
"junk_hint": "U kunt de map Spam legen om alle berichten permanent te verwijderen.",
|
||||||
"trash_hint": "U kunt de prullenbak legen om alle berichten permanent te verwijderen."
|
"trash_hint": "U kunt de prullenbak legen om alle berichten permanent te verwijderen."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "Geen geplande e-mails",
|
||||||
|
"no_scheduled_emails_description": "Berichten die voor later zijn gepland verschijnen hier.",
|
||||||
|
"scheduled_count": "{count, plural, one {1 geplande e-mail} other {# geplande e-mails}}",
|
||||||
|
"scheduled_actions_hint": "Geplande berichten kunnen worden geannuleerd, opnieuw gepland of bewerkt via hun geplande acties.",
|
||||||
|
"cancel_scheduled_send": "Verzenden annuleren",
|
||||||
|
"reschedule_send": "Opnieuw plannen",
|
||||||
|
"cancel_and_edit": "Annuleren en bewerken",
|
||||||
|
"cancel_and_compose_again": "Annuleren en opnieuw opstellen",
|
||||||
|
"reschedule_prompt": "Voer een nieuwe datum/tijd in, bijv. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Geen e-mail geselecteerd",
|
"no_email_selected": "Geen e-mail geselecteerd",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "AI-oordeel",
|
"ai_verdict": "AI-oordeel",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"no_subject": "(geen onderwerp)"
|
"no_subject": "(geen onderwerp)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "Gepland om te verzenden op {date}",
|
||||||
|
"scheduled_send_created": "E-mail gepland voor verzending",
|
||||||
|
"cancel_scheduled_send": "Verzenden annuleren",
|
||||||
|
"reschedule_send": "Opnieuw plannen",
|
||||||
|
"cancel_and_edit": "Annuleren en bewerken",
|
||||||
|
"cancel_and_compose_again": "Annuleren en opnieuw opstellen",
|
||||||
|
"reschedule_prompt": "Voer een nieuwe datum/tijd in, bijv. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "De geplande weergave ondersteunt alleen geplande acties",
|
||||||
|
"undo_send_scheduled": "Bericht gepland voor verzending",
|
||||||
|
"undo_send": "Verzenden ongedaan maken"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Nieuw bericht",
|
"new_message": "Nieuw bericht",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "Link toevoegen",
|
"add_link": "Link toevoegen",
|
||||||
"link_url_prompt": "Voer de URL in",
|
"link_url_prompt": "Voer de URL in",
|
||||||
"sending": "Bezig met verzenden..."
|
"sending": "Bezig met verzenden...",
|
||||||
|
"schedule_send": "Verzenden plannen",
|
||||||
|
"schedule_send_description": "Kies wanneer de server dit bericht moet vrijgeven.",
|
||||||
|
"schedule_send_required": "Kies een datum en tijd.",
|
||||||
|
"schedule_send_invalid": "Voer een geldige datum en tijd in.",
|
||||||
|
"schedule_send_future": "Kies een toekomstige datum en tijd.",
|
||||||
|
"schedule_send_too_late": "Dit tijdstip is later dan de server toestaat.",
|
||||||
|
"schedule_send_unsupported": "Gepland verzenden wordt niet ondersteund voor dit account.",
|
||||||
|
"schedule_send_cleanup_warning": "Gepland verzenden is aangemaakt, maar het opschonen van de conceptversie is mislukt.",
|
||||||
|
"send_delay_unsupported": "Verzendvertraging wordt niet ondersteund voor dit account.",
|
||||||
|
"send_delay_unsupported_confirm": "Dit account ondersteunt geen verzendvertraging. Meteen verzenden?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Bevestigen",
|
"confirm": "Bevestigen",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Voorbeelden van afbeeldingen tonen in bijlagen",
|
"label": "Voorbeelden van afbeeldingen tonen in bijlagen",
|
||||||
"description": "Toon afbeeldingsbijlagen als miniatuurkaarten in plaats van generieke bestandspictogrammen"
|
"description": "Toon afbeeldingsbijlagen als miniatuurkaarten in plaats van generieke bestandspictogrammen"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Verzenden ongedaan maken / verzendvertraging",
|
||||||
|
"description": "Vertraag normale verzending met een korte server-side periode.",
|
||||||
|
"off": "Uit",
|
||||||
|
"seconds": "{seconds} seconden",
|
||||||
|
"unsupported": "Het huidige account meldt geen ondersteuning voor vertraagd verzenden. De instelling blijft opgeslagen voor andere accounts."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "Label",
|
"color_tag": "Label",
|
||||||
"remove_color": "Label verwijderen",
|
"remove_color": "Label verwijderen",
|
||||||
"items_selected": "{count} e-mails geselecteerd",
|
"items_selected": "{count} e-mails geselecteerd",
|
||||||
"edit_draft": "Concept bewerken"
|
"edit_draft": "Concept bewerken",
|
||||||
|
"cancel_scheduled_send": "Verzenden annuleren",
|
||||||
|
"reschedule_send": "Opnieuw plannen",
|
||||||
|
"cancel_and_edit": "Annuleren en bewerken",
|
||||||
|
"cancel_and_compose_again": "Annuleren en opnieuw opstellen"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Map markeren als gelezen",
|
"mark_folder_read": "Map markeren als gelezen",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "Gesprek uitklappen/inklappen"
|
"expand_collapse": "Gesprek uitklappen/inklappen"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "E-mail verzenden",
|
||||||
|
"schedule_send": "Verzenden plannen",
|
||||||
"template_picker": "Sjabloonkiezer openen"
|
"template_picker": "Sjabloonkiezer openen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "Poczta",
|
"mail": "Poczta",
|
||||||
"nav_label": "Nawigacja",
|
"nav_label": "Nawigacja",
|
||||||
"add_app": "Aplikacje",
|
"add_app": "Aplikacje",
|
||||||
"shared": "Udostępnione"
|
"shared": "Udostępnione",
|
||||||
|
"scheduled": "Zaplanowane"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Aplikacje domyślne",
|
"title": "Aplikacje domyślne",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Opróżnij folder",
|
"confirm_button": "Opróżnij folder",
|
||||||
"junk_hint": "Możesz opróżnić folder Spam, aby trwale usunąć wszystkie wiadomości.",
|
"junk_hint": "Możesz opróżnić folder Spam, aby trwale usunąć wszystkie wiadomości.",
|
||||||
"trash_hint": "Możesz opróżnić Kosz, aby trwale usunąć wszystkie wiadomości."
|
"trash_hint": "Możesz opróżnić Kosz, aby trwale usunąć wszystkie wiadomości."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "Brak zaplanowanych e-maili",
|
||||||
|
"no_scheduled_emails_description": "Wiadomości zaplanowane na później pojawią się tutaj.",
|
||||||
|
"scheduled_count": "{count, plural, one {1 zaplanowany e-mail} few {# zaplanowane e-maile} other {# zaplanowanych e-maili}}",
|
||||||
|
"scheduled_actions_hint": "Zaplanowane wiadomości można anulować, przełożyć lub edytować z poziomu akcji planowania.",
|
||||||
|
"cancel_scheduled_send": "Anuluj wysyłkę",
|
||||||
|
"reschedule_send": "Przełóż",
|
||||||
|
"cancel_and_edit": "Anuluj i edytuj",
|
||||||
|
"cancel_and_compose_again": "Anuluj i napisz ponownie",
|
||||||
|
"reschedule_prompt": "Wprowadź nową datę/godzinę, np. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Nie wybrano wiadomości",
|
"no_email_selected": "Nie wybrano wiadomości",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "Werdykt AI",
|
"ai_verdict": "Werdykt AI",
|
||||||
"account": "Konto",
|
"account": "Konto",
|
||||||
"no_subject": "(brak tematu)"
|
"no_subject": "(brak tematu)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "Zaplanowano wysyłkę na {date}",
|
||||||
|
"scheduled_send_created": "E-mail zaplanowany do wysłania",
|
||||||
|
"cancel_scheduled_send": "Anuluj wysyłkę",
|
||||||
|
"reschedule_send": "Przełóż",
|
||||||
|
"cancel_and_edit": "Anuluj i edytuj",
|
||||||
|
"cancel_and_compose_again": "Anuluj i napisz ponownie",
|
||||||
|
"reschedule_prompt": "Wprowadź nową datę/godzinę, np. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "Widok Zaplanowane obsługuje tylko akcje planowania",
|
||||||
|
"undo_send_scheduled": "Wiadomość zaplanowana do wysłania",
|
||||||
|
"undo_send": "Cofnij wysyłkę"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Nowa wiadomość",
|
"new_message": "Nowa wiadomość",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "Dodaj link",
|
"add_link": "Dodaj link",
|
||||||
"link_url_prompt": "Wprowadź adres URL",
|
"link_url_prompt": "Wprowadź adres URL",
|
||||||
"sending": "Wysyłanie..."
|
"sending": "Wysyłanie...",
|
||||||
|
"schedule_send": "Zaplanuj wysyłkę",
|
||||||
|
"schedule_send_description": "Wybierz, kiedy serwer ma wysłać tę wiadomość.",
|
||||||
|
"schedule_send_required": "Wybierz datę i godzinę.",
|
||||||
|
"schedule_send_invalid": "Wprowadź prawidłową datę i godzinę.",
|
||||||
|
"schedule_send_future": "Wybierz datę i godzinę w przyszłości.",
|
||||||
|
"schedule_send_too_late": "Ten czas jest późniejszy, niż pozwala serwer.",
|
||||||
|
"schedule_send_unsupported": "Zaplanowana wysyłka nie jest obsługiwana dla tego konta.",
|
||||||
|
"schedule_send_cleanup_warning": "Zaplanowana wysyłka została utworzona, ale czyszczenie wersji roboczej nie powiodło się.",
|
||||||
|
"send_delay_unsupported": "Opóźnienie wysyłki nie jest obsługiwane dla tego konta.",
|
||||||
|
"send_delay_unsupported_confirm": "To konto nie obsługuje opóźnienia wysyłki. Wysłać natychmiast?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Potwierdź",
|
"confirm": "Potwierdź",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Pokaż podgląd obrazów w załącznikach",
|
"label": "Pokaż podgląd obrazów w załącznikach",
|
||||||
"description": "Wyświetlaj załączone obrazy jako miniatury zamiast ogólnych ikon plików"
|
"description": "Wyświetlaj załączone obrazy jako miniatury zamiast ogólnych ikon plików"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Cofnij wysyłkę / opóźnienie wysyłki",
|
||||||
|
"description": "Opóźnia normalne wysyłanie krótkim oknem po stronie serwera.",
|
||||||
|
"off": "Wyłączone",
|
||||||
|
"seconds": "{seconds} sekund",
|
||||||
|
"unsupported": "Bieżące konto nie zgłasza obsługi opóźnionej wysyłki. Ustawienie pozostanie zapisane dla innych kont."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "Etykieta",
|
"color_tag": "Etykieta",
|
||||||
"remove_color": "Usuń etykietę",
|
"remove_color": "Usuń etykietę",
|
||||||
"items_selected": "{count} zaznaczonych wiadomości",
|
"items_selected": "{count} zaznaczonych wiadomości",
|
||||||
"edit_draft": "Edytuj szkic"
|
"edit_draft": "Edytuj szkic",
|
||||||
|
"cancel_scheduled_send": "Anuluj wysyłkę",
|
||||||
|
"reschedule_send": "Przełóż",
|
||||||
|
"cancel_and_edit": "Anuluj i edytuj",
|
||||||
|
"cancel_and_compose_again": "Anuluj i napisz ponownie"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Oznacz folder jako przeczytany",
|
"mark_folder_read": "Oznacz folder jako przeczytany",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "Rozwiń/zwiń wątek"
|
"expand_collapse": "Rozwiń/zwiń wątek"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "Wyślij e-mail",
|
||||||
|
"schedule_send": "Zaplanuj wysyłkę",
|
||||||
"template_picker": "Otwórz wybór szablonu"
|
"template_picker": "Otwórz wybór szablonu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navegação",
|
"nav_label": "Navegação",
|
||||||
"add_app": "Apps",
|
"add_app": "Apps",
|
||||||
"shared": "Compartilhado"
|
"shared": "Compartilhado",
|
||||||
|
"scheduled": "Agendados"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Aplicativos padrão",
|
"title": "Aplicativos padrão",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Esvaziar pasta",
|
"confirm_button": "Esvaziar pasta",
|
||||||
"junk_hint": "Você pode esvaziar a pasta de Spam para remover permanentemente todas as mensagens.",
|
"junk_hint": "Você pode esvaziar a pasta de Spam para remover permanentemente todas as mensagens.",
|
||||||
"trash_hint": "Você pode esvaziar a Lixeira para remover permanentemente todas as mensagens."
|
"trash_hint": "Você pode esvaziar a Lixeira para remover permanentemente todas as mensagens."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "Nenhum e-mail agendado",
|
||||||
|
"no_scheduled_emails_description": "Mensagens agendadas para mais tarde aparecerão aqui.",
|
||||||
|
"scheduled_count": "{count, plural, one {1 e-mail agendado} other {# e-mails agendados}}",
|
||||||
|
"scheduled_actions_hint": "Mensagens agendadas podem ser canceladas, reagendadas ou editadas pelas ações de agendamento.",
|
||||||
|
"cancel_scheduled_send": "Cancelar envio",
|
||||||
|
"reschedule_send": "Reagendar",
|
||||||
|
"cancel_and_edit": "Cancelar e editar",
|
||||||
|
"cancel_and_compose_again": "Cancelar e escrever novamente",
|
||||||
|
"reschedule_prompt": "Informe uma nova data/hora, ex. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Nenhum e-mail selecionado",
|
"no_email_selected": "Nenhum e-mail selecionado",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "Veredito da IA",
|
"ai_verdict": "Veredito da IA",
|
||||||
"account": "Conta",
|
"account": "Conta",
|
||||||
"no_subject": "(sem assunto)"
|
"no_subject": "(sem assunto)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "Agendado para enviar em {date}",
|
||||||
|
"scheduled_send_created": "E-mail agendado para envio",
|
||||||
|
"cancel_scheduled_send": "Cancelar envio",
|
||||||
|
"reschedule_send": "Reagendar",
|
||||||
|
"cancel_and_edit": "Cancelar e editar",
|
||||||
|
"cancel_and_compose_again": "Cancelar e escrever novamente",
|
||||||
|
"reschedule_prompt": "Informe uma nova data/hora, ex. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "A visualização Agendados permite apenas ações de agendamento",
|
||||||
|
"undo_send_scheduled": "Mensagem agendada para envio",
|
||||||
|
"undo_send": "Desfazer envio"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Nova Mensagem",
|
"new_message": "Nova Mensagem",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "Adicionar link",
|
"add_link": "Adicionar link",
|
||||||
"link_url_prompt": "Insira a URL",
|
"link_url_prompt": "Insira a URL",
|
||||||
"sending": "Enviando..."
|
"sending": "Enviando...",
|
||||||
|
"schedule_send": "Agendar envio",
|
||||||
|
"schedule_send_description": "Escolha quando o servidor deve liberar esta mensagem.",
|
||||||
|
"schedule_send_required": "Escolha uma data e hora.",
|
||||||
|
"schedule_send_invalid": "Informe uma data e hora válidas.",
|
||||||
|
"schedule_send_future": "Escolha uma data e hora no futuro.",
|
||||||
|
"schedule_send_too_late": "Este horário é posterior ao permitido pelo servidor.",
|
||||||
|
"schedule_send_unsupported": "Envio agendado não é compatível com esta conta.",
|
||||||
|
"schedule_send_cleanup_warning": "O envio agendado foi criado, mas a limpeza do rascunho falhou.",
|
||||||
|
"send_delay_unsupported": "Atraso de envio não é compatível com esta conta.",
|
||||||
|
"send_delay_unsupported_confirm": "Esta conta não oferece atraso de envio. Enviar imediatamente?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Mostrar prévias de imagens em anexos",
|
"label": "Mostrar prévias de imagens em anexos",
|
||||||
"description": "Renderizar anexos de imagem como miniaturas em vez de ícones genéricos de arquivo"
|
"description": "Renderizar anexos de imagem como miniaturas em vez de ícones genéricos de arquivo"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Desfazer envio / atraso de envio",
|
||||||
|
"description": "Atrase envios normais com uma breve janela no servidor.",
|
||||||
|
"off": "Desativado",
|
||||||
|
"seconds": "{seconds} segundos",
|
||||||
|
"unsupported": "A conta atual não anuncia suporte a envio atrasado. A configuração continuará salva para outras contas."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "Etiqueta",
|
"color_tag": "Etiqueta",
|
||||||
"remove_color": "Remover etiqueta",
|
"remove_color": "Remover etiqueta",
|
||||||
"items_selected": "{count} e-mails selecionados",
|
"items_selected": "{count} e-mails selecionados",
|
||||||
"edit_draft": "Editar rascunho"
|
"edit_draft": "Editar rascunho",
|
||||||
|
"cancel_scheduled_send": "Cancelar envio",
|
||||||
|
"reschedule_send": "Reagendar",
|
||||||
|
"cancel_and_edit": "Cancelar e editar",
|
||||||
|
"cancel_and_compose_again": "Cancelar e escrever novamente"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Marcar pasta como lida",
|
"mark_folder_read": "Marcar pasta como lida",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "Expandir/recolher conversa"
|
"expand_collapse": "Expandir/recolher conversa"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "Enviar e-mail",
|
||||||
|
"schedule_send": "Agendar envio",
|
||||||
"template_picker": "Abrir seletor de modelos"
|
"template_picker": "Abrir seletor de modelos"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "Почта",
|
"mail": "Почта",
|
||||||
"nav_label": "Навигация",
|
"nav_label": "Навигация",
|
||||||
"add_app": "Приложения",
|
"add_app": "Приложения",
|
||||||
"shared": "Общие"
|
"shared": "Общие",
|
||||||
|
"scheduled": "Запланировано"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Приложения по умолчанию",
|
"title": "Приложения по умолчанию",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Очистить папку",
|
"confirm_button": "Очистить папку",
|
||||||
"junk_hint": "Вы можете очистить папку Спам для постоянного удаления всех сообщений.",
|
"junk_hint": "Вы можете очистить папку Спам для постоянного удаления всех сообщений.",
|
||||||
"trash_hint": "Вы можете очистить Корзину для постоянного удаления всех сообщений."
|
"trash_hint": "Вы можете очистить Корзину для постоянного удаления всех сообщений."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "Нет запланированных писем",
|
||||||
|
"no_scheduled_emails_description": "Сообщения, запланированные на позже, появятся здесь.",
|
||||||
|
"scheduled_count": "{count, plural, one {# запланированное письмо} few {# запланированных письма} other {# запланированных писем}}",
|
||||||
|
"scheduled_actions_hint": "Запланированные сообщения можно отменить, перенести или изменить через действия планирования.",
|
||||||
|
"cancel_scheduled_send": "Отменить отправку",
|
||||||
|
"reschedule_send": "Перенести",
|
||||||
|
"cancel_and_edit": "Отменить и изменить",
|
||||||
|
"cancel_and_compose_again": "Отменить и написать заново",
|
||||||
|
"reschedule_prompt": "Введите новую дату/время, например 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Письмо не выбрано",
|
"no_email_selected": "Письмо не выбрано",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "Вердикт ИИ",
|
"ai_verdict": "Вердикт ИИ",
|
||||||
"account": "Аккаунт",
|
"account": "Аккаунт",
|
||||||
"no_subject": "(без темы)"
|
"no_subject": "(без темы)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "Запланировано к отправке {date}",
|
||||||
|
"scheduled_send_created": "Письмо запланировано к отправке",
|
||||||
|
"cancel_scheduled_send": "Отменить отправку",
|
||||||
|
"reschedule_send": "Перенести",
|
||||||
|
"cancel_and_edit": "Отменить и изменить",
|
||||||
|
"cancel_and_compose_again": "Отменить и написать заново",
|
||||||
|
"reschedule_prompt": "Введите новую дату/время, например 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "В представлении запланированных доступны только действия планирования",
|
||||||
|
"undo_send_scheduled": "Сообщение запланировано к отправке",
|
||||||
|
"undo_send": "Отменить отправку"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Новое письмо",
|
"new_message": "Новое письмо",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "Добавить ссылку",
|
"add_link": "Добавить ссылку",
|
||||||
"link_url_prompt": "Введите URL",
|
"link_url_prompt": "Введите URL",
|
||||||
"sending": "Отправка..."
|
"sending": "Отправка...",
|
||||||
|
"schedule_send": "Запланировать отправку",
|
||||||
|
"schedule_send_description": "Выберите, когда сервер должен отправить это сообщение.",
|
||||||
|
"schedule_send_required": "Выберите дату и время.",
|
||||||
|
"schedule_send_invalid": "Введите действительные дату и время.",
|
||||||
|
"schedule_send_future": "Выберите дату и время в будущем.",
|
||||||
|
"schedule_send_too_late": "Это время позже, чем разрешает сервер.",
|
||||||
|
"schedule_send_unsupported": "Запланированная отправка не поддерживается для этой учетной записи.",
|
||||||
|
"schedule_send_cleanup_warning": "Запланированная отправка создана, но очистка черновика не удалась.",
|
||||||
|
"send_delay_unsupported": "Задержка отправки не поддерживается для этой учетной записи.",
|
||||||
|
"send_delay_unsupported_confirm": "Эта учетная запись не поддерживает задержку отправки. Отправить сразу?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Показывать миниатюры изображений во вложениях",
|
"label": "Показывать миниатюры изображений во вложениях",
|
||||||
"description": "Отображать вложенные изображения в виде миниатюр вместо обычных значков файлов"
|
"description": "Отображать вложенные изображения в виде миниатюр вместо обычных значков файлов"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Отмена отправки / задержка отправки",
|
||||||
|
"description": "Задерживает обычную отправку на короткий серверный интервал.",
|
||||||
|
"off": "Выкл.",
|
||||||
|
"seconds": "{seconds} сек.",
|
||||||
|
"unsupported": "Текущая учетная запись не заявляет поддержку отложенной отправки. Настройка сохранится для других учетных записей."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "Тег",
|
"color_tag": "Тег",
|
||||||
"remove_color": "Удалить тег",
|
"remove_color": "Удалить тег",
|
||||||
"items_selected": "{count} писем выбрано",
|
"items_selected": "{count} писем выбрано",
|
||||||
"edit_draft": "Редактировать черновик"
|
"edit_draft": "Редактировать черновик",
|
||||||
|
"cancel_scheduled_send": "Отменить отправку",
|
||||||
|
"reschedule_send": "Перенести",
|
||||||
|
"cancel_and_edit": "Отменить и изменить",
|
||||||
|
"cancel_and_compose_again": "Отменить и написать заново"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Отметить папку как прочитанную",
|
"mark_folder_read": "Отметить папку как прочитанную",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "Развернуть/свернуть цепочку"
|
"expand_collapse": "Развернуть/свернуть цепочку"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "Отправить письмо",
|
||||||
|
"schedule_send": "Запланировать отправку",
|
||||||
"template_picker": "Открыть выбор шаблонов"
|
"template_picker": "Открыть выбор шаблонов"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"shared": "Paylaşılan",
|
"shared": "Paylaşılan",
|
||||||
"mail": "Posta",
|
"mail": "Posta",
|
||||||
"nav_label": "Gezinme",
|
"nav_label": "Gezinme",
|
||||||
"add_app": "Uygulamalar"
|
"add_app": "Uygulamalar",
|
||||||
|
"scheduled": "Zamanlandı"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Varsayılan uygulamalar",
|
"title": "Varsayılan uygulamalar",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Klasörü boşalt",
|
"confirm_button": "Klasörü boşalt",
|
||||||
"junk_hint": "Tüm iletileri kalıcı olarak kaldırmak için Önemsiz klasörünü boşaltabilirsiniz.",
|
"junk_hint": "Tüm iletileri kalıcı olarak kaldırmak için Önemsiz klasörünü boşaltabilirsiniz.",
|
||||||
"trash_hint": "Tüm iletileri kalıcı olarak kaldırmak için Çöp Kutusu klasörünü boşaltabilirsiniz."
|
"trash_hint": "Tüm iletileri kalıcı olarak kaldırmak için Çöp Kutusu klasörünü boşaltabilirsiniz."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "Zamanlanmış e-posta yok",
|
||||||
|
"no_scheduled_emails_description": "Daha sonra gönderilecek iletiler burada görünür.",
|
||||||
|
"scheduled_count": "{count, plural, one {1 zamanlanmış e-posta} other {# zamanlanmış e-posta}}",
|
||||||
|
"scheduled_actions_hint": "Zamanlanmış iletiler zamanlama işlemlerinden iptal edilebilir, yeniden zamanlanabilir veya düzenlenebilir.",
|
||||||
|
"cancel_scheduled_send": "Göndermeyi iptal et",
|
||||||
|
"reschedule_send": "Yeniden zamanla",
|
||||||
|
"cancel_and_edit": "İptal et ve düzenle",
|
||||||
|
"cancel_and_compose_again": "İptal et ve yeniden yaz",
|
||||||
|
"reschedule_prompt": "Yeni tarih/saat girin, ör. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "E-posta seçilmedi",
|
"no_email_selected": "E-posta seçilmedi",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "Yapay zeka kararı",
|
"ai_verdict": "Yapay zeka kararı",
|
||||||
"account": "Hesap",
|
"account": "Hesap",
|
||||||
"no_subject": "(konu yok)"
|
"no_subject": "(konu yok)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "{date} tarihinde gönderilmek üzere zamanlandı",
|
||||||
|
"scheduled_send_created": "E-posta gönderim için zamanlandı",
|
||||||
|
"cancel_scheduled_send": "Göndermeyi iptal et",
|
||||||
|
"reschedule_send": "Yeniden zamanla",
|
||||||
|
"cancel_and_edit": "İptal et ve düzenle",
|
||||||
|
"cancel_and_compose_again": "İptal et ve yeniden yaz",
|
||||||
|
"reschedule_prompt": "Yeni tarih/saat girin, ör. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "Zamanlandı görünümü yalnızca zamanlama işlemlerini destekler",
|
||||||
|
"undo_send_scheduled": "İleti gönderim için zamanlandı",
|
||||||
|
"undo_send": "Göndermeyi geri al"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Yeni İleti",
|
"new_message": "Yeni İleti",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
"message": "İletinizde \"{keyword}\" geçiyor ancak hiçbir dosya eklenmemiş. Yine de gönderilsin mi?",
|
"message": "İletinizde \"{keyword}\" geçiyor ancak hiçbir dosya eklenmemiş. Yine de gönderilsin mi?",
|
||||||
"send_anyway": "Yine de gönder",
|
"send_anyway": "Yine de gönder",
|
||||||
"back": "Düzenlemeye dön"
|
"back": "Düzenlemeye dön"
|
||||||
}
|
},
|
||||||
|
"schedule_send": "Göndermeyi zamanla",
|
||||||
|
"schedule_send_description": "Sunucunun bu iletiyi ne zaman göndereceğini seçin.",
|
||||||
|
"schedule_send_required": "Bir tarih ve saat seçin.",
|
||||||
|
"schedule_send_invalid": "Geçerli bir tarih ve saat girin.",
|
||||||
|
"schedule_send_future": "Gelecekte bir tarih ve saat seçin.",
|
||||||
|
"schedule_send_too_late": "Bu zaman sunucunun izin verdiğinden daha geç.",
|
||||||
|
"schedule_send_unsupported": "Bu hesap için zamanlanmış gönderim desteklenmiyor.",
|
||||||
|
"schedule_send_cleanup_warning": "Zamanlanmış gönderim oluşturuldu, ancak taslak temizliği başarısız oldu.",
|
||||||
|
"send_delay_unsupported": "Bu hesap için gönderim gecikmesi desteklenmiyor.",
|
||||||
|
"send_delay_unsupported_confirm": "Bu hesap gönderim gecikmesini desteklemiyor. Hemen gönderilsin mi?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Onayla",
|
"confirm": "Onayla",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Eklerde resim önizlemelerini göster",
|
"label": "Eklerde resim önizlemelerini göster",
|
||||||
"description": "Resim eklerini, genel dosya simgeleri yerine küçük resim kartları olarak göster"
|
"description": "Resim eklerini, genel dosya simgeleri yerine küçük resim kartları olarak göster"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Göndermeyi geri al / gönderim gecikmesi",
|
||||||
|
"description": "Normal gönderimleri sunucu tarafında kısa bir süre geciktirin.",
|
||||||
|
"off": "Kapalı",
|
||||||
|
"seconds": "{seconds} saniye",
|
||||||
|
"unsupported": "Geçerli hesap gecikmeli gönderim desteği bildirmiyor. Ayar diğer hesaplar için kaydedilmeye devam eder."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "Etiket",
|
"color_tag": "Etiket",
|
||||||
"remove_color": "Etiketi kaldır",
|
"remove_color": "Etiketi kaldır",
|
||||||
"items_selected": "{count} e-posta seçildi",
|
"items_selected": "{count} e-posta seçildi",
|
||||||
"edit_draft": "Taslağı Düzenle"
|
"edit_draft": "Taslağı Düzenle",
|
||||||
|
"cancel_scheduled_send": "Göndermeyi iptal et",
|
||||||
|
"reschedule_send": "Yeniden zamanla",
|
||||||
|
"cancel_and_edit": "İptal et ve düzenle",
|
||||||
|
"cancel_and_compose_again": "İptal et ve yeniden yaz"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Klasörü okundu olarak işaretle",
|
"mark_folder_read": "Klasörü okundu olarak işaretle",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "İleti dizisini genişlet/daralt"
|
"expand_collapse": "İleti dizisini genişlet/daralt"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "E-posta gönder",
|
||||||
|
"schedule_send": "Göndermeyi planla",
|
||||||
"template_picker": "Şablon seçiciyi aç"
|
"template_picker": "Şablon seçiciyi aç"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "Пошта",
|
"mail": "Пошта",
|
||||||
"nav_label": "Навігація",
|
"nav_label": "Навігація",
|
||||||
"add_app": "програми",
|
"add_app": "програми",
|
||||||
"shared": "Спільні"
|
"shared": "Спільні",
|
||||||
|
"scheduled": "Заплановано"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "Програми за замовчуванням",
|
"title": "Програми за замовчуванням",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "Порожня папка",
|
"confirm_button": "Порожня папка",
|
||||||
"junk_hint": "Ви можете очистити папку «Сміття», щоб остаточно видалити всі повідомлення.",
|
"junk_hint": "Ви можете очистити папку «Сміття», щоб остаточно видалити всі повідомлення.",
|
||||||
"trash_hint": "Ви можете очистити папку «Кошик», щоб остаточно видалити всі повідомлення."
|
"trash_hint": "Ви можете очистити папку «Кошик», щоб остаточно видалити всі повідомлення."
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "Немає запланованих листів",
|
||||||
|
"no_scheduled_emails_description": "Повідомлення, заплановані на пізніше, з’являться тут.",
|
||||||
|
"scheduled_count": "{count, plural, one {# запланований лист} few {# заплановані листи} other {# запланованих листів}}",
|
||||||
|
"scheduled_actions_hint": "Заплановані повідомлення можна скасувати, перенести або редагувати через дії планування.",
|
||||||
|
"cancel_scheduled_send": "Скасувати надсилання",
|
||||||
|
"reschedule_send": "Перенести",
|
||||||
|
"cancel_and_edit": "Скасувати й редагувати",
|
||||||
|
"cancel_and_compose_again": "Скасувати й написати знову",
|
||||||
|
"reschedule_prompt": "Введіть нову дату/час, напр. 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "Електронна адреса не вибрана",
|
"no_email_selected": "Електронна адреса не вибрана",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "Висновок ШІ",
|
"ai_verdict": "Висновок ШІ",
|
||||||
"account": "Обліковий запис",
|
"account": "Обліковий запис",
|
||||||
"no_subject": "(без теми)"
|
"no_subject": "(без теми)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "Заплановано на надсилання {date}",
|
||||||
|
"scheduled_send_created": "Лист заплановано до надсилання",
|
||||||
|
"cancel_scheduled_send": "Скасувати надсилання",
|
||||||
|
"reschedule_send": "Перенести",
|
||||||
|
"cancel_and_edit": "Скасувати й редагувати",
|
||||||
|
"cancel_and_compose_again": "Скасувати й написати знову",
|
||||||
|
"reschedule_prompt": "Введіть нову дату/час, напр. 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "У поданні Заплановано доступні лише дії планування",
|
||||||
|
"undo_send_scheduled": "Повідомлення заплановано до надсилання",
|
||||||
|
"undo_send": "Скасувати надсилання"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "Нове повідомлення",
|
"new_message": "Нове повідомлення",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "Додати посилання",
|
"add_link": "Додати посилання",
|
||||||
"link_url_prompt": "Введіть URL",
|
"link_url_prompt": "Введіть URL",
|
||||||
"sending": "Надсилання..."
|
"sending": "Надсилання...",
|
||||||
|
"schedule_send": "Запланувати надсилання",
|
||||||
|
"schedule_send_description": "Виберіть, коли сервер має надіслати це повідомлення.",
|
||||||
|
"schedule_send_required": "Виберіть дату й час.",
|
||||||
|
"schedule_send_invalid": "Введіть дійсні дату й час.",
|
||||||
|
"schedule_send_future": "Виберіть дату й час у майбутньому.",
|
||||||
|
"schedule_send_too_late": "Цей час пізніший, ніж дозволяє сервер.",
|
||||||
|
"schedule_send_unsupported": "Заплановане надсилання не підтримується для цього облікового запису.",
|
||||||
|
"schedule_send_cleanup_warning": "Заплановане надсилання створено, але очищення чернетки не вдалося.",
|
||||||
|
"send_delay_unsupported": "Затримка надсилання не підтримується для цього облікового запису.",
|
||||||
|
"send_delay_unsupported_confirm": "Цей обліковий запис не підтримує затримку надсилання. Надіслати негайно?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Підтвердити",
|
"confirm": "Підтвердити",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "Показувати попередній перегляд зображень у вкладеннях",
|
"label": "Показувати попередній перегляд зображень у вкладеннях",
|
||||||
"description": "Відображати вкладені зображення як ескізи замість загальних піктограм файлів"
|
"description": "Відображати вкладені зображення як ескізи замість загальних піктограм файлів"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "Скасувати надсилання / затримка надсилання",
|
||||||
|
"description": "Затримує звичайне надсилання на короткий серверний інтервал.",
|
||||||
|
"off": "Вимкнено",
|
||||||
|
"seconds": "{seconds} с",
|
||||||
|
"unsupported": "Поточний обліковий запис не повідомляє про підтримку відкладеного надсилання. Налаштування залишиться збереженим для інших облікових записів."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "Мітка",
|
"color_tag": "Мітка",
|
||||||
"remove_color": "Видалити мітку",
|
"remove_color": "Видалити мітку",
|
||||||
"items_selected": "Вибрано електронних листів: {count}",
|
"items_selected": "Вибрано електронних листів: {count}",
|
||||||
"edit_draft": "Редагувати чернетку"
|
"edit_draft": "Редагувати чернетку",
|
||||||
|
"cancel_scheduled_send": "Скасувати надсилання",
|
||||||
|
"reschedule_send": "Перенести",
|
||||||
|
"cancel_and_edit": "Скасувати й редагувати",
|
||||||
|
"cancel_and_compose_again": "Скасувати й написати знову"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "Позначити папку як прочитану",
|
"mark_folder_read": "Позначити папку як прочитану",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "Розгорнути/згорнути ланцюжок"
|
"expand_collapse": "Розгорнути/згорнути ланцюжок"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "Надіслати лист",
|
||||||
|
"schedule_send": "Запланувати надсилання",
|
||||||
"template_picker": "Відкрити засіб вибору шаблону"
|
"template_picker": "Відкрити засіб вибору шаблону"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -132,7 +132,8 @@
|
|||||||
"mail": "邮件",
|
"mail": "邮件",
|
||||||
"nav_label": "导航",
|
"nav_label": "导航",
|
||||||
"add_app": "应用",
|
"add_app": "应用",
|
||||||
"shared": "共享"
|
"shared": "共享",
|
||||||
|
"scheduled": "已计划"
|
||||||
},
|
},
|
||||||
"protocol_handlers": {
|
"protocol_handlers": {
|
||||||
"title": "默认应用",
|
"title": "默认应用",
|
||||||
@@ -239,7 +240,16 @@
|
|||||||
"confirm_button": "清空文件夹",
|
"confirm_button": "清空文件夹",
|
||||||
"junk_hint": "您可以清空垃圾邮件文件夹以永久删除所有邮件。",
|
"junk_hint": "您可以清空垃圾邮件文件夹以永久删除所有邮件。",
|
||||||
"trash_hint": "您可以清空已删除文件夹以永久删除所有邮件。"
|
"trash_hint": "您可以清空已删除文件夹以永久删除所有邮件。"
|
||||||
}
|
},
|
||||||
|
"no_scheduled_emails": "没有计划发送的邮件",
|
||||||
|
"no_scheduled_emails_description": "计划稍后发送的邮件会显示在这里。",
|
||||||
|
"scheduled_count": "{count, plural, one {1 封计划发送邮件} other {# 封计划发送邮件}}",
|
||||||
|
"scheduled_actions_hint": "计划发送的邮件可以通过计划操作取消、重新计划或编辑。",
|
||||||
|
"cancel_scheduled_send": "取消发送",
|
||||||
|
"reschedule_send": "重新计划",
|
||||||
|
"cancel_and_edit": "取消并编辑",
|
||||||
|
"cancel_and_compose_again": "取消并重新撰写",
|
||||||
|
"reschedule_prompt": "输入新的日期/时间,例如 2026-05-04T15:30"
|
||||||
},
|
},
|
||||||
"email_viewer": {
|
"email_viewer": {
|
||||||
"no_email_selected": "未选择邮件",
|
"no_email_selected": "未选择邮件",
|
||||||
@@ -514,7 +524,17 @@
|
|||||||
"ai_verdict": "AI 判断",
|
"ai_verdict": "AI 判断",
|
||||||
"account": "账户",
|
"account": "账户",
|
||||||
"no_subject": "(无主题)"
|
"no_subject": "(无主题)"
|
||||||
}
|
},
|
||||||
|
"scheduled_banner": "计划于 {date} 发送",
|
||||||
|
"scheduled_send_created": "邮件已计划发送",
|
||||||
|
"cancel_scheduled_send": "取消发送",
|
||||||
|
"reschedule_send": "重新计划",
|
||||||
|
"cancel_and_edit": "取消并编辑",
|
||||||
|
"cancel_and_compose_again": "取消并重新撰写",
|
||||||
|
"reschedule_prompt": "输入新的日期/时间,例如 2026-05-04T15:30",
|
||||||
|
"scheduled_actions_only": "计划发送视图仅支持计划操作",
|
||||||
|
"undo_send_scheduled": "邮件已计划发送",
|
||||||
|
"undo_send": "撤销发送"
|
||||||
},
|
},
|
||||||
"email_composer": {
|
"email_composer": {
|
||||||
"new_message": "新邮件",
|
"new_message": "新邮件",
|
||||||
@@ -607,7 +627,19 @@
|
|||||||
},
|
},
|
||||||
"add_link": "添加链接",
|
"add_link": "添加链接",
|
||||||
"link_url_prompt": "输入 URL",
|
"link_url_prompt": "输入 URL",
|
||||||
"sending": "发送中..."
|
"sending": "发送中...",
|
||||||
|
"schedule_send": "计划发送",
|
||||||
|
"schedule_send_description": "选择服务器应何时发送此邮件。",
|
||||||
|
"schedule_send_required": "请选择日期和时间。",
|
||||||
|
"schedule_send_invalid": "请输入有效的日期和时间。",
|
||||||
|
"schedule_send_future": "请选择未来的日期和时间。",
|
||||||
|
"schedule_send_too_late": "该时间晚于服务器允许的范围。",
|
||||||
|
"schedule_send_unsupported": "此账户不支持计划发送。",
|
||||||
|
"schedule_send_cleanup_warning": "已创建计划发送,但清理草稿失败。",
|
||||||
|
"send_delay_unsupported": "此账户不支持发送延迟。",
|
||||||
|
"send_delay_unsupported_confirm": "此账户不支持发送延迟。是否立即发送?",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files"
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
@@ -1134,6 +1166,13 @@
|
|||||||
"attachment_image_previews": {
|
"attachment_image_previews": {
|
||||||
"label": "在附件中显示图片预览",
|
"label": "在附件中显示图片预览",
|
||||||
"description": "将图片附件显示为缩略图卡片,而不是通用文件图标"
|
"description": "将图片附件显示为缩略图卡片,而不是通用文件图标"
|
||||||
|
},
|
||||||
|
"send_delay": {
|
||||||
|
"label": "撤销发送 / 发送延迟",
|
||||||
|
"description": "通过服务器端短暂窗口延迟普通发送。",
|
||||||
|
"off": "关闭",
|
||||||
|
"seconds": "{seconds} 秒",
|
||||||
|
"unsupported": "当前账户未声明支持延迟发送。该设置仍会为其他账户保存。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -1803,7 +1842,11 @@
|
|||||||
"color_tag": "标签",
|
"color_tag": "标签",
|
||||||
"remove_color": "删除标签",
|
"remove_color": "删除标签",
|
||||||
"items_selected": "已选择 {count} 封邮件",
|
"items_selected": "已选择 {count} 封邮件",
|
||||||
"edit_draft": "编辑草稿"
|
"edit_draft": "编辑草稿",
|
||||||
|
"cancel_scheduled_send": "取消发送",
|
||||||
|
"reschedule_send": "重新计划",
|
||||||
|
"cancel_and_edit": "取消并编辑",
|
||||||
|
"cancel_and_compose_again": "取消并重新撰写"
|
||||||
},
|
},
|
||||||
"mailbox_context_menu": {
|
"mailbox_context_menu": {
|
||||||
"mark_folder_read": "将文件夹标记为已读",
|
"mark_folder_read": "将文件夹标记为已读",
|
||||||
@@ -1881,6 +1924,8 @@
|
|||||||
"expand_collapse": "展开/折叠会话"
|
"expand_collapse": "展开/折叠会话"
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
"send": "发送邮件",
|
||||||
|
"schedule_send": "定时发送",
|
||||||
"template_picker": "打开模板选择器"
|
"template_picker": "打开模板选择器"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
-12
@@ -5971,7 +5971,6 @@
|
|||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -7643,17 +7642,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next-intl/node_modules/@swc/helpers": {
|
|
||||||
"version": "0.5.19",
|
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
|
||||||
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
|
|||||||
+347
-27
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
|
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult } from "@/lib/jmap/types";
|
||||||
import type { UnifiedMailboxRole } from "@/lib/jmap/types";
|
import type { UnifiedMailboxRole } from "@/lib/jmap/types";
|
||||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
@@ -11,6 +11,17 @@ import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, adv
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
|
||||||
|
type ScheduledSubmissionMetadata = {
|
||||||
|
submissionId: string;
|
||||||
|
sendAt: string;
|
||||||
|
identityId: string;
|
||||||
|
undoStatus: 'pending' | 'final' | 'canceled';
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIRTUAL_SCHEDULED_MAILBOX_ID = '__scheduled__';
|
||||||
|
|
||||||
|
type PendingUndoSend = { submissionId: string; emailId?: string; sendAt: string; isSmime: boolean };
|
||||||
|
|
||||||
interface EmailStore {
|
interface EmailStore {
|
||||||
emails: Email[];
|
emails: Email[];
|
||||||
mailboxes: Mailbox[];
|
mailboxes: Mailbox[];
|
||||||
@@ -67,6 +78,17 @@ interface EmailStore {
|
|||||||
unifiedErrors: Map<string, string>; // accountId -> error message
|
unifiedErrors: Map<string, string>; // accountId -> error message
|
||||||
unifiedCounts: UnifiedMailboxCounts[];
|
unifiedCounts: UnifiedMailboxCounts[];
|
||||||
|
|
||||||
|
// Scheduled send state
|
||||||
|
scheduledEmails: ScheduledEmail[];
|
||||||
|
scheduledEmailIds: Set<string>;
|
||||||
|
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>;
|
||||||
|
scheduledTotal: number;
|
||||||
|
scheduledHasMore: boolean;
|
||||||
|
scheduledNextPosition: number;
|
||||||
|
isLoadingScheduled: boolean;
|
||||||
|
isScheduledView: boolean;
|
||||||
|
pendingUndoSend: PendingUndoSend | null;
|
||||||
|
|
||||||
setEmails: (emails: Email[]) => void;
|
setEmails: (emails: Email[]) => void;
|
||||||
setMailboxes: (mailboxes: Mailbox[]) => void;
|
setMailboxes: (mailboxes: Mailbox[]) => void;
|
||||||
/** Cache or update the mailbox list for a specific account. */
|
/** Cache or update the mailbox list for a specific account. */
|
||||||
@@ -111,8 +133,8 @@ interface EmailStore {
|
|||||||
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
|
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
|
||||||
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
||||||
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
||||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], envelopeMailFrom?: string) => Promise<void>;
|
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string) => Promise<SendEmailResult>;
|
||||||
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
|
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
|
||||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||||
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||||
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||||
@@ -185,6 +207,16 @@ interface EmailStore {
|
|||||||
refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<void>;
|
refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<void>;
|
||||||
exitUnifiedView: () => void;
|
exitUnifiedView: () => void;
|
||||||
|
|
||||||
|
fetchScheduledEmails: (client: IJMAPClient) => Promise<void>;
|
||||||
|
loadMoreScheduledEmails: (client: IJMAPClient) => Promise<void>;
|
||||||
|
cancelScheduledEmail: (client: IJMAPClient, submissionId: string, emailId?: string) => Promise<void>;
|
||||||
|
cancelScheduledEmailForEdit: (client: IJMAPClient, email: ScheduledEmail | Email) => Promise<Email | null>;
|
||||||
|
rescheduleScheduledEmail: (client: IJMAPClient, submissionId: string, emailId: string, identityId: string, delayedUntil: string) => Promise<SendEmailResult>;
|
||||||
|
cancelUndoSend: (client: IJMAPClient, pending: PendingUndoSend) => Promise<Email | null>;
|
||||||
|
clearPendingUndoSend: () => void;
|
||||||
|
refreshScheduledMetadata: (client: IJMAPClient) => Promise<void>;
|
||||||
|
setScheduledView: (isScheduledView: boolean) => void;
|
||||||
|
|
||||||
// Mock data for demo
|
// Mock data for demo
|
||||||
loadMockData: () => void;
|
loadMockData: () => void;
|
||||||
}
|
}
|
||||||
@@ -219,6 +251,38 @@ function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | n
|
|||||||
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
|
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function annotateScheduledEmails(
|
||||||
|
emails: Email[],
|
||||||
|
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
|
||||||
|
): Email[] {
|
||||||
|
if (scheduledSubmissionByEmailId.size === 0) return emails;
|
||||||
|
return emails.map(email => annotateScheduledEmail(email, scheduledSubmissionByEmailId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotateScheduledEmail(
|
||||||
|
email: Email,
|
||||||
|
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
|
||||||
|
): Email {
|
||||||
|
const scheduled = scheduledSubmissionByEmailId.get(email.id);
|
||||||
|
if (!scheduled) return email;
|
||||||
|
return {
|
||||||
|
...email,
|
||||||
|
scheduledSendAt: scheduled.sendAt,
|
||||||
|
emailSubmissionId: scheduled.submissionId,
|
||||||
|
scheduledIdentityId: scheduled.identityId,
|
||||||
|
scheduledUndoStatus: scheduled.undoStatus,
|
||||||
|
isScheduled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldClearPendingUndoSend(pending: PendingUndoSend | null, scheduledEmails: ScheduledEmail[]): boolean {
|
||||||
|
if (!pending) return false;
|
||||||
|
const pendingSendTime = new Date(pending.sendAt).getTime();
|
||||||
|
if (Number.isFinite(pendingSendTime) && pendingSendTime <= Date.now()) return true;
|
||||||
|
const scheduledEmail = scheduledEmails.find(email => email.emailSubmissionId === pending.submissionId);
|
||||||
|
return scheduledEmail?.scheduledUndoStatus !== undefined && scheduledEmail.scheduledUndoStatus !== 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When the mail view is showing a non-active account (Pro shell's
|
* When the mail view is showing a non-active account (Pro shell's
|
||||||
* Thunderbird-style sidebar), redirect read/write operations to that
|
* Thunderbird-style sidebar), redirect read/write operations to that
|
||||||
@@ -244,12 +308,6 @@ function resolveActionMailboxes(): Mailbox[] {
|
|||||||
return state.mailboxes;
|
return state.mailboxes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* After a mailbox-list mutation (create/rename/delete/etc.), refresh the
|
|
||||||
* cache for whichever account we're operating on. Writes the result to the
|
|
||||||
* standard `mailboxes` slot for the active account, or the per-account
|
|
||||||
* cache for non-active accounts so the Pro sidebar stays in sync.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Builds the `UnifiedAccountClient[]` list used by every unified fan-out
|
* Builds the `UnifiedAccountClient[]` list used by every unified fan-out
|
||||||
* action (browse, load-more, search). Each entry has a JMAP client plus a
|
* action (browse, load-more, search). Each entry has a JMAP client plus a
|
||||||
@@ -305,6 +363,12 @@ export async function buildUnifiedAccountClients(
|
|||||||
return built;
|
return built;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a mailbox-list mutation (create/rename/delete/etc.), refresh the
|
||||||
|
* cache for whichever account we're operating on. Writes the result to the
|
||||||
|
* standard `mailboxes` slot for the active account, or the per-account
|
||||||
|
* cache for non-active accounts so the Pro sidebar stays in sync.
|
||||||
|
*/
|
||||||
async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise<void> {
|
async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise<void> {
|
||||||
const viewingId = useEmailStore.getState().viewingAccountId;
|
const viewingId = useEmailStore.getState().viewingAccountId;
|
||||||
const client = resolveActionClient(fallbackClient);
|
const client = resolveActionClient(fallbackClient);
|
||||||
@@ -387,6 +451,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
unifiedErrors: new Map(),
|
unifiedErrors: new Map(),
|
||||||
unifiedCounts: [],
|
unifiedCounts: [],
|
||||||
|
|
||||||
|
// Scheduled send state
|
||||||
|
scheduledEmails: [],
|
||||||
|
scheduledEmailIds: new Set(),
|
||||||
|
scheduledSubmissionByEmailId: new Map(),
|
||||||
|
scheduledTotal: 0,
|
||||||
|
scheduledHasMore: false,
|
||||||
|
scheduledNextPosition: 0,
|
||||||
|
isLoadingScheduled: false,
|
||||||
|
isScheduledView: false,
|
||||||
|
pendingUndoSend: null,
|
||||||
|
|
||||||
// Spam undo cache
|
// Spam undo cache
|
||||||
spamUndoCache: new Map(),
|
spamUndoCache: new Map(),
|
||||||
|
|
||||||
@@ -516,7 +591,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Auto-select inbox if no mailbox is selected or the current selection
|
// Auto-select inbox if no mailbox is selected or the current selection
|
||||||
// doesn't exist in the fetched list (e.g. after an account switch)
|
// doesn't exist in the fetched list (e.g. after an account switch)
|
||||||
const currentSelectedMailbox = get().selectedMailbox;
|
const currentSelectedMailbox = get().selectedMailbox;
|
||||||
const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox);
|
const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID
|
||||||
|
|| (currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox));
|
||||||
const loadingPatch = isInitialLoad ? { isLoading: false } : {};
|
const loadingPatch = isInitialLoad ? { isLoading: false } : {};
|
||||||
if (!selectionValid) {
|
if (!selectionValid) {
|
||||||
// Find inbox from PRIMARY account (not shared accounts)
|
// Find inbox from PRIMARY account (not shared accounts)
|
||||||
@@ -568,6 +644,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
|
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
|
||||||
try {
|
try {
|
||||||
const targetMailboxId = mailboxId || get().selectedMailbox;
|
const targetMailboxId = mailboxId || get().selectedMailbox;
|
||||||
|
if (targetMailboxId === VIRTUAL_SCHEDULED_MAILBOX_ID) {
|
||||||
|
set({ isLoading: false, emails: [], hasMoreEmails: false, totalEmails: 0 });
|
||||||
|
await get().fetchScheduledEmails(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const effectiveClient = resolveActionClient(client);
|
const effectiveClient = resolveActionClient(client);
|
||||||
|
|
||||||
// Find the mailbox to get its accountId (for shared folder support)
|
// Find the mailbox to get its accountId (for shared folder support)
|
||||||
@@ -589,7 +670,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// all folders that carry the tag are returned.
|
// all folders that carry the tag are returned.
|
||||||
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
|
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
|
||||||
set({
|
set({
|
||||||
emails: result.emails,
|
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
@@ -657,6 +738,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
set({ isLoadingMore: true, error: null });
|
set({ isLoadingMore: true, error: null });
|
||||||
try {
|
try {
|
||||||
|
if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
|
||||||
|
set({ isLoadingMore: false });
|
||||||
|
await get().loadMoreScheduledEmails(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const effectiveClient = resolveActionClient(client);
|
const effectiveClient = resolveActionClient(client);
|
||||||
// Get emails per page from settings
|
// Get emails per page from settings
|
||||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||||
@@ -702,7 +789,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Deduplicate: the server may return overlapping results if new emails
|
// Deduplicate: the server may return overlapping results if new emails
|
||||||
// arrived between paginated requests and shifted positions.
|
// arrived between paginated requests and shifted positions.
|
||||||
const existingIds = new Set(currentEmails.map(e => e.id));
|
const existingIds = new Set(currentEmails.map(e => e.id));
|
||||||
const newEmails = result.emails.filter((e: Email) => !existingIds.has(e.id));
|
const newEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId).filter((e: Email) => !existingIds.has(e.id));
|
||||||
|
|
||||||
set({
|
set({
|
||||||
emails: [...currentEmails, ...newEmails],
|
emails: [...currentEmails, ...newEmails],
|
||||||
@@ -732,7 +819,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
const email = await resolveActionClient(client).getEmail(emailId, accountId);
|
const email = await resolveActionClient(client).getEmail(emailId, accountId);
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
set({ selectedEmail: email });
|
const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId);
|
||||||
|
set({ selectedEmail: annotatedEmail });
|
||||||
|
return annotatedEmail;
|
||||||
}
|
}
|
||||||
return email;
|
return email;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -752,12 +841,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, envelopeMailFrom) => {
|
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, envelopeMailFrom);
|
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom);
|
||||||
// Refresh handled by UI layer for immediate feedback
|
// Refresh handled by UI layer for immediate feedback
|
||||||
set({ isLoading: false });
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
|
||||||
|
? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: false }
|
||||||
|
: get().pendingUndoSend,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : "Failed to send email",
|
error: error instanceof Error ? error.message : "Failed to send email",
|
||||||
@@ -767,15 +862,21 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sendRawEmail: async (client, rawMimeBlob, identityId) => {
|
sendRawEmail: async (client, rawMimeBlob, identityId, delayedUntil, envelopeRecipients) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const mailboxes = await client.getMailboxes();
|
const mailboxes = await client.getMailboxes();
|
||||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
if (!sentMailbox) throw new Error('No sent mailbox found');
|
if (!sentMailbox) throw new Error('No sent mailbox found');
|
||||||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||||||
await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id);
|
const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil, envelopeRecipients);
|
||||||
set({ isLoading: false });
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
|
||||||
|
? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: true }
|
||||||
|
: get().pendingUndoSend,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : "Failed to send email",
|
error: error instanceof Error ? error.message : "Failed to send email",
|
||||||
@@ -1325,7 +1426,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
|
const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
|
||||||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
|
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
|
||||||
set({
|
set({
|
||||||
emails: result.emails,
|
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||||||
externalSearchResults: externals,
|
externalSearchResults: externals,
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
@@ -1400,7 +1501,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
|
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
|
||||||
|
|
||||||
set({
|
set({
|
||||||
emails: result.emails,
|
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||||||
externalSearchResults: externals,
|
externalSearchResults: externals,
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
@@ -1930,6 +2031,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
get().fetchTagCounts(client);
|
get().fetchTagCounts(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accountChanges.EmailSubmission) {
|
||||||
|
await get().refreshScheduledMetadata(client);
|
||||||
|
if (get().isScheduledView) {
|
||||||
|
await get().fetchScheduledEmails(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle Mailbox state changes - refresh mailbox list
|
// Handle Mailbox state changes - refresh mailbox list
|
||||||
if (accountChanges.Mailbox) {
|
if (accountChanges.Mailbox) {
|
||||||
await get().fetchMailboxes(client);
|
await get().fetchMailboxes(client);
|
||||||
@@ -1977,6 +2085,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Only refresh if a mailbox is currently selected
|
// Only refresh if a mailbox is currently selected
|
||||||
if (!selectedMailbox) return;
|
if (!selectedMailbox) return;
|
||||||
|
|
||||||
|
if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
|
||||||
|
await get().fetchScheduledEmails(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch emails for the current mailbox without clearing the list first
|
// Fetch emails for the current mailbox without clearing the list first
|
||||||
// This provides a smoother update experience
|
// This provides a smoother update experience
|
||||||
@@ -2003,6 +2116,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentEmails = get().emails;
|
const currentEmails = get().emails;
|
||||||
|
const previousTotal = get().totalEmails;
|
||||||
|
|
||||||
// Only notify for genuinely new incoming mail in the Inbox.
|
// Only notify for genuinely new incoming mail in the Inbox.
|
||||||
// Without these guards the toast/sound also fires when sending,
|
// Without these guards the toast/sound also fires when sending,
|
||||||
@@ -2020,15 +2134,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Merge the refreshed first page with the existing loaded emails.
|
// Merge the refreshed first page with the existing loaded emails.
|
||||||
// This avoids discarding already-loaded pages which would cause the
|
// This avoids discarding already-loaded pages which would cause the
|
||||||
// virtual list to shrink and then rapidly re-load (scroll bounce).
|
// virtual list to shrink and then rapidly re-load (scroll bounce).
|
||||||
const freshMap = new Map(result.emails.map((e: Email) => [e.id, e]));
|
const refreshedEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId);
|
||||||
|
|
||||||
// Build the merged list: start with the fresh first page, then append
|
// Build the merged list: start with the fresh first page, then append
|
||||||
// existing emails beyond that page (if any), skipping duplicates and
|
// existing emails beyond that page (if any), skipping duplicates. Do not
|
||||||
// emails removed from the first page (e.g. deleted or moved).
|
// append the whole previous list: drafts are saved as destroy+create, so
|
||||||
const merged: Email[] = [...result.emails];
|
// the old draft can disappear from the refreshed first page and must not
|
||||||
const mergedIds = new Set(result.emails.map((e: Email) => e.id));
|
// be reintroduced from stale local state.
|
||||||
|
const merged: Email[] = [...refreshedEmails];
|
||||||
|
const mergedIds = new Set(refreshedEmails.map((e: Email) => e.id));
|
||||||
|
const insertedCount = Math.max((result.total || 0) - previousTotal, 0);
|
||||||
|
const appendFromIndex = Math.max(refreshedEmails.length - insertedCount, 0);
|
||||||
|
|
||||||
for (const email of currentEmails) {
|
for (const email of currentEmails.slice(appendFromIndex)) {
|
||||||
if (!mergedIds.has(email.id)) {
|
if (!mergedIds.has(email.id)) {
|
||||||
merged.push(email);
|
merged.push(email);
|
||||||
mergedIds.add(email.id);
|
mergedIds.add(email.id);
|
||||||
@@ -2384,6 +2502,208 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setScheduledView: (isScheduledView) => set(state => {
|
||||||
|
const leavingScheduled = !isScheduledView && state.selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID;
|
||||||
|
return {
|
||||||
|
isScheduledView,
|
||||||
|
selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox,
|
||||||
|
selectedEmail: leavingScheduled ? null : state.selectedEmail,
|
||||||
|
selectedEmailIds: leavingScheduled ? new Set<string>() : state.selectedEmailIds,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
clearPendingUndoSend: () => set({ pendingUndoSend: null }),
|
||||||
|
|
||||||
|
fetchScheduledEmails: async (client) => {
|
||||||
|
set({ isLoadingScheduled: true, error: null });
|
||||||
|
try {
|
||||||
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||||
|
const result = await client.getScheduledEmails(emailsPerPage, 0);
|
||||||
|
const scheduledEmailIds = new Set(result.emails.map(email => email.id));
|
||||||
|
const scheduledSubmissionByEmailId = new Map(result.emails.map(email => [email.id, {
|
||||||
|
submissionId: email.emailSubmissionId,
|
||||||
|
sendAt: email.scheduledSendAt,
|
||||||
|
identityId: email.scheduledIdentityId,
|
||||||
|
undoStatus: email.scheduledUndoStatus,
|
||||||
|
}]));
|
||||||
|
const pendingUndoSend = get().pendingUndoSend;
|
||||||
|
set({
|
||||||
|
scheduledEmails: result.emails,
|
||||||
|
scheduledEmailIds,
|
||||||
|
scheduledSubmissionByEmailId,
|
||||||
|
scheduledTotal: result.total,
|
||||||
|
scheduledHasMore: result.hasMore,
|
||||||
|
scheduledNextPosition: result.nextPosition,
|
||||||
|
isLoadingScheduled: false,
|
||||||
|
pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, result.emails) ? null : pendingUndoSend,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch scheduled emails:', error);
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch scheduled emails',
|
||||||
|
scheduledEmails: [],
|
||||||
|
scheduledEmailIds: new Set(),
|
||||||
|
scheduledSubmissionByEmailId: new Map(),
|
||||||
|
scheduledTotal: 0,
|
||||||
|
scheduledHasMore: false,
|
||||||
|
scheduledNextPosition: 0,
|
||||||
|
isLoadingScheduled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMoreScheduledEmails: async (client) => {
|
||||||
|
const { isLoadingScheduled, scheduledHasMore, scheduledEmails, scheduledNextPosition } = get();
|
||||||
|
if (isLoadingScheduled || !scheduledHasMore) return;
|
||||||
|
set({ isLoadingScheduled: true, error: null });
|
||||||
|
try {
|
||||||
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||||
|
const result = await client.getScheduledEmails(emailsPerPage, scheduledNextPosition);
|
||||||
|
const merged = [...scheduledEmails, ...result.emails.filter(email => !scheduledEmails.some(existing => existing.id === email.id))];
|
||||||
|
const pendingUndoSend = get().pendingUndoSend;
|
||||||
|
set({
|
||||||
|
scheduledEmails: merged,
|
||||||
|
scheduledEmailIds: new Set(merged.map(email => email.id)),
|
||||||
|
scheduledSubmissionByEmailId: new Map(merged.map(email => [email.id, {
|
||||||
|
submissionId: email.emailSubmissionId,
|
||||||
|
sendAt: email.scheduledSendAt,
|
||||||
|
identityId: email.scheduledIdentityId,
|
||||||
|
undoStatus: email.scheduledUndoStatus,
|
||||||
|
}])),
|
||||||
|
scheduledTotal: result.total,
|
||||||
|
scheduledHasMore: result.hasMore,
|
||||||
|
scheduledNextPosition: result.nextPosition,
|
||||||
|
isLoadingScheduled: false,
|
||||||
|
pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, merged) ? null : pendingUndoSend,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: error instanceof Error ? error.message : 'Failed to load scheduled emails', isLoadingScheduled: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshScheduledMetadata: async (client) => {
|
||||||
|
try {
|
||||||
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||||
|
const allEmails: ScheduledEmail[] = [];
|
||||||
|
let position = 0;
|
||||||
|
let hasMore = true;
|
||||||
|
let total = 0;
|
||||||
|
while (hasMore) {
|
||||||
|
const page = await client.getScheduledEmails(emailsPerPage, position);
|
||||||
|
allEmails.push(...page.emails.filter(email => !allEmails.some(existing => existing.id === email.id)));
|
||||||
|
total = page.total;
|
||||||
|
hasMore = page.hasMore && page.nextPosition > position;
|
||||||
|
position = page.nextPosition;
|
||||||
|
}
|
||||||
|
const pendingUndoSend = get().pendingUndoSend;
|
||||||
|
set({
|
||||||
|
scheduledEmails: get().isScheduledView ? allEmails : get().scheduledEmails,
|
||||||
|
scheduledEmailIds: new Set(allEmails.map(email => email.id)),
|
||||||
|
scheduledSubmissionByEmailId: new Map(allEmails.map(email => [email.id, {
|
||||||
|
submissionId: email.emailSubmissionId,
|
||||||
|
sendAt: email.scheduledSendAt,
|
||||||
|
identityId: email.scheduledIdentityId,
|
||||||
|
undoStatus: email.scheduledUndoStatus,
|
||||||
|
}])),
|
||||||
|
scheduledTotal: total,
|
||||||
|
scheduledHasMore: false,
|
||||||
|
scheduledNextPosition: position,
|
||||||
|
pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, allEmails) ? null : pendingUndoSend,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh scheduled metadata:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelScheduledEmail: async (client, submissionId, emailId) => {
|
||||||
|
await client.cancelEmailSubmission(submissionId);
|
||||||
|
if (emailId) {
|
||||||
|
await client.deleteEmail(emailId);
|
||||||
|
set(state => ({
|
||||||
|
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
|
||||||
|
selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== emailId)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (get().pendingUndoSend?.submissionId === submissionId) {
|
||||||
|
set({ pendingUndoSend: null });
|
||||||
|
}
|
||||||
|
await get().fetchScheduledEmails(client);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelScheduledEmailForEdit: async (client, email) => {
|
||||||
|
const submissionId = email.emailSubmissionId;
|
||||||
|
if (!submissionId) return null;
|
||||||
|
await client.cancelEmailSubmission(submissionId);
|
||||||
|
if (get().pendingUndoSend?.submissionId === submissionId) {
|
||||||
|
set({ pendingUndoSend: null });
|
||||||
|
}
|
||||||
|
if (email.isSmimeScheduled) {
|
||||||
|
await client.deleteEmail(email.id);
|
||||||
|
set(state => ({
|
||||||
|
selectedEmail: state.selectedEmail?.id === email.id ? null : state.selectedEmail,
|
||||||
|
selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== email.id)),
|
||||||
|
}));
|
||||||
|
await get().fetchScheduledEmails(client);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes();
|
||||||
|
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||||||
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
|
if (draftsMailbox) {
|
||||||
|
await client.restoreEmailToDraft(email.id, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id);
|
||||||
|
}
|
||||||
|
await get().fetchScheduledEmails(client);
|
||||||
|
const restored = await client.getEmail(email.id);
|
||||||
|
return restored;
|
||||||
|
},
|
||||||
|
|
||||||
|
rescheduleScheduledEmail: async (client, submissionId, emailId, identityId, delayedUntil) => {
|
||||||
|
let result: SendEmailResult | undefined;
|
||||||
|
try {
|
||||||
|
result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, delayedUntil);
|
||||||
|
const pendingUndoSend = get().pendingUndoSend;
|
||||||
|
if (pendingUndoSend?.submissionId === submissionId) {
|
||||||
|
set({ pendingUndoSend: { ...pendingUndoSend, submissionId: result.emailSubmissionId || submissionId, sendAt: result.sendAt || delayedUntil } });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
await get().fetchScheduledEmails(client);
|
||||||
|
if (result && get().selectedEmail?.id === emailId) {
|
||||||
|
const refreshed = get().scheduledEmails.find(email => email.id === emailId);
|
||||||
|
set(state => ({
|
||||||
|
selectedEmail: refreshed || (state.selectedEmail ? {
|
||||||
|
...state.selectedEmail,
|
||||||
|
emailSubmissionId: result?.emailSubmissionId || submissionId,
|
||||||
|
scheduledSendAt: result?.sendAt || delayedUntil,
|
||||||
|
scheduledIdentityId: identityId,
|
||||||
|
scheduledUndoStatus: 'pending' as const,
|
||||||
|
isScheduled: true,
|
||||||
|
} : state.selectedEmail),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelUndoSend: async (client, pending) => {
|
||||||
|
await client.cancelEmailSubmission(pending.submissionId);
|
||||||
|
if (pending.emailId && pending.isSmime) {
|
||||||
|
await client.deleteEmail(pending.emailId);
|
||||||
|
set(state => ({
|
||||||
|
selectedEmail: state.selectedEmail?.id === pending.emailId ? null : state.selectedEmail,
|
||||||
|
selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== pending.emailId)),
|
||||||
|
}));
|
||||||
|
} else if (pending.emailId) {
|
||||||
|
const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes();
|
||||||
|
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||||||
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
|
if (draftsMailbox) {
|
||||||
|
await client.restoreEmailToDraft(pending.emailId, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await get().refreshScheduledMetadata(client);
|
||||||
|
set({ pendingUndoSend: null });
|
||||||
|
return pending.emailId && !pending.isSmime ? client.getEmail(pending.emailId) : null;
|
||||||
|
},
|
||||||
|
|
||||||
loadMockData: () => {
|
loadMockData: () => {
|
||||||
const mockEmails: Email[] = [
|
const mockEmails: Email[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type ToolbarPosition = 'top' | 'below-subject';
|
|||||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||||
export type MailLayout = 'split' | 'focus' | 'horizontal';
|
export type MailLayout = 'split' | 'focus' | 'horizontal';
|
||||||
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
|
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
|
||||||
|
export type SendDelaySeconds = 0 | 10 | 30 | 60;
|
||||||
export type ProtocolOpenMode = 'active-session' | 'new-tab';
|
export type ProtocolOpenMode = 'active-session' | 'new-tab';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,6 +154,7 @@ interface SettingsState {
|
|||||||
autoSelectReplyIdentity: boolean;
|
autoSelectReplyIdentity: boolean;
|
||||||
plainTextMode: boolean; // Send plain text only (no rich text editor)
|
plainTextMode: boolean; // Send plain text only (no rich text editor)
|
||||||
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
|
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
|
||||||
|
sendDelaySeconds: SendDelaySeconds;
|
||||||
signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards
|
signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards
|
||||||
signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter
|
signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter
|
||||||
|
|
||||||
@@ -327,6 +329,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
autoSelectReplyIdentity: false,
|
autoSelectReplyIdentity: false,
|
||||||
plainTextMode: false,
|
plainTextMode: false,
|
||||||
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
|
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||||
|
sendDelaySeconds: 0 as SendDelaySeconds,
|
||||||
signaturePosition: 'below_quote' as SignaturePosition,
|
signaturePosition: 'below_quote' as SignaturePosition,
|
||||||
signatureSeparatorEnabled: true,
|
signatureSeparatorEnabled: true,
|
||||||
|
|
||||||
@@ -520,6 +523,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
|
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
|
||||||
plainTextMode: state.plainTextMode,
|
plainTextMode: state.plainTextMode,
|
||||||
subAddressDelimiter: state.subAddressDelimiter,
|
subAddressDelimiter: state.subAddressDelimiter,
|
||||||
|
sendDelaySeconds: state.sendDelaySeconds,
|
||||||
signaturePosition: state.signaturePosition,
|
signaturePosition: state.signaturePosition,
|
||||||
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
|
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
|
||||||
sessionTimeout: state.sessionTimeout,
|
sessionTimeout: state.sessionTimeout,
|
||||||
@@ -589,6 +593,10 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) {
|
if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(settings[key])) {
|
||||||
|
set({ sendDelaySeconds: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
|
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -767,6 +775,9 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
state.density = state.listDensity;
|
state.density = state.listDensity;
|
||||||
delete state.listDensity;
|
delete state.listDensity;
|
||||||
}
|
}
|
||||||
|
if (![0, 10, 30, 60].includes(state.sendDelaySeconds as number)) {
|
||||||
|
state.sendDelaySeconds = 0;
|
||||||
|
}
|
||||||
if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') {
|
if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') {
|
||||||
state.protocolOpenMode = state.protocolMailtoOpenMode;
|
state.protocolOpenMode = state.protocolMailtoOpenMode;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user