Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0c7e991ff | ||
|
|
82be047708 | ||
|
|
7d1fb73290 | ||
|
|
2818a16f06 | ||
|
|
e93dd44111 | ||
|
|
d1b2206aa0 | ||
|
|
0137a1a593 | ||
|
|
e86183b44a | ||
|
|
ad80aa23ca | ||
|
|
3a8daf8bff | ||
|
|
f8e7cce85a | ||
|
|
62ebe443f9 | ||
|
|
3c0faba837 | ||
|
|
956acb69ce | ||
|
|
e2abc8dee9 | ||
|
|
42ec34be21 | ||
|
|
534894c38d | ||
|
|
537707d9ed | ||
|
|
afe1e5a67c | ||
|
|
d13934c2a6 | ||
|
|
c4aa0c1772 | ||
|
|
acc90eb455 | ||
|
|
9a302e5183 | ||
|
|
401870c9bb | ||
|
|
91cd087243 | ||
|
|
d04a8e578b | ||
|
|
5aa6d7a2f0 | ||
|
|
c46de636e0 | ||
|
|
8de8babba5 | ||
|
|
63f2169ae7 | ||
|
|
e843ef0ebb | ||
|
|
4b463f9691 | ||
|
|
58e4a3d117 | ||
|
|
e3f6ae874d | ||
|
|
52f5a5b42c | ||
|
|
e7bded82fb | ||
|
|
3ac14ecf38 | ||
|
|
0dca019fe5 | ||
|
|
e6d939ba43 | ||
|
|
ca0d6805cf | ||
|
|
8bcb487442 | ||
|
|
0245ec67e1 | ||
|
|
8810a63262 | ||
|
|
d3778e6521 | ||
|
|
5c1d59f38a | ||
|
|
159706a683 | ||
|
|
1fc6185002 | ||
|
|
5928c6f73e | ||
|
|
7dccc2f432 | ||
|
|
4269d0589c | ||
|
|
2b1b06abd6 | ||
|
|
ac4a89120d | ||
|
|
704a259432 | ||
|
|
1c02970ae1 | ||
|
|
66b2036e37 | ||
|
|
bd2ffab3bc | ||
|
|
7142627cec | ||
|
|
dd322d4c9d | ||
|
|
7e15782fb3 | ||
|
|
1e8bd40b93 | ||
|
|
3dc1b4ceee | ||
|
|
1a3d359fee | ||
|
|
891c3250be | ||
|
|
4a3c775b40 | ||
|
|
f45b67fe19 | ||
|
|
c458091698 | ||
|
|
37cd5ca635 | ||
|
|
154ae84247 | ||
|
|
16f719066f | ||
|
|
17577e222c | ||
|
|
48821338c3 |
@@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# OpenID Connect issuer URL for discovery
|
||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||
|
||||
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
|
||||
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
|
||||
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
||||
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
|
||||
|
||||
# =============================================================================
|
||||
# Session & Security
|
||||
# =============================================================================
|
||||
@@ -229,6 +234,22 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
# your own directory (e.g. http://localhost:3001 for local development).
|
||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
|
||||
# =============================================================================
|
||||
# Internationalization
|
||||
# =============================================================================
|
||||
# These are build-time variables - to change them with the published Docker
|
||||
# image, rebuild it with --build-arg (see README "Default UI locale").
|
||||
#
|
||||
# Fallback UI locale used when the visitor's Accept-Language header does not
|
||||
# match any supported locale. Defaults to "en".
|
||||
# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh
|
||||
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
|
||||
|
||||
# Locale prefix mode for URLs. Recommended "always" when proxying under a
|
||||
# subpath (NEXT_PUBLIC_BASE_PATH) to avoid next-intl rewrite loops.
|
||||
# Values: never (default) | always | as-needed
|
||||
# NEXT_PUBLIC_LOCALE_PREFIX=always
|
||||
|
||||
# =============================================================================
|
||||
# Legacy Build-time Variables (still supported as fallback)
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.1 (2026-05-22)
|
||||
|
||||
### Features
|
||||
|
||||
- **Admin**: Expose PWA branding fields in the admin Branding tab
|
||||
- **Pro**: Hide empty-state placeholder and collapse the viewer pane in Pro mode so the mail list fills the space
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Preserve inline images when replying (#163)
|
||||
- **Filters**: Use the canonical `INBOX` mailbox in Sieve filter paths (#313)
|
||||
- **Mail**: Resolve destination account id to the local namespace on cross-account mailbox drop
|
||||
|
||||
## 1.7.0 (2026-05-21)
|
||||
|
||||
> **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced.
|
||||
@@ -21,6 +34,7 @@
|
||||
- **Pro**: Multi-account contacts and a cross-account file picker
|
||||
- **Pro**: Composer From dropdown grouped by account
|
||||
- **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load
|
||||
- **Plugins**: Marketplace update flow for installed plugins and themes
|
||||
- **Setup**: Allow the setup wizard over plain HTTP with a dismissable warning gate
|
||||
- **Setup**: Warn when the JMAP URL points at a local-only host
|
||||
- **Account**: List and reorder logged-in accounts from settings (#282)
|
||||
@@ -62,6 +76,7 @@
|
||||
- **Plugins**: Load `globals.css` and Geist font in the plugin sandbox iframe
|
||||
- **Plugins**: Sync plugin slot iframe height with reported content height
|
||||
- **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore`
|
||||
- **Plugins**: Trust the directory version on marketplace install and update
|
||||
- **Filters**: Prevent duplication of Bulwark rules with literal braces in values
|
||||
- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch
|
||||
- **Routing**: Anchor unmatched URLs into `main` so 404 renders
|
||||
|
||||
@@ -8,6 +8,11 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# at build time, so it cannot be changed without rebuilding.
|
||||
ARG NEXT_PUBLIC_BASE_PATH=
|
||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
|
||||
# Accept-Language header does not match any supported locale. Baked in at
|
||||
# build time because next-intl wires it into client-side routing too.
|
||||
ARG NEXT_PUBLIC_DEFAULT_LOCALE=
|
||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
|
||||
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||
ARG GIT_COMMIT=unknown
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -96,7 +96,7 @@ function OAuthCallbackInner() {
|
||||
// the refresh-token cookie write for the same reason.
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/sso/complete", {
|
||||
const res = await apiFetch("/api/auth/sso/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
@@ -722,7 +722,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-12 pb-4 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
alt={appName}
|
||||
className="max-w-20 max-h-20 object-contain"
|
||||
/>
|
||||
@@ -872,7 +872,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-10 pb-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
alt={appName}
|
||||
className="max-w-16 max-h-16 object-contain"
|
||||
/>
|
||||
|
||||
+385
-133
@@ -15,7 +15,7 @@ import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/li
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -57,6 +57,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
@@ -74,6 +75,8 @@ import { buildQuoteHeader } from "@/lib/quote-header";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||
|
||||
const SCHEDULED_MAILBOX_ID = '__scheduled__';
|
||||
|
||||
|
||||
export default function Home() {
|
||||
const t = useTranslations();
|
||||
@@ -107,12 +110,39 @@ export default function Home() {
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastUndoToastSubmissionRef = useRef<string | null>(null);
|
||||
const initialMailLoadClientRef = useRef<object | null>(null);
|
||||
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||
const { identities } = useIdentityStore();
|
||||
useIdentitySync();
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
|
||||
|
||||
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
|
||||
const value = window.prompt(t('email_viewer.reschedule_prompt'));
|
||||
if (!value) return null;
|
||||
const time = new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) {
|
||||
toast.error(t('email_composer.schedule_send_invalid'));
|
||||
return null;
|
||||
}
|
||||
if (time <= Date.now()) {
|
||||
toast.error(t('email_composer.schedule_send_future'));
|
||||
return null;
|
||||
}
|
||||
if (!client?.hasDelayedSend()) {
|
||||
toast.error(t('email_composer.schedule_send_unsupported'));
|
||||
return null;
|
||||
}
|
||||
const maxDelayedSend = client.getMaxDelayedSend();
|
||||
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
|
||||
toast.error(t('email_composer.schedule_send_too_late'));
|
||||
return null;
|
||||
}
|
||||
return new Date(time).toISOString();
|
||||
}, [client, t]);
|
||||
|
||||
// Load trusted senders address book when feature is enabled
|
||||
useEffect(() => {
|
||||
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
|
||||
@@ -269,6 +299,21 @@ export default function Home() {
|
||||
fetchTagCounts,
|
||||
fetchEmailContent,
|
||||
isUnifiedView,
|
||||
scheduledEmails,
|
||||
scheduledTotal,
|
||||
scheduledHasMore,
|
||||
isLoadingScheduled,
|
||||
isScheduledView,
|
||||
setScheduledView,
|
||||
fetchScheduledEmails,
|
||||
loadMoreScheduledEmails,
|
||||
cancelScheduledEmail,
|
||||
cancelScheduledEmailForEdit,
|
||||
rescheduleScheduledEmail,
|
||||
refreshScheduledMetadata,
|
||||
cancelUndoSend,
|
||||
clearPendingUndoSend,
|
||||
pendingUndoSend,
|
||||
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||
refreshUnifiedCounts,
|
||||
exitUnifiedView,
|
||||
@@ -293,47 +338,26 @@ export default function Home() {
|
||||
useProMultiAccountMailboxes();
|
||||
|
||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||
const delayedSendSupported = client?.hasDelayedSend() ?? true;
|
||||
const activeEmails = isScheduledView ? scheduledEmails : emails;
|
||||
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
|
||||
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
|
||||
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const connectedAccountsSignature = useMemo(
|
||||
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
|
||||
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
|
||||
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||
const result: UnifiedAccountClient[] = [];
|
||||
for (const account of connected) {
|
||||
const accountClient = clients.get(account.id);
|
||||
if (!accountClient) continue;
|
||||
result.push({
|
||||
accountId: account.id,
|
||||
accountLabel: account.label || account.email,
|
||||
client: accountClient,
|
||||
mailboxes: [],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
// Builds the populated UnifiedAccountClient[] used by the unified-view
|
||||
// effects and one-shot actions in this page. Reads the includeGroup
|
||||
// setting at call time so the latest toggle value is always honored.
|
||||
const buildPopulatedUnifiedAccounts = useCallback(async (): Promise<UnifiedAccountClient[]> => {
|
||||
return buildUnifiedAccountClients({
|
||||
includeGroup: useSettingsStore.getState().includeGroupInUnified,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const populateUnifiedAccountMailboxes = useCallback(
|
||||
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
|
||||
const populated = await Promise.all(
|
||||
list.map(async (entry) => {
|
||||
try {
|
||||
const mailboxes = await entry.client.getMailboxes();
|
||||
return { ...entry, mailboxes };
|
||||
} catch (err) {
|
||||
debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
|
||||
return entry;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return populated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getMailtoProtocolAccounts = useCallback(() => {
|
||||
const connectedClients = useAuthStore.getState().getAllConnectedClients();
|
||||
return useAccountStore.getState().accounts.filter((account) =>
|
||||
@@ -353,7 +377,7 @@ export default function Home() {
|
||||
conversationThreadId: null as string | null,
|
||||
});
|
||||
navRestoreStateRef.current.client = client;
|
||||
navRestoreStateRef.current.emails = emails;
|
||||
navRestoreStateRef.current.emails = activeEmails;
|
||||
navRestoreStateRef.current.mailboxes = mailboxes;
|
||||
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
|
||||
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
|
||||
@@ -379,7 +403,24 @@ export default function Home() {
|
||||
|
||||
// Restore mailbox selection. selectMailbox clears the current email,
|
||||
// which is fine because we re-apply the saved email below.
|
||||
if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
|
||||
if (state.mailboxId === SCHEDULED_MAILBOX_ID) {
|
||||
if (!ctx.client?.hasDelayedSend()) {
|
||||
setScheduledView(false);
|
||||
selectEmail(null);
|
||||
return;
|
||||
}
|
||||
setScheduledView(true);
|
||||
selectMailbox(SCHEDULED_MAILBOX_ID);
|
||||
selectEmail(null);
|
||||
if (ctx.client) {
|
||||
try {
|
||||
await fetchScheduledEmails(ctx.client);
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch scheduled emails on history restore:', error);
|
||||
}
|
||||
}
|
||||
} else if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
|
||||
setScheduledView(false);
|
||||
selectMailbox(state.mailboxId);
|
||||
if (ctx.client) {
|
||||
try {
|
||||
@@ -440,19 +481,19 @@ export default function Home() {
|
||||
// Keyboard shortcuts handlers
|
||||
const keyboardHandlers = useMemo(() => ({
|
||||
onNextEmail: () => {
|
||||
if (emails.length === 0) return;
|
||||
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||
const nextIndex = currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
|
||||
if (nextIndex >= 0 && nextIndex < emails.length) {
|
||||
handleEmailSelect(emails[nextIndex]);
|
||||
if (activeEmails.length === 0) return;
|
||||
const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||
const nextIndex = currentIndex < activeEmails.length - 1 ? currentIndex + 1 : currentIndex;
|
||||
if (nextIndex >= 0 && nextIndex < activeEmails.length) {
|
||||
handleEmailSelect(activeEmails[nextIndex]);
|
||||
}
|
||||
},
|
||||
onPreviousEmail: () => {
|
||||
if (emails.length === 0) return;
|
||||
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : emails.length;
|
||||
if (activeEmails.length === 0) return;
|
||||
const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : activeEmails.length;
|
||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
|
||||
if (prevIndex >= 0 && prevIndex < emails.length) {
|
||||
handleEmailSelect(emails[prevIndex]);
|
||||
if (prevIndex >= 0 && prevIndex < activeEmails.length) {
|
||||
handleEmailSelect(activeEmails[prevIndex]);
|
||||
}
|
||||
},
|
||||
onOpenEmail: () => {
|
||||
@@ -468,18 +509,23 @@ export default function Home() {
|
||||
}
|
||||
},
|
||||
onReply: () => {
|
||||
if (isScheduledView) return;
|
||||
if (selectedEmail) handleReply();
|
||||
},
|
||||
onReplyAll: () => {
|
||||
if (isScheduledView) return;
|
||||
if (selectedEmail) handleReplyAll();
|
||||
},
|
||||
onForward: () => {
|
||||
if (isScheduledView) return;
|
||||
if (selectedEmail) handleForward();
|
||||
},
|
||||
onToggleStar: () => {
|
||||
if (isScheduledView) return;
|
||||
if (selectedEmail) handleToggleStar();
|
||||
},
|
||||
onArchive: async () => {
|
||||
if (isScheduledView) return;
|
||||
if (selectedEmailIds.size > 0 && client) {
|
||||
try {
|
||||
await batchArchive(client);
|
||||
@@ -491,6 +537,7 @@ export default function Home() {
|
||||
}
|
||||
},
|
||||
onDelete: async () => {
|
||||
if (isScheduledView) return;
|
||||
if (selectedEmailIds.size > 0 && client) {
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
const isInTrash = currentMailbox?.role === 'trash';
|
||||
@@ -520,6 +567,7 @@ export default function Home() {
|
||||
}
|
||||
},
|
||||
onMarkAsUnread: async () => {
|
||||
if (isScheduledView) return;
|
||||
if (!client) return;
|
||||
if (selectedEmailIds.size > 0) {
|
||||
await batchMarkAsRead(client, false);
|
||||
@@ -528,6 +576,7 @@ export default function Home() {
|
||||
}
|
||||
},
|
||||
onMarkAsRead: async () => {
|
||||
if (isScheduledView) return;
|
||||
if (!client) return;
|
||||
if (selectedEmailIds.size > 0) {
|
||||
await batchMarkAsRead(client, true);
|
||||
@@ -536,6 +585,7 @@ export default function Home() {
|
||||
}
|
||||
},
|
||||
onToggleSpam: async () => {
|
||||
if (isScheduledView) return;
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
const isInJunk = currentMailbox?.role === 'junk';
|
||||
if (selectedEmailIds.size > 0 && client) {
|
||||
@@ -563,6 +613,7 @@ export default function Home() {
|
||||
if (isMobile) setActiveView('viewer');
|
||||
},
|
||||
onFocusSearch: () => {
|
||||
if (isScheduledView) return;
|
||||
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
@@ -574,22 +625,27 @@ export default function Home() {
|
||||
},
|
||||
onRefresh: async () => {
|
||||
if (client && selectedMailbox) {
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
if (selectedMailbox === SCHEDULED_MAILBOX_ID) {
|
||||
await fetchScheduledEmails(client);
|
||||
} else {
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectAll: () => {
|
||||
if (isScheduledView) return;
|
||||
selectAllEmails();
|
||||
},
|
||||
onDeselectAll: () => {
|
||||
clearSelection();
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]);
|
||||
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes, isScheduledView]);
|
||||
|
||||
// Initialize keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
enabled: isAuthenticated && !showComposer,
|
||||
emails,
|
||||
emails: activeEmails,
|
||||
selectedEmailId: selectedEmail?.id,
|
||||
selectionCount: selectedEmailIds.size,
|
||||
handlers: keyboardHandlers,
|
||||
@@ -602,13 +658,36 @@ export default function Home() {
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
const state = useEmailStore.getState();
|
||||
await Promise.all([
|
||||
state.fetchMailboxes(client),
|
||||
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client),
|
||||
]);
|
||||
if (state.isScheduledView || state.selectedMailbox === SCHEDULED_MAILBOX_ID) {
|
||||
await Promise.all([
|
||||
state.fetchMailboxes(client),
|
||||
state.fetchScheduledEmails(client),
|
||||
]);
|
||||
} else {
|
||||
await state.fetchMailboxes(client);
|
||||
await state.refreshScheduledMetadata(client);
|
||||
await (state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!delayedSendSupported && isScheduledView) {
|
||||
setScheduledView(false);
|
||||
}
|
||||
}, [delayedSendSupported, isScheduledView, setScheduledView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUndoSend) return;
|
||||
const pendingSendTime = new Date(pendingUndoSend.sendAt).getTime();
|
||||
if (!Number.isFinite(pendingSendTime) || pendingSendTime <= Date.now()) {
|
||||
clearPendingUndoSend();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(clearPendingUndoSend, pendingSendTime - Date.now());
|
||||
return () => clearTimeout(timer);
|
||||
}, [clearPendingUndoSend, pendingUndoSend]);
|
||||
|
||||
// Update page title based on context
|
||||
useEffect(() => {
|
||||
let title = appName;
|
||||
@@ -812,50 +891,61 @@ export default function Home() {
|
||||
}, [isAuthenticated, client, handleMailtoProtocolRequest]);
|
||||
|
||||
// Fallback fetch for paths that didn't go through login()'s prefetch
|
||||
// (notably checkAuth on page refresh). The prefetch in auth-store/login()
|
||||
// populates mailboxes before this effect first runs, so on the post-login
|
||||
// path this block is a no-op.
|
||||
// (notably checkAuth on page refresh). Settings pages can also prefill
|
||||
// mailboxes without emails, so bootstrap emails once per client when the
|
||||
// mail route mounts even if mailbox data is already present.
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && client && mailboxes.length === 0) {
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const loadData = async (attempt = 1) => {
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchMailboxes(client),
|
||||
fetchQuota(client)
|
||||
]);
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
const selectedMailboxId = state.selectedMailbox;
|
||||
|
||||
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
|
||||
const delay = Math.min(1000 * attempt, 5000);
|
||||
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
|
||||
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMailboxId) {
|
||||
await fetchEmails(client, selectedMailboxId);
|
||||
} else {
|
||||
await fetchEmails(client);
|
||||
}
|
||||
|
||||
fetchTagCounts(client);
|
||||
} catch (error) {
|
||||
console.error('Error loading email data:', error);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
};
|
||||
if (!isAuthenticated || !client) {
|
||||
initialMailLoadClientRef.current = null;
|
||||
return;
|
||||
}
|
||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]);
|
||||
|
||||
if (initialMailLoadClientRef.current === client) return;
|
||||
initialMailLoadClientRef.current = client;
|
||||
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const loadData = async (attempt = 1) => {
|
||||
try {
|
||||
const needsMailboxes = useEmailStore.getState().mailboxes.length === 0;
|
||||
await Promise.all([
|
||||
needsMailboxes ? fetchMailboxes(client) : Promise.resolve(),
|
||||
fetchQuota(client)
|
||||
]);
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
const selectedMailboxId = state.selectedMailbox;
|
||||
|
||||
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
|
||||
const delay = Math.min(1000 * attempt, 5000);
|
||||
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
|
||||
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshScheduledMetadata(client);
|
||||
|
||||
// Fetch emails for the selected mailbox after scheduled metadata is available.
|
||||
if (selectedMailboxId) {
|
||||
await fetchEmails(client, selectedMailboxId);
|
||||
} else {
|
||||
await fetchEmails(client);
|
||||
}
|
||||
|
||||
fetchTagCounts(client);
|
||||
} catch (error) {
|
||||
console.error('Error loading email data:', error);
|
||||
initialMailLoadClientRef.current = null;
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
};
|
||||
}, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
|
||||
|
||||
// Push notifications: set up once per client and tear down when the client
|
||||
// goes away (logout or account switch). Kept separate from the fetch effect
|
||||
@@ -889,12 +979,12 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
if (!enableUnifiedMailbox && !isEmbedded) return;
|
||||
if (!isAuthenticated || !client) return;
|
||||
const built = buildUnifiedAccounts();
|
||||
if (built.length < 2) return;
|
||||
populateUnifiedAccountMailboxes(built).then((populated) => {
|
||||
refreshUnifiedCounts(populated);
|
||||
buildPopulatedUnifiedAccounts().then((built) => {
|
||||
const hasGroupEntry = built.some((b) => b.isShared);
|
||||
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
|
||||
refreshUnifiedCounts(built);
|
||||
});
|
||||
}, [enableUnifiedMailbox, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
||||
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts]);
|
||||
|
||||
// System-notification click handler. The push SW navigates the user back
|
||||
// here with `?email=<id>` (specific email it built the toast from) or
|
||||
@@ -962,7 +1052,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
// Only set timeout if there's a selected email, it's unread, and we have a client
|
||||
if (!selectedEmail || !client || selectedEmail.keywords?.$seen) {
|
||||
if (!selectedEmail || !client || selectedEmail.keywords?.$seen || isScheduledView) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -996,7 +1086,7 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedEmail?.id]);
|
||||
}, [selectedEmail?.id, isScheduledView]);
|
||||
|
||||
// Handle new email notifications - play sound
|
||||
useEffect(() => {
|
||||
@@ -1037,6 +1127,7 @@ export default function Home() {
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
delayedUntil?: string;
|
||||
}) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -1044,8 +1135,13 @@ export default function Home() {
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
const originalEmailId = selectedEmail?.id;
|
||||
|
||||
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.envelopeMailFrom);
|
||||
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom);
|
||||
setShowComposer(false);
|
||||
if (result.scheduled) {
|
||||
await refreshScheduledMetadata(client);
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the original email with $answered or $forwarded keyword
|
||||
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
|
||||
@@ -1063,7 +1159,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
// Refresh the current mailbox to update the UI
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
if (!isScheduledView) await fetchEmails(client, selectedMailbox);
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
}
|
||||
@@ -1193,6 +1289,45 @@ export default function Home() {
|
||||
if (isMobile) setActiveView('viewer');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUndoSend || !client) return;
|
||||
if (lastUndoToastSubmissionRef.current === pendingUndoSend.submissionId) return;
|
||||
|
||||
lastUndoToastSubmissionRef.current = pendingUndoSend.submissionId;
|
||||
const pending = pendingUndoSend;
|
||||
const undoDurationMs = Math.max(sendDelaySeconds, 8) * 1000;
|
||||
|
||||
toast.success(t('email_viewer.scheduled_send_created'), {
|
||||
duration: undoDurationMs,
|
||||
action: {
|
||||
label: t('email_viewer.undo_send'),
|
||||
onClick: () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const restored = await cancelUndoSend(client, pending);
|
||||
if (restored && !pending.isSmime) {
|
||||
await handleEditDraft(restored);
|
||||
}
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
} catch (error) {
|
||||
console.error('Failed to undo scheduled send:', error);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const current = useEmailStore.getState().pendingUndoSend;
|
||||
if (current?.submissionId === pending.submissionId) {
|
||||
clearPendingUndoSend();
|
||||
}
|
||||
}, undoDurationMs);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t]);
|
||||
|
||||
const handleReplyAll = async () => {
|
||||
if (selectedEmail) {
|
||||
const ok = await emailHooks.onBeforeReplyAll.intercept({
|
||||
@@ -1459,7 +1594,29 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const handleMailboxSelect = async (mailboxId: string) => {
|
||||
if (mailboxId === SCHEDULED_MAILBOX_ID) {
|
||||
if (!delayedSendSupported) {
|
||||
setScheduledView(false);
|
||||
return;
|
||||
}
|
||||
if (isUnifiedView) exitUnifiedView();
|
||||
setScheduledView(true);
|
||||
selectMailbox(mailboxId);
|
||||
selectEmail(null);
|
||||
clearSelection();
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView("list");
|
||||
}
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
}
|
||||
if (client) await fetchScheduledEmails(client);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnifiedMailboxId(mailboxId)) {
|
||||
setScheduledView(false);
|
||||
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
||||
if (!role) return;
|
||||
|
||||
@@ -1474,8 +1631,7 @@ export default function Home() {
|
||||
setTabletListVisible(true);
|
||||
}
|
||||
|
||||
const built = buildUnifiedAccounts();
|
||||
const populated = await populateUnifiedAccountMailboxes(built);
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
await fetchUnifiedEmailsAction(populated, role);
|
||||
refreshUnifiedCounts(populated);
|
||||
return;
|
||||
@@ -1484,6 +1640,7 @@ export default function Home() {
|
||||
if (isUnifiedView) {
|
||||
exitUnifiedView();
|
||||
}
|
||||
setScheduledView(false);
|
||||
|
||||
selectMailbox(mailboxId);
|
||||
selectEmail(null); // Clear selected email when switching mailboxes
|
||||
@@ -1510,6 +1667,7 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const handleTagSelect = async (keywordId: string | null) => {
|
||||
setScheduledView(false);
|
||||
selectKeyword(keywordId);
|
||||
|
||||
// On mobile, close sidebar and go to list view
|
||||
@@ -1753,17 +1911,24 @@ export default function Home() {
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.eml,message/rfc822';
|
||||
input.accept = EML_IMPORT_ACCEPT;
|
||||
input.multiple = true;
|
||||
input.onchange = async (e) => {
|
||||
const files = Array.from((e.target as HTMLInputElement).files ?? []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
let emails;
|
||||
try {
|
||||
emails = await expandImportableEmails(files);
|
||||
} catch {
|
||||
toast.error(t('notifications.import_email_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
for (const file of files) {
|
||||
for (const { blob } of emails) {
|
||||
try {
|
||||
const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' });
|
||||
await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true });
|
||||
imported++;
|
||||
} catch {
|
||||
@@ -1775,7 +1940,7 @@ export default function Home() {
|
||||
toast.success(t('notifications.import_email_success'));
|
||||
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
|
||||
}
|
||||
if (failed > 0) {
|
||||
if (failed > 0 || (imported === 0 && emails.length === 0)) {
|
||||
toast.error(t('notifications.import_email_error'));
|
||||
}
|
||||
};
|
||||
@@ -1813,8 +1978,7 @@ export default function Home() {
|
||||
if (isUnifiedView) {
|
||||
const role = useEmailStore.getState().unifiedRole;
|
||||
if (role) {
|
||||
const built = buildUnifiedAccounts();
|
||||
const populated = await populateUnifiedAccountMailboxes(built);
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
await fetchUnifiedEmailsAction(populated, role);
|
||||
}
|
||||
return;
|
||||
@@ -1918,6 +2082,16 @@ export default function Home() {
|
||||
});
|
||||
|
||||
const originalEmailId = selectedEmail.id;
|
||||
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
|
||||
let delayedUntil: string | undefined;
|
||||
if (sendDelaySeconds > 0) {
|
||||
if (!client.hasDelayedSend()) {
|
||||
const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm'));
|
||||
if (!confirmed) return;
|
||||
} else {
|
||||
delayedUntil = new Date(Date.now() + sendDelaySeconds * 1000).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234).
|
||||
const threading = computeReplyThreadingHeaders({
|
||||
@@ -1926,7 +2100,7 @@ export default function Home() {
|
||||
});
|
||||
|
||||
// Send reply with just the body text
|
||||
await sendEmail(
|
||||
const result = await sendEmail(
|
||||
client,
|
||||
[sender.email],
|
||||
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
||||
@@ -1941,9 +2115,15 @@ export default function Home() {
|
||||
undefined,
|
||||
threading?.inReplyTo,
|
||||
threading?.references,
|
||||
delayedUntil,
|
||||
envelopeMailFrom,
|
||||
);
|
||||
|
||||
if (result.scheduled) {
|
||||
await refreshScheduledMetadata(client);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the original email as answered
|
||||
try {
|
||||
await client.setKeyword(originalEmailId, '$answered');
|
||||
@@ -1968,12 +2148,12 @@ export default function Home() {
|
||||
}
|
||||
|
||||
// Get current mailbox name for mobile header
|
||||
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||
const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
||||
const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
|
||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||
|
||||
// Handle email selection with mobile view switching
|
||||
@@ -1985,9 +2165,8 @@ export default function Home() {
|
||||
setShowComposer(false);
|
||||
}
|
||||
|
||||
// Show the list stub immediately so subject/sender render without
|
||||
// waiting for the body fetch - avoids the loading flicker.
|
||||
const listEmail = emails.find(e => e.id === email.id);
|
||||
const listEmail = activeEmails.find(e => e.id === email.id);
|
||||
if (listEmail) {
|
||||
selectEmail(listEmail);
|
||||
}
|
||||
@@ -2024,6 +2203,14 @@ export default function Home() {
|
||||
|
||||
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||
if (fullEmail) {
|
||||
if (listEmail?.isScheduled) {
|
||||
fullEmail.scheduledSendAt = listEmail.scheduledSendAt;
|
||||
fullEmail.emailSubmissionId = listEmail.emailSubmissionId;
|
||||
fullEmail.scheduledIdentityId = listEmail.scheduledIdentityId;
|
||||
fullEmail.scheduledUndoStatus = listEmail.scheduledUndoStatus;
|
||||
fullEmail.isScheduled = true;
|
||||
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
|
||||
}
|
||||
if (emailAccountId) {
|
||||
fullEmail.accountId = emailAccountId;
|
||||
fullEmail.accountLabel = listEmail?.accountLabel;
|
||||
@@ -2056,14 +2243,14 @@ export default function Home() {
|
||||
};
|
||||
|
||||
// Navigate to next/previous email in the list
|
||||
const selectedEmailIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||
const selectedEmailIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||
|
||||
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < emails.length - 1
|
||||
? () => handleEmailSelect(emails[selectedEmailIndex + 1])
|
||||
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < activeEmails.length - 1
|
||||
? () => handleEmailSelect(activeEmails[selectedEmailIndex + 1])
|
||||
: undefined;
|
||||
|
||||
const handleNavigatePrev = selectedEmailIndex > 0
|
||||
? () => handleEmailSelect(emails[selectedEmailIndex - 1])
|
||||
? () => handleEmailSelect(activeEmails[selectedEmailIndex - 1])
|
||||
: undefined;
|
||||
|
||||
// Handle opening conversation view on mobile
|
||||
@@ -2218,6 +2405,8 @@ export default function Home() {
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
selectedKeyword={selectedKeyword}
|
||||
scheduledTotal={scheduledTotal}
|
||||
showScheduledMailbox={delayedSendSupported}
|
||||
onMailboxSelect={handleMailboxSelect}
|
||||
onTagSelect={handleTagSelect}
|
||||
onUnreadFilterClick={handleUnreadFilterClick}
|
||||
@@ -2299,26 +2488,27 @@ export default function Home() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedEmailIds.size > 0) {
|
||||
if (selectedEmailIds.size === emails.length) {
|
||||
if (selectedEmailIds.size === activeEmails.length) {
|
||||
clearSelection();
|
||||
} else {
|
||||
selectAllEmails();
|
||||
}
|
||||
} else if (emails.length > 0) {
|
||||
} else if (activeEmails.length > 0) {
|
||||
const currentId = selectedEmail?.id;
|
||||
const target = currentId && emails.some((e) => e.id === currentId)
|
||||
const target = currentId && activeEmails.some((e) => e.id === currentId)
|
||||
? currentId
|
||||
: emails[0].id;
|
||||
: activeEmails[0].id;
|
||||
toggleEmailSelection(target);
|
||||
}
|
||||
}}
|
||||
disabled={isScheduledView}
|
||||
className={cn(
|
||||
"flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
selectedEmailIds.size > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={selectedEmailIds.size > 0 ? (selectedEmailIds.size === emails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
|
||||
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : selectedEmailIds.size > 0 ? (selectedEmailIds.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
|
||||
>
|
||||
{selectedEmailIds.size > 0 ? (
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
@@ -2336,6 +2526,8 @@ export default function Home() {
|
||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||
data-search-input
|
||||
data-tour="search-input"
|
||||
disabled={isUnifiedView || isScheduledView}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -2351,13 +2543,15 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAdvancedSearch}
|
||||
disabled={isUnifiedView || isScheduledView}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
|
||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={t("advanced_search.toggle_filters")}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||
@@ -2499,11 +2693,17 @@ export default function Home() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(searchQuery || !isFilterEmpty(searchFilters)) && !isLoading && (
|
||||
{(searchQuery || !isFilterEmpty(searchFilters)) && !activeIsLoading && !isScheduledView && (
|
||||
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
|
||||
{hasMoreEmails
|
||||
? t("advanced_search.results_found_more", { count: emails.length })
|
||||
: t("advanced_search.results_found", { count: emails.length })}
|
||||
{activeHasMore
|
||||
? t("advanced_search.results_found_more", { count: activeEmails.length })
|
||||
: t("advanced_search.results_found", { count: activeEmails.length })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScheduledView && !activeIsLoading && (
|
||||
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
|
||||
{t('email_list.scheduled_count', { count: scheduledTotal })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2512,9 +2712,35 @@ export default function Home() {
|
||||
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
<EmailList
|
||||
emails={emails}
|
||||
emails={activeEmails}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
isLoading={isLoading}
|
||||
isLoading={activeIsLoading}
|
||||
hasMore={activeHasMore}
|
||||
isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined}
|
||||
isScheduledView={isScheduledView}
|
||||
onLoadMoreScheduled={() => client && loadMoreScheduledEmails(client)}
|
||||
onCancelScheduled={async (email) => {
|
||||
if (client && email.emailSubmissionId) await cancelScheduledEmail(client, email.emailSubmissionId, email.id);
|
||||
}}
|
||||
onCancelScheduledForEdit={async (email) => {
|
||||
if (!client) return;
|
||||
const restored = await cancelScheduledEmailForEdit(client, email);
|
||||
if (email.isSmimeScheduled) {
|
||||
setComposerMode('compose');
|
||||
setPendingDraft(null);
|
||||
} else if (restored) {
|
||||
await handleEditDraft(restored);
|
||||
return;
|
||||
}
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
}}
|
||||
onRescheduleScheduled={async (email) => {
|
||||
const delayedUntil = promptForRescheduleDelayedUntil();
|
||||
if (delayedUntil && client && email.emailSubmissionId && email.scheduledIdentityId) {
|
||||
await rescheduleScheduledEmail(client, email.emailSubmissionId, email.id, email.scheduledIdentityId, delayedUntil);
|
||||
}
|
||||
}}
|
||||
onEmailSelect={handleEmailSelect}
|
||||
onEmailDoubleClick={isEmbedded ? ((email) => {
|
||||
useProTabStore.getState().openEmailTab({
|
||||
@@ -2596,7 +2822,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Email list resize handle (desktop only) */}
|
||||
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && (
|
||||
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||
@@ -2675,6 +2901,14 @@ export default function Home() {
|
||||
await handleEmailSend(data);
|
||||
setPendingDraft(null);
|
||||
}}
|
||||
onScheduledSendCreated={async () => {
|
||||
if (client) {
|
||||
await refreshScheduledMetadata(client);
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
}
|
||||
setShowComposer(false);
|
||||
setPendingDraft(null);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
@@ -2770,6 +3004,24 @@ export default function Home() {
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onEditDraft={handleEditDraft}
|
||||
onCancelScheduled={async () => {
|
||||
if (client && selectedEmail?.emailSubmissionId) await cancelScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id);
|
||||
}}
|
||||
onCancelScheduledForEdit={async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
const restored = await cancelScheduledEmailForEdit(client, selectedEmail);
|
||||
if (selectedEmail.isSmimeScheduled) {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
return;
|
||||
}
|
||||
if (restored) await handleEditDraft(restored);
|
||||
}}
|
||||
onRescheduleScheduled={async (delayedUntil) => {
|
||||
if (client && selectedEmail?.emailSubmissionId && selectedEmail.scheduledIdentityId) {
|
||||
await rescheduleScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id, selectedEmail.scheduledIdentityId, delayedUntil);
|
||||
}
|
||||
}}
|
||||
onCompose={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PaneSizeContext } from "@/hooks/use-pane-size";
|
||||
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
|
||||
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
|
||||
import MailPage from "@/app/(main)/[locale]/page";
|
||||
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
|
||||
@@ -172,7 +173,7 @@ export default function ProHome() {
|
||||
// enabled it. If either precondition stops holding, hand the user back
|
||||
// to the standard shell.
|
||||
if (isMobile || isTablet || !proInterface) {
|
||||
window.location.replace("/");
|
||||
window.location.replace(`${getPathPrefix()}/`);
|
||||
}
|
||||
}, [initialCheckDone, isMobile, isTablet, proInterface]);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Languages,
|
||||
Info,
|
||||
Bug,
|
||||
Download,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -59,6 +60,7 @@ import { FolderSettings } from '@/components/settings/folder-settings';
|
||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||
@@ -90,6 +92,7 @@ type Tab =
|
||||
| 'layout'
|
||||
| 'reading'
|
||||
| 'composing'
|
||||
| 'downloads'
|
||||
| 'identities'
|
||||
| 'vacation'
|
||||
| 'filters'
|
||||
@@ -126,6 +129,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
layout: LayoutGrid,
|
||||
reading: BookOpen,
|
||||
composing: PenLine,
|
||||
downloads: Download,
|
||||
identities: UserPen,
|
||||
vacation: PalmtreeIcon,
|
||||
filters: Filter,
|
||||
@@ -202,6 +206,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
'settings.email_behavior.signature_position',
|
||||
'settings.email_behavior.sub_address_delimiter',
|
||||
],
|
||||
downloads: ['settings.downloads'],
|
||||
identities: ['settings.identities'],
|
||||
vacation: ['settings.vacation'],
|
||||
filters: ['settings.filters'],
|
||||
@@ -236,6 +241,7 @@ const tabKeywords: Record<Tab, string> = {
|
||||
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
|
||||
reading: 'mark read preview thread conversation archive delete attachment open',
|
||||
composing: 'editor signature plain text reply forward draft compose',
|
||||
downloads: 'download filename template eml attachment save export',
|
||||
identities: 'from address signature email',
|
||||
vacation: 'auto reply away out of office holiday responder',
|
||||
filters: 'sieve rules block junk forward',
|
||||
@@ -580,6 +586,7 @@ export default function SettingsPage() {
|
||||
// Mail
|
||||
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' },
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||
@@ -666,6 +673,7 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
@@ -77,30 +77,17 @@ function emptyDraft(): RowDraft {
|
||||
|
||||
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
||||
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
||||
const lastEmittedRef = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-sync from props when the underlying config value changes (e.g. revert,
|
||||
// initial load). Skip when drafts already represent the same array to avoid
|
||||
// clobbering in-progress edits.
|
||||
setDrafts((prev) => {
|
||||
if (prev.length === value.length) {
|
||||
const same = prev.every((d, i) => {
|
||||
const e = value[i];
|
||||
return d.id === e.id && d.url === e.url && d.label === e.label;
|
||||
});
|
||||
if (same) return prev;
|
||||
}
|
||||
return value.map(entryToDraft);
|
||||
});
|
||||
if (value === lastEmittedRef.current) return;
|
||||
setDrafts(value.map(entryToDraft))
|
||||
}, [value]);
|
||||
|
||||
function commit(next: RowDraft[]) {
|
||||
setDrafts(next);
|
||||
const entries: JmapServerEntry[] = [];
|
||||
for (const d of next) {
|
||||
const e = draftToEntry(d);
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
const entries = next.map(draftToEntry).filter((e): e is JmapServerEntry => e !== null);
|
||||
lastEmittedRef.current = entries;
|
||||
onChange(entries);
|
||||
}
|
||||
|
||||
|
||||
@@ -273,6 +273,7 @@ export function AuthTab() {
|
||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
|
||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||
<Toggle label="Allow private OAuth endpoints" description="Permit discovery to resolve to RFC-1918 / loopback hosts. Enable only for split-DNS deployments where the mail server's public hostname resolves to an internal IP." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
|
||||
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
|
||||
</Section>
|
||||
|
||||
@@ -25,6 +25,20 @@ const TEXT_FIELDS = [
|
||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||
];
|
||||
|
||||
const PWA_IMAGE_FIELDS = [
|
||||
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
||||
];
|
||||
|
||||
const PWA_TEXT_FIELDS = [
|
||||
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
|
||||
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
|
||||
];
|
||||
|
||||
const PWA_COLOR_FIELDS = [
|
||||
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
|
||||
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
|
||||
];
|
||||
|
||||
export function BrandingTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
@@ -262,6 +276,142 @@ export function BrandingTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Progressive Web App</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{PWA_IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder="Enter URL or upload a file"
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
type="file"
|
||||
accept={field.accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(field.key, file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title="Upload file"
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isUploadedFile(field.key) && (
|
||||
<button
|
||||
onClick={() => handleDeleteUpload(field.key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={currentValue(field.key)}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{PWA_TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{PWA_COLOR_FIELDS.map(field => {
|
||||
const value = currentValue(field.key) || field.defaultValue;
|
||||
return (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5"
|
||||
title="Pick a color"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.defaultValue}
|
||||
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||
|
||||
@@ -28,7 +28,7 @@ const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
|
||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
|
||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
// Single-page tab navigation: clicks update a Zustand store. The URL stays
|
||||
// at /admin so React doesn't fire a route transition on every tab switch -
|
||||
@@ -90,9 +90,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark'
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
|
||||
|
||||
// Match the navigation rail: red for security/deprecated, amber for normal.
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -14,7 +14,7 @@ export default function AdminLoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import "../globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -38,7 +39,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
icons: { icon: faviconUrl },
|
||||
icons: { icon: withBasePath(faviconUrl) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
type State = 'bootstrap' | 'configured' | 'env-managed';
|
||||
|
||||
@@ -258,7 +258,7 @@ export default function SetupWizardPage() {
|
||||
// edge cases that swallow client-side replaces after the
|
||||
// setupComplete flag flips.
|
||||
setTimeout(() => {
|
||||
window.location.assign('/admin/login');
|
||||
window.location.assign(`${getPathPrefix()}/admin/login`);
|
||||
}, 1500);
|
||||
}}
|
||||
/>
|
||||
@@ -340,13 +340,13 @@ function CompletedScreen() {
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<a
|
||||
href="/admin/login"
|
||||
href={`${getPathPrefix()}/admin/login`}
|
||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Sign in to admin dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
href={`${getPathPrefix()}/`}
|
||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Open webmail login
|
||||
@@ -422,13 +422,13 @@ function AlreadyConfiguredScreen() {
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<a
|
||||
href="/admin/login"
|
||||
href={`${getPathPrefix()}/admin/login`}
|
||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Sign in to admin dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
href={`${getPathPrefix()}/`}
|
||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Open webmail login
|
||||
@@ -1329,7 +1329,7 @@ function BrandingAsset({
|
||||
}}
|
||||
/>
|
||||
{value ? (
|
||||
<img src={value} alt="" className="max-w-full max-h-full object-contain" />
|
||||
<img src={withBasePath(value)} alt="" className="max-w-full max-h-full object-contain" />
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
/** Slots that correspond to branding config keys */
|
||||
const VALID_SLOTS = new Set([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
|
||||
@@ -3,9 +3,8 @@ import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { getRequiredConfig, getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
@@ -62,7 +61,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
@@ -52,9 +53,13 @@ async function tryTokenRequest(
|
||||
}
|
||||
}
|
||||
|
||||
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
|
||||
// Admin-trusted callers (matched server entry or configured JMAP server URL)
|
||||
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
|
||||
// go through the SSRF validator regardless of the setting.
|
||||
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
|
||||
// 1. Try OAuth discovery
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
|
||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||
|
||||
// 2. Try common Stalwart token endpoint paths directly
|
||||
@@ -105,14 +110,17 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
let upstreamUrl: string;
|
||||
let resolvedServerId: string | null = null;
|
||||
let adminTrusted = false;
|
||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||
|
||||
if (matchedEntry) {
|
||||
upstreamUrl = matchedEntry.url;
|
||||
resolvedServerId = matchedEntry.id;
|
||||
adminTrusted = true;
|
||||
} else if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
adminTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||
@@ -123,7 +131,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
|
||||
if (!tokenEndpoint) {
|
||||
logger.warn('TOTP token exchange: no token endpoint found');
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
|
||||
@@ -10,6 +10,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
*/
|
||||
|
||||
const ACCOUNT_ID = 'dev-account-001';
|
||||
const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = [];
|
||||
const emailCreationIds = new Map<string, string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mailboxes
|
||||
@@ -1533,6 +1535,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
|
||||
bodyValues: {},
|
||||
};
|
||||
emails.unshift(newEmail);
|
||||
emailCreationIds.set(key, newId);
|
||||
created[key] = { id: newId };
|
||||
}
|
||||
}
|
||||
@@ -1575,8 +1578,51 @@ function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
|
||||
return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult {
|
||||
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: { 'sub-1': { id: 'sub-mock-1' } }, notCreated: null }, callId];
|
||||
function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult {
|
||||
const created: Record<string, { id: string; sendAt?: string }> = {};
|
||||
const updated: Record<string, null> = {};
|
||||
const create = args.create as Record<string, { emailId?: string; identityId?: string; envelope?: { mailFrom?: { parameters?: { HOLDFOR?: string; HOLDUNTIL?: string } } } }> | undefined;
|
||||
if (create) {
|
||||
for (const [key, value] of Object.entries(create)) {
|
||||
const id = `submission-${Date.now()}-${key}`;
|
||||
const holdFor = value.envelope?.mailFrom?.parameters?.HOLDFOR;
|
||||
const holdUntil = value.envelope?.mailFrom?.parameters?.HOLDUNTIL;
|
||||
const holdForSeconds = holdFor ? Number(holdFor) : Number.NaN;
|
||||
const holdUntilTime = Number.isFinite(holdForSeconds) && holdForSeconds > 0
|
||||
? Date.now() + holdForSeconds * 1000
|
||||
: holdUntil ? new Date(holdUntil).getTime() : Number.NaN;
|
||||
const delayedUntil = Number.isFinite(holdUntilTime) ? new Date(holdUntilTime).toISOString() : undefined;
|
||||
created[key] = { id, ...(delayedUntil ? { sendAt: delayedUntil } : {}) };
|
||||
if (delayedUntil && value.emailId && value.identityId) {
|
||||
const emailId = value.emailId.startsWith('#') ? emailCreationIds.get(value.emailId.slice(1)) || value.emailId : value.emailId;
|
||||
scheduledSubmissions.push({ id, emailId, identityId: value.identityId, sendAt: delayedUntil, undoStatus: 'pending' });
|
||||
}
|
||||
}
|
||||
}
|
||||
const update = args.update as Record<string, { undoStatus?: 'pending' | 'final' | 'canceled' }> | undefined;
|
||||
if (update) {
|
||||
for (const [id, patch] of Object.entries(update)) {
|
||||
const submission = scheduledSubmissions.find(s => s.id === id);
|
||||
if (submission && patch.undoStatus) {
|
||||
submission.undoStatus = patch.undoStatus;
|
||||
updated[id] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, notCreated: null, notUpdated: null }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionQuery(args: MethodArgs, callId: string): MethodResult {
|
||||
const position = Number(args.position || 0);
|
||||
const limit = Number(args.limit || 50);
|
||||
const submissions = [...scheduledSubmissions].sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
|
||||
return ['EmailSubmission/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: submissions.slice(position, position + limit).map(s => s.id), total: submissions.length, position, canCalculateChanges: false }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResult {
|
||||
const ids = args.ids as string[] | undefined;
|
||||
const list = ids ? scheduledSubmissions.filter(s => ids.includes(s.id)) : scheduledSubmissions;
|
||||
return ['EmailSubmission/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||
}
|
||||
|
||||
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
|
||||
@@ -1639,6 +1685,8 @@ const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => Meth
|
||||
'Identity/get': handleIdentityGet,
|
||||
'Identity/set': handleIdentitySet,
|
||||
'EmailSubmission/set': handleEmailSubmissionSet,
|
||||
'EmailSubmission/query': handleEmailSubmissionQuery,
|
||||
'EmailSubmission/get': handleEmailSubmissionGet,
|
||||
'Quota/get': handleQuotaGet,
|
||||
'VacationResponse/get': handleVacationResponseGet,
|
||||
'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId],
|
||||
@@ -1776,7 +1824,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
isReadOnly: false,
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': {},
|
||||
'urn:ietf:params:jmap:submission': { maxDelayedSend: 2592000, submissionExtensions: { FUTURERELEASE: true } },
|
||||
'urn:ietf:params:jmap:quota': {},
|
||||
'urn:ietf:params:jmap:vacationresponse': {},
|
||||
'urn:ietf:params:jmap:contacts': {},
|
||||
|
||||
@@ -3,11 +3,13 @@ import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
|
||||
const VALID_SIZES = new Set([192, 512]);
|
||||
|
||||
// Cache resized images in memory to avoid reprocessing on every request
|
||||
const cache = new Map<number, Blob>();
|
||||
// Cache resized images keyed by (size, source URL) so admin re-uploads or URL
|
||||
// changes invalidate the prior render instead of serving stale bytes forever.
|
||||
const cache = new Map<string, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
// Absolute URL (http/https)
|
||||
@@ -17,6 +19,14 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
|
||||
// but stored on disk under getConfigDir()/branding/.
|
||||
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
|
||||
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
|
||||
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
|
||||
return readFile(path.join(getConfigDir(), 'branding', filename));
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
@@ -47,9 +57,11 @@ export async function GET(
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
};
|
||||
|
||||
const cacheKey = `${size}|${iconUrl}`;
|
||||
|
||||
try {
|
||||
if (cache.has(size)) {
|
||||
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||
if (cache.has(cacheKey)) {
|
||||
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||
@@ -61,7 +73,7 @@ export async function GET(
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(size, blob);
|
||||
cache.set(cacheKey, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
|
||||
+2
-2
@@ -242,8 +242,8 @@ body {
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.email-content-text {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,11 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
const calendarName = calendar?.name || "";
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endTime = getEventEndDate(event);
|
||||
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
||||
const safeFormat = (d: Date, fmt: string) => {
|
||||
if (isNaN(d.getTime())) return "--:--";
|
||||
try { return format(d, fmt); } catch { return "--:--"; }
|
||||
};
|
||||
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
|
||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent) => {
|
||||
@@ -153,7 +157,6 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||
continuesAfter && "rounded-r-sm",
|
||||
continuesBefore && "-ml-0.5",
|
||||
continuesAfter && "pr-2",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
isBeingDragged && "opacity-50",
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||
Pencil, Trash2, Copy, Send, Check,
|
||||
} from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { format, isSameDay, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -143,6 +143,8 @@ export function EventDetailPopover({
|
||||
const startDate = getEventStartDate(event);
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endDate = getEventEndDate(event);
|
||||
const displayEndDate = getEventDisplayEndDate(event);
|
||||
const isMultiDay = !isSameDay(startDate, displayEndDate);
|
||||
|
||||
const locationName = useMemo(() => {
|
||||
if (!event.locations) return null;
|
||||
@@ -331,16 +333,50 @@ export function EventDetailPopover({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
{isMultiDay ? (
|
||||
event.showWithoutTime ? (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)} –
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(displayEndDate)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{formatTime(startDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(endDate)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{formatTime(endDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durationMinutes)})
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(startDate)} – {formatTime(endDate)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
</div>
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(startDate)} – {formatTime(endDate)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
@@ -621,14 +621,42 @@ export function EventModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const displayEnd = getEventDisplayEndDate(event);
|
||||
const multiDay = !isSameDay(startD, displayEnd);
|
||||
if (multiDay && event.showWithoutTime) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{formatEventDate(startD)} –</div>
|
||||
<div className="font-medium">{formatEventDate(displayEnd)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (multiDay) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{formatEventDate(endD)}</span>
|
||||
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{event.description && (
|
||||
<p className="text-sm text-muted-foreground">{event.description}</p>
|
||||
@@ -742,17 +770,55 @@ export function EventModal({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const displayEnd = getEventDisplayEndDate(event);
|
||||
const multiDay = !isSameDay(startD, displayEnd);
|
||||
if (multiDay && event.showWithoutTime) {
|
||||
return (
|
||||
<>
|
||||
<div className="font-medium text-foreground">{formatEventDate(startD)} –</div>
|
||||
<div className="font-medium text-foreground">{formatEventDate(displayEnd)}</div>
|
||||
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (multiDay) {
|
||||
return (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(endD)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durMin)})
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -35,6 +35,10 @@ import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import {
|
||||
rewriteCidImagesForEditor,
|
||||
replaceInlineImagePlaceholders,
|
||||
} from "@/lib/email-composer-utils";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
@@ -84,7 +88,9 @@ interface EmailComposerProps {
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
delayedUntil?: string;
|
||||
}) => void | Promise<void>;
|
||||
onScheduledSendCreated?: () => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
onDiscardDraft?: (draftId: string) => void;
|
||||
onSaveState?: (data: ComposerDraftData) => void;
|
||||
@@ -164,8 +170,21 @@ function buildEmbeddedSignatureHtml(
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatLocalDateTimeInput(date: Date): string {
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function getDefaultScheduleValue(): string {
|
||||
const tomorrowAtEight = new Date();
|
||||
tomorrowAtEight.setDate(tomorrowAtEight.getDate() + 1);
|
||||
tomorrowAtEight.setHours(8, 0, 0, 0);
|
||||
return formatLocalDateTimeInput(tomorrowAtEight);
|
||||
}
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onScheduledSendCreated,
|
||||
onClose,
|
||||
onDiscardDraft,
|
||||
onSaveState,
|
||||
@@ -183,6 +202,7 @@ export function EmailComposer({
|
||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||
const activeIdentities = useIdentityStore((s) => s.identities);
|
||||
@@ -300,7 +320,8 @@ export function EmailComposer({
|
||||
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const wrap = replyTo.quoteWrapInBlockquote !== false;
|
||||
const originalHtml = replyTo.htmlBody
|
||||
?? (replyTo.body
|
||||
? rewriteCidImagesForEditor(replyTo.htmlBody)
|
||||
: (replyTo.body
|
||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||
: '');
|
||||
const bodyHtml = wrap
|
||||
@@ -314,7 +335,10 @@ export function EmailComposer({
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
||||
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
|
||||
// cid: image refs are rewritten so they render in the editor (browsers
|
||||
// can't fetch cid: URLs); see useEffect below for the data-URL backfill.
|
||||
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody);
|
||||
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedHtml}</blockquote>`;
|
||||
}
|
||||
|
||||
if (replyTo.body) {
|
||||
@@ -382,6 +406,12 @@ export function EmailComposer({
|
||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||
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({
|
||||
isActive: showSaveAsTemplate,
|
||||
@@ -487,6 +517,23 @@ export function EmailComposer({
|
||||
}
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutsideSendMenu = (event: MouseEvent) => {
|
||||
if (!sendMenuRef.current?.contains(event.target as Node)) {
|
||||
setShowSendMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutsideSendMenu);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutsideSendMenu);
|
||||
}, []);
|
||||
|
||||
const openScheduleDialog = useCallback(() => {
|
||||
setScheduleError('');
|
||||
setScheduleValue(getDefaultScheduleValue());
|
||||
setShowScheduleDialog(true);
|
||||
setShowSendMenu(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSelectReplyIdentity) return;
|
||||
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||
@@ -534,6 +581,76 @@ export function EmailComposer({
|
||||
selectedIdentityId,
|
||||
]);
|
||||
|
||||
// Hydrate inline images referenced by the quoted body (issue #163).
|
||||
// `getInitialBody` rewrites `<img src="cid:xxx">` to placeholder src +
|
||||
// data-cid; here we (1) register each inline attachment in inlineImagesRef
|
||||
// so the send path re-attaches the blob with the right cid, and (2) fetch
|
||||
// each blob as a data URL and swap it into the body so the editor actually
|
||||
// shows the image instead of a blank placeholder.
|
||||
useEffect(() => {
|
||||
if (plainTextMode) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
||||
if (!composerClient || !replyTo?.attachments?.length) return;
|
||||
|
||||
const inlineAtts = replyTo.attachments.filter((att) =>
|
||||
att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')
|
||||
);
|
||||
if (inlineAtts.length === 0) return;
|
||||
|
||||
// Seed the ref synchronously so a fast Send still attaches the right blobs
|
||||
// even if the FileReader work below hasn't resolved yet.
|
||||
for (const att of inlineAtts) {
|
||||
if (!att.cid) continue;
|
||||
if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue;
|
||||
inlineImagesRef.current.push({
|
||||
cid: att.cid,
|
||||
blobId: att.blobId,
|
||||
type: att.type,
|
||||
name: att.name || 'inline',
|
||||
size: att.size,
|
||||
dataUrl: '',
|
||||
});
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const updates = new Map<string, string>();
|
||||
for (const att of inlineAtts) {
|
||||
if (!att.cid) continue;
|
||||
try {
|
||||
const buffer = await composerClient.fetchBlobArrayBuffer(
|
||||
att.blobId,
|
||||
att.name || 'inline',
|
||||
att.type,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const blob = new Blob([buffer], { type: att.type });
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
if (cancelled) return;
|
||||
const entry = inlineImagesRef.current.find((e) => e.cid === att.cid);
|
||||
if (entry) entry.dataUrl = dataUrl;
|
||||
updates.set(att.cid, dataUrl);
|
||||
} catch (err) {
|
||||
debug.error('Failed to load inline image for compose', err);
|
||||
}
|
||||
}
|
||||
if (cancelled || updates.size === 0) return;
|
||||
setBody((prev) => replaceInlineImagePlaceholders(prev, updates));
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// We deliberately hydrate once per composer open - subsequent replyTo
|
||||
// object identity churn from parent renders shouldn't refetch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [composerClient, plainTextMode, mode]);
|
||||
|
||||
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
||||
: signatureIdentity?.textSignature
|
||||
@@ -1106,6 +1223,33 @@ export function EmailComposer({
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validateScheduleValue = (value: string): string | null => {
|
||||
if (!value) return t('schedule_send_required');
|
||||
const time = new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) return t('schedule_send_invalid');
|
||||
if (time <= Date.now()) return t('schedule_send_future');
|
||||
if (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:
|
||||
// references so recipient clients that strip data URIs can still render them.
|
||||
const rewriteInlineImages = (html: string): {
|
||||
@@ -1149,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 bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
|
||||
@@ -1177,6 +1321,7 @@ export function EmailComposer({
|
||||
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
|
||||
if (matched) {
|
||||
setAttachmentWarningKeyword(matched);
|
||||
setAttachmentWarningDelayedUntil(delayedUntil);
|
||||
setShowAttachmentWarning(true);
|
||||
return;
|
||||
}
|
||||
@@ -1265,6 +1410,7 @@ export function EmailComposer({
|
||||
const inlineAttachments = rewritten?.attachments ?? [];
|
||||
|
||||
try {
|
||||
const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil);
|
||||
// Let plugins veto the send (external-mail warning, mistyped-domain
|
||||
// guards, etc.). Returning false from any handler aborts before either
|
||||
// the S/MIME or standard JMAP path runs.
|
||||
@@ -1414,7 +1560,16 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Standard JMAP send path
|
||||
// Collect uploaded attachment blobIds for the send request
|
||||
@@ -1464,6 +1619,7 @@ export function EmailComposer({
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||
inReplyTo: threadingHeaders?.inReplyTo,
|
||||
references: threadingHeaders?.references,
|
||||
delayedUntil: effectiveDelayedUntil,
|
||||
});
|
||||
|
||||
if (mode === 'reply' || mode === 'replyAll') {
|
||||
@@ -1488,14 +1644,54 @@ export function EmailComposer({
|
||||
setDraftId(null);
|
||||
setSubAddressTag("");
|
||||
setValidationErrors({});
|
||||
setShowScheduleDialog(false);
|
||||
setScheduleValue('');
|
||||
setScheduleError('');
|
||||
// Clear ref so unmount effect doesn't re-save
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||
} catch (err) {
|
||||
debug.error('Failed to send email:', err);
|
||||
toast.error(t('send_failed'));
|
||||
toast.error(err instanceof Error ? err.message : t('send_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleSend = () => {
|
||||
if (!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
|
||||
// draft. Scoped to events whose target lives inside this composer's
|
||||
// DOM tree — in Pro mode multiple composer tabs can be mounted at
|
||||
// once (inactive tabs are CSS-hidden, not unmounted), so a window
|
||||
// listener would otherwise fire every mounted composer's handleSend
|
||||
// on a single keystroke. handleSend is rebound every render, so we
|
||||
// route through a ref to keep the listener stable.
|
||||
const composerRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const handleSendRef = useRef<((skipAttachmentCheck?: boolean) => Promise<void>) | undefined>(undefined);
|
||||
handleSendRef.current = handleSend;
|
||||
useEffect(() => {
|
||||
const handleSendShortcut = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
if (e.altKey || e.shiftKey) return;
|
||||
const root = composerRootRef.current;
|
||||
if (!root || !(e.target instanceof Node) || !root.contains(e.target)) return;
|
||||
e.preventDefault();
|
||||
void handleSendRef.current?.();
|
||||
};
|
||||
window.addEventListener('keydown', handleSendShortcut);
|
||||
return () => window.removeEventListener('keydown', handleSendShortcut);
|
||||
}, []);
|
||||
|
||||
const cleanClose = () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
@@ -1534,8 +1730,50 @@ 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 (
|
||||
<div className={cn("flex h-full bg-background", className)}>
|
||||
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
|
||||
<PluginSlot
|
||||
name="composer-sidebar"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
|
||||
@@ -1548,6 +1786,7 @@ export function EmailComposer({
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
>
|
||||
{/* Drag overlay */}
|
||||
{isDraggingOver && (
|
||||
@@ -1975,7 +2214,6 @@ export function EmailComposer({
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* S/MIME toggles */}
|
||||
{canSmimeSign && (
|
||||
<>
|
||||
@@ -2013,15 +2251,56 @@ export function EmailComposer({
|
||||
>
|
||||
{t('discard')}
|
||||
</button>
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
title={getSendTooltip()}
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{t('send')}
|
||||
</Button>
|
||||
{composerClient?.hasDelayedSend() ? (
|
||||
<div ref={sendMenuRef} className="relative hidden md:inline-flex">
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
title={getSendTooltip()}
|
||||
className="rounded-r-none border-r border-primary-foreground/20"
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -2060,6 +2339,29 @@ export function EmailComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showScheduleDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||
<div className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md p-6 animate-in zoom-in-95 duration-200">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{t('schedule_send')}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{t('schedule_send_description')}</p>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={scheduleValue}
|
||||
onChange={(e) => {
|
||||
setScheduleValue(e.target.value);
|
||||
setScheduleError('');
|
||||
}}
|
||||
className={cn(scheduleError && "border-destructive focus-visible:ring-destructive")}
|
||||
/>
|
||||
{scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setShowScheduleDialog(false)}>{tCommon('cancel')}</Button>
|
||||
<Button onClick={handleScheduleSend} disabled={!canSend}>{t('schedule_send')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* S/MIME passphrase prompt */}
|
||||
{smimePassphrasePrompt && (
|
||||
<div
|
||||
@@ -2136,7 +2438,7 @@ export function EmailComposer({
|
||||
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
|
||||
{t('forgot_attachment.back')}
|
||||
</Button>
|
||||
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
|
||||
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true, attachmentWarningDelayedUntil); setAttachmentWarningDelayedUntil(undefined); }}>
|
||||
{t('forgot_attachment.send_anyway')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2383,6 +2685,11 @@ function RecipientChipInput({
|
||||
aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined}
|
||||
aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
|
||||
aria-invalid={validationError || undefined}
|
||||
data-bwignore="true"
|
||||
data-1p-ignore
|
||||
data-op-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
</div>
|
||||
{validationError && validationMessage && (
|
||||
@@ -2399,4 +2706,4 @@ function RecipientChipInput({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
EditIcon,
|
||||
CalendarClock,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
@@ -63,6 +65,9 @@ interface EmailContextMenuProps {
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduled?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
onRescheduleScheduled?: () => void;
|
||||
// Batch actions
|
||||
onBatchMarkAsRead?: (read: boolean) => void;
|
||||
onBatchDelete?: () => void;
|
||||
@@ -133,6 +138,9 @@ export function EmailContextMenu({
|
||||
onBatchMarkAsSpam,
|
||||
onBatchUndoSpam,
|
||||
onEditDraft,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const _tColor = useTranslations("email_viewer.color_tag");
|
||||
@@ -143,6 +151,8 @@ export function EmailContextMenu({
|
||||
const currentColors = getCurrentColors(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
const isScheduled = email.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||
|
||||
// Build color options from keyword definitions in settings
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
@@ -196,8 +206,36 @@ export function EmailContextMenu({
|
||||
</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 */}
|
||||
{!showBatchActions && isDraft && onEditDraft && (
|
||||
{!isScheduled && !showBatchActions && isDraft && onEditDraft && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={EditIcon}
|
||||
@@ -209,7 +247,7 @@ export function EmailContextMenu({
|
||||
)}
|
||||
|
||||
{/* Single email actions - Reply, Reply All, Forward */}
|
||||
{!showBatchActions && (
|
||||
{!isScheduled && !showBatchActions && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={Reply}
|
||||
@@ -375,6 +413,8 @@ export function EmailContextMenu({
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PluginSlot name="context-menu-email" />
|
||||
</ContextMenu>
|
||||
|
||||
@@ -50,7 +50,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
@@ -68,8 +70,6 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
|
||||
@@ -4,13 +4,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { ThreadListItem } from "./thread-list-item";
|
||||
import { EmailContextMenu } from "./email-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle } from "lucide-react";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
@@ -26,6 +27,8 @@ interface EmailListProps {
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
hasMore?: boolean;
|
||||
isLoadingMoreItems?: boolean;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
onReply?: (email: Email) => void;
|
||||
onReplyAll?: (email: Email) => void;
|
||||
@@ -39,6 +42,11 @@ interface EmailListProps {
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
onEditDraft?: (email: Email) => void;
|
||||
isScheduledView?: boolean;
|
||||
onLoadMoreScheduled?: () => void;
|
||||
onCancelScheduled?: (email: Email) => void | Promise<void>;
|
||||
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
|
||||
onRescheduleScheduled?: (email: Email) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function EmailList({
|
||||
@@ -48,6 +56,8 @@ export function EmailList({
|
||||
onEmailDoubleClick,
|
||||
className,
|
||||
isLoading = false,
|
||||
hasMore,
|
||||
isLoadingMoreItems,
|
||||
onOpenConversation,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
@@ -61,6 +71,11 @@ export function EmailList({
|
||||
onUndoSpam,
|
||||
onMoveToMailbox,
|
||||
onEditDraft,
|
||||
isScheduledView = false,
|
||||
onLoadMoreScheduled,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
}: EmailListProps) {
|
||||
const t = useTranslations('email_list');
|
||||
const { client } = useAuthStore();
|
||||
@@ -95,9 +110,9 @@ export function EmailList({
|
||||
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||
|
||||
const threadGroups = useMemo(() => {
|
||||
const groups = groupEmailsByThread(emails, disableThreading);
|
||||
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
|
||||
return sortThreadGroups(groups);
|
||||
}, [emails, disableThreading]);
|
||||
}, [emails, disableThreading, isScheduledView]);
|
||||
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
@@ -107,7 +122,11 @@ export function EmailList({
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const footerHasMore = hasMore ?? hasMoreEmails;
|
||||
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
|
||||
const estimateSize = useCallback(() => {
|
||||
if (isFocusedMailLayout) {
|
||||
@@ -216,10 +235,14 @@ export function EmailList({
|
||||
};
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (isScheduledView) {
|
||||
onLoadMoreScheduled?.();
|
||||
return;
|
||||
}
|
||||
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
|
||||
loadMoreEmails(client);
|
||||
}
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
|
||||
|
||||
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
|
||||
const isExpanded = expandedThreadIds.has(threadId);
|
||||
@@ -279,7 +302,7 @@ export function EmailList({
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
hasSelection ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
hasSelection && !isScheduledView ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
|
||||
@@ -403,17 +426,19 @@ export function EmailList({
|
||||
) : emails.length === 0 && !isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted shadow-lg flex items-center justify-center">
|
||||
{searchQuery || !isFilterEmpty(searchFilters) ? (
|
||||
{isScheduledView ? (
|
||||
<CalendarClock className="w-10 h-10 text-muted-foreground" />
|
||||
) : searchQuery || !isFilterEmpty(searchFilters) ? (
|
||||
<SearchX className="w-10 h-10 text-muted-foreground" />
|
||||
) : (
|
||||
<MailX className="w-10 h-10 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
|
||||
{isScheduledView ? t('no_scheduled_emails') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
|
||||
</p>
|
||||
<p className="text-sm mt-1 text-muted-foreground">
|
||||
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
|
||||
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -465,13 +490,13 @@ export function EmailList({
|
||||
</div>
|
||||
|
||||
<div className="py-4 flex justify-center">
|
||||
{isLoadingMore && hasMoreEmails && (
|
||||
{footerIsLoadingMore && footerHasMore && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>{t('loading_more')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasMoreEmails && emails.length > 0 && (
|
||||
{!footerHasMore && emails.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground border-t border-border pt-6">
|
||||
{t('no_more_emails')}
|
||||
</div>
|
||||
@@ -506,6 +531,9 @@ export function EmailList({
|
||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
||||
onUndoSpam={() => onUndoSpam?.(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)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchArchive={async () => {
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
@@ -63,6 +66,7 @@ import {
|
||||
EditIcon,
|
||||
PlayCircle,
|
||||
PenSquare,
|
||||
CalendarClock,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
@@ -73,12 +77,14 @@ import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||
import { RecipientPopover } from "./recipient-popover";
|
||||
@@ -121,6 +127,9 @@ interface EmailViewerProps {
|
||||
onNavigatePrev?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduled?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
onRescheduleScheduled?: (delayedUntil: string) => void;
|
||||
onCompose?: () => void;
|
||||
currentUserEmail?: string;
|
||||
currentUserName?: string;
|
||||
@@ -798,6 +807,7 @@ interface DraggableAttachmentChipProps {
|
||||
attachment: EffectiveAttachment;
|
||||
client: IJMAPClient | null;
|
||||
enabled: boolean;
|
||||
downloadName?: string;
|
||||
children: (dragProps: {
|
||||
draggable: boolean;
|
||||
onPointerEnter: () => void;
|
||||
@@ -806,9 +816,9 @@ interface DraggableAttachmentChipProps {
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) {
|
||||
function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
|
||||
const source = useMemo<AttachmentDragSource>(() => ({
|
||||
name: attachment.name || 'download',
|
||||
name: downloadName || attachment.name || 'download',
|
||||
type: attachment.type || 'application/octet-stream',
|
||||
getBlobUrl: async () => {
|
||||
if (attachment.blobId && client) {
|
||||
@@ -831,7 +841,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, children }: Drag
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}), [attachment, client]);
|
||||
}), [attachment, client, downloadName]);
|
||||
const drag = useAttachmentDrag(source, enabled);
|
||||
return <>{children(drag)}</>;
|
||||
}
|
||||
@@ -869,6 +879,9 @@ export function EmailViewer({
|
||||
onNavigatePrev,
|
||||
onShowShortcuts,
|
||||
onEditDraft,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
onCompose,
|
||||
currentUserEmail,
|
||||
currentUserName,
|
||||
@@ -878,6 +891,7 @@ export function EmailViewer({
|
||||
className,
|
||||
}: EmailViewerProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tComposer = useTranslations('email_composer');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tCommon = useTranslations('common');
|
||||
const tSmime = useTranslations('smime');
|
||||
@@ -900,6 +914,26 @@ export function EmailViewer({
|
||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
|
||||
const dragOutActive = useMemo(() => isDragOutSupported(), []);
|
||||
const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
|
||||
const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_TEMPLATE;
|
||||
const filenameSpaceReplacement = useSettingsStore((state) => state.filenameSpaceReplacement);
|
||||
const filenameLowercase = useSettingsStore((state) => state.filenameLowercase);
|
||||
const filenameStripDiacritics = useSettingsStore((state) => state.filenameStripDiacritics);
|
||||
const filenameCollapseSeparators = useSettingsStore((state) => state.filenameCollapseSeparators);
|
||||
const emailFilenameOptions = useMemo(() => ({
|
||||
template: emailDownloadTemplate,
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
}), [emailDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
|
||||
const attachmentFilenameOptions = useMemo(() => ({
|
||||
template: attachmentDownloadTemplate,
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
}), [attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
|
||||
@@ -908,6 +942,8 @@ export function EmailViewer({
|
||||
|
||||
// Detect if the email is a draft
|
||||
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)
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
@@ -920,8 +956,33 @@ export function EmailViewer({
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
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 { startTour } = useTour();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
||||
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
||||
@@ -2559,6 +2620,15 @@ export function EmailViewer({
|
||||
return emailContent;
|
||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||
|
||||
const resolveAttachmentName = useCallback(
|
||||
(attachment: EffectiveAttachment) => {
|
||||
const fallback = attachment.name || 'download';
|
||||
if (!email) return fallback;
|
||||
return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentFilenameOptions) || fallback;
|
||||
},
|
||||
[email, attachmentFilenameOptions],
|
||||
);
|
||||
|
||||
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
// Blob URLs inherit our origin; script-bearing MIME types (text/html,
|
||||
@@ -2568,6 +2638,8 @@ export function EmailViewer({
|
||||
&& mailAttachmentAction === 'preview'
|
||||
&& isMimeTypeSafeForInlinePreview(attachment.type);
|
||||
|
||||
const downloadName = resolveAttachmentName(attachment);
|
||||
|
||||
const info: AttachmentInfo = {
|
||||
name: attachment.name || '',
|
||||
type: attachment.type,
|
||||
@@ -2578,7 +2650,7 @@ export function EmailViewer({
|
||||
|
||||
if (attachment.blobId && onDownloadAttachment) {
|
||||
emailHooks.onAttachmentDownload.emit(info);
|
||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
|
||||
onDownloadAttachment(attachment.blobId, downloadName, attachment.type);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2598,7 +2670,7 @@ export function EmailViewer({
|
||||
emailHooks.onAttachmentDownload.emit(info);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachment.name || 'download';
|
||||
anchor.download = downloadName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
@@ -2628,16 +2700,17 @@ export function EmailViewer({
|
||||
emailHooks.onAttachmentDownload.emit(info);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachment.name || 'download';
|
||||
anchor.download = downloadName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
}
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}, [mailAttachmentAction, onDownloadAttachment, email?.id]);
|
||||
}, [mailAttachmentAction, onDownloadAttachment, email, resolveAttachmentName]);
|
||||
|
||||
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
|
||||
const downloadName = resolveAttachmentName(attachment);
|
||||
const info: AttachmentInfo = {
|
||||
name: attachment.name || '',
|
||||
type: attachment.type,
|
||||
@@ -2647,7 +2720,7 @@ export function EmailViewer({
|
||||
};
|
||||
emailHooks.onAttachmentDownload.emit(info);
|
||||
if (attachment.blobId && onDownloadAttachment) {
|
||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
|
||||
onDownloadAttachment(attachment.blobId, downloadName, attachment.type, true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2660,7 +2733,7 @@ export function EmailViewer({
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachment.name || 'download';
|
||||
anchor.download = downloadName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
@@ -2676,12 +2749,12 @@ export function EmailViewer({
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachment.name || 'download';
|
||||
anchor.download = downloadName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}, [onDownloadAttachment, email?.id]);
|
||||
}, [onDownloadAttachment, email?.id, resolveAttachmentName]);
|
||||
|
||||
// Pre-fetch object URLs for image attachments so their actual contents can be
|
||||
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
|
||||
@@ -2797,7 +2870,9 @@ export function EmailViewer({
|
||||
// Word/Outlook HTML emails ship a <style> block but put their gutter in
|
||||
// @page margins (print-only), so they need a fallback body padding too.
|
||||
const isWordHtml = /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(effectiveEmailContent.html);
|
||||
const bodyPadding = (effectiveEmailContent.hasStyleTag && !isWordHtml) ? '0' : '1rem 1.25rem';
|
||||
const hasOwnLayout = effectiveEmailContent.hasStyleTag && !isWordHtml;
|
||||
const bodyPadding = hasOwnLayout ? '0' : '1rem 1.25rem';
|
||||
const mobileBodyPaddingX = hasOwnLayout ? '0' : '0.75rem';
|
||||
|
||||
// Word emails rely on empty <p class=MsoNormal> </p> spacers for vertical
|
||||
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
|
||||
@@ -2820,11 +2895,11 @@ export function EmailViewer({
|
||||
<style>
|
||||
html, body { overflow: hidden; }
|
||||
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
@media (max-width: 640px) { body { padding-left: 0; padding-right: 0; } }
|
||||
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
|
||||
img { max-width: 100% !important; height: auto !important; }
|
||||
a { color: #1a73e8; }
|
||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||
td, th { word-break: break-word; }
|
||||
td, th { overflow-wrap: anywhere; }
|
||||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||
${wordHtmlCSS}
|
||||
${darkModeCSS}
|
||||
@@ -3054,35 +3129,58 @@ export function EmailViewer({
|
||||
const handleExportEmail = async () => {
|
||||
if (!email?.blobId || !client) return;
|
||||
try {
|
||||
const subject = (email.subject || 'email').replace(/[<>:"/\\|?*]+/g, '_').slice(0, 100);
|
||||
await client.downloadBlob(email.blobId, `${subject}.eml`, 'message/rfc822');
|
||||
await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822');
|
||||
} catch {
|
||||
toast.error(tNotifications('export_email_error'));
|
||||
return;
|
||||
}
|
||||
const action = useSettingsStore.getState().postExportAction;
|
||||
if (action === 'archive') onArchive?.();
|
||||
else if (action === 'trash') onDelete?.();
|
||||
};
|
||||
|
||||
// Import email from .eml file
|
||||
// Import email from .eml file or .zip archive containing .eml files
|
||||
const handleImportEmail = () => {
|
||||
if (!client) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.eml,message/rfc822';
|
||||
input.accept = EML_IMPORT_ACCEPT;
|
||||
input.multiple = true;
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const files = Array.from((e.target as HTMLInputElement).files ?? []);
|
||||
if (files.length === 0) return;
|
||||
const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState();
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const mailboxId = mailbox?.originalId || selectedMailbox;
|
||||
if (!mailboxId) {
|
||||
toast.error(tNotifications('import_email_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
let emails;
|
||||
try {
|
||||
const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState();
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const mailboxId = mailbox?.originalId || selectedMailbox;
|
||||
if (!mailboxId) {
|
||||
toast.error(tNotifications('import_email_error'));
|
||||
return;
|
||||
emails = await expandImportableEmails(files);
|
||||
} catch {
|
||||
toast.error(tNotifications('import_email_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
for (const { blob } of emails) {
|
||||
try {
|
||||
await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true });
|
||||
imported++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' });
|
||||
await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true });
|
||||
}
|
||||
|
||||
if (imported > 0) {
|
||||
toast.success(tNotifications('import_email_success'));
|
||||
await fetchEmails(client);
|
||||
} catch {
|
||||
}
|
||||
if (failed > 0 || emails.length === 0) {
|
||||
toast.error(tNotifications('import_email_error'));
|
||||
}
|
||||
};
|
||||
@@ -3206,9 +3304,9 @@ export function EmailViewer({
|
||||
|
||||
if (!email) {
|
||||
if (isDemoMode) {
|
||||
const logoSrc = resolvedTheme === 'dark'
|
||||
const logoSrc = withBasePath(resolvedTheme === 'dark'
|
||||
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
|
||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg';
|
||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
|
||||
return (
|
||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||
<div className="text-center p-8 max-w-md">
|
||||
@@ -3253,19 +3351,21 @@ export function EmailViewer({
|
||||
}
|
||||
return (
|
||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||
<div className="text-center p-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||
{!isEmbedded && (
|
||||
<div className="text-center p-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||
{onCompose && (
|
||||
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
{t('compose')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||
{onCompose && (
|
||||
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
{t('compose')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3291,7 +3391,32 @@ export function EmailViewer({
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</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
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -3303,7 +3428,7 @@ export function EmailViewer({
|
||||
<span className="text-sm">{t('edit_draft')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{!isDraft && (<>
|
||||
{!isScheduled && !isDraft && (<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3345,6 +3470,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
|
||||
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
|
||||
{!isScheduled && (
|
||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||
{/* Archive */}
|
||||
<Button
|
||||
@@ -3802,6 +3928,7 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3812,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)}
|
||||
>
|
||||
{/* Mobile More menu sidebar overlay */}
|
||||
{isMobile && moreMenuOpen && (
|
||||
{!isScheduled && isMobile && moreMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
|
||||
onClick={() => setMoreMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isMobile && (
|
||||
{!isScheduled && isMobile && (
|
||||
<div className={cn(
|
||||
"fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
@@ -4205,7 +4332,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -4285,7 +4412,7 @@ export function EmailViewer({
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -4750,10 +4877,44 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scheduled Banner */}
|
||||
{isScheduled && (
|
||||
<div className="border-b border-border bg-primary/10">
|
||||
<div className="max-w-4xl mx-auto px-6 py-2.5 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{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 */}
|
||||
{isDraft && (
|
||||
<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">
|
||||
<File className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{t('draft_banner')}</span>
|
||||
@@ -4904,7 +5065,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -4989,7 +5150,7 @@ export function EmailViewer({
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -5047,7 +5208,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -5126,7 +5287,7 @@ export function EmailViewer({
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -5222,14 +5383,16 @@ export function EmailViewer({
|
||||
<PluginSlot name="email-footer" />
|
||||
|
||||
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="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-shrink-0">
|
||||
<Avatar
|
||||
name={currentUserName || "You"}
|
||||
email={currentUserEmail || ""}
|
||||
name={activeAccount?.displayName || currentUserName || "You"}
|
||||
email={activeAccount?.email || activeAccount?.username || currentUserEmail || ""}
|
||||
size="lg"
|
||||
className="shadow-sm w-10 h-10"
|
||||
disableFavicon
|
||||
fallbackColor={activeAccount?.avatarColor}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
|
||||
@@ -115,7 +115,17 @@ export const ResizableImage = Node.create({
|
||||
width: { default: null },
|
||||
cid: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-cid"),
|
||||
parseHTML: (el) => {
|
||||
const dataCid = el.getAttribute("data-cid");
|
||||
if (dataCid) return dataCid;
|
||||
// Fall back to deriving the cid from `src="cid:xxx"` so inline
|
||||
// image refs survive editor round-trips even when data-cid was
|
||||
// never set (defensive — the composer normally pre-rewrites
|
||||
// quoted-body cid: refs into data-cid).
|
||||
const src = el.getAttribute("src") || "";
|
||||
if (/^cid:/i.test(src)) return src.slice(4) || null;
|
||||
return null;
|
||||
},
|
||||
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
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 { cn } from "@/lib/utils";
|
||||
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 { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -67,15 +67,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
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
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
@@ -91,8 +97,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
@@ -241,12 +245,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{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 className={cn(
|
||||
'text-xs tabular-nums',
|
||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
@@ -299,14 +313,24 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{kd.label}
|
||||
</span>
|
||||
))}
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
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"
|
||||
title={scheduledSendLabel}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -335,16 +359,18 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
/>
|
||||
{!email.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -374,12 +400,17 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||
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 getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
@@ -645,12 +676,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{keywordDef && (
|
||||
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
||||
)}
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
@@ -715,14 +756,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{keywordDef.label}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
hasUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
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"
|
||||
title={scheduledSendLabel}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -751,16 +802,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions for thread header */}
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
/>
|
||||
{!latestEmail.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||
|
||||
@@ -78,7 +78,10 @@ export function FilterRuleModal({
|
||||
const pathMap = new Map<string, string>();
|
||||
const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
|
||||
for (const node of nodes) {
|
||||
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||
// Sieve fileinto expects the IMAP-canonical "INBOX" for the inbox,
|
||||
// not the localized JMAP display name (e.g. "Entrada" in pt-BR).
|
||||
const segment = node.role === "inbox" ? "INBOX" : node.name;
|
||||
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
|
||||
pathMap.set(node.id, fullPath);
|
||||
if (node.children.length > 0) buildPaths(node.children, fullPath);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface NavItem {
|
||||
@@ -385,7 +385,7 @@ export function NavigationRail({
|
||||
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
href="/admin"
|
||||
href={`${getPathPrefix()}/admin`}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"transition-colors duration-150",
|
||||
@@ -444,7 +444,7 @@ export function NavigationRail({
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl));
|
||||
return logoUrl ? (
|
||||
<div className="flex items-center justify-center py-3 px-1">
|
||||
<img
|
||||
@@ -572,7 +572,7 @@ export function NavigationRail({
|
||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
href="/admin"
|
||||
href={`${getPathPrefix()}/admin`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
|
||||
title={t("admin") || "Admin"}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, ReactNode } from "react";
|
||||
import { useState, useEffect, useMemo, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
@@ -74,6 +74,8 @@ interface SidebarProps {
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefreshMailboxes?: () => void;
|
||||
scheduledTotal?: number;
|
||||
showScheduledMailbox?: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
|
||||
@@ -674,6 +676,8 @@ export function Sidebar({
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onRefreshMailboxes,
|
||||
scheduledTotal = 0,
|
||||
showScheduledMailbox = false,
|
||||
className,
|
||||
multiAccountMode = false,
|
||||
accountMailboxes,
|
||||
@@ -732,15 +736,20 @@ export function Sidebar({
|
||||
// pane.
|
||||
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded;
|
||||
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
|
||||
const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified);
|
||||
const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons);
|
||||
const tagCounts = useEmailStore(s => s.tagCounts);
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const connectedAccounts = accounts.filter(a => a.isConnected);
|
||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||
// Pro shell treats the unified mailbox as a core part of the multi-account
|
||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
|
||||
// 2+ account requirement still applies - with a single account the
|
||||
// unified counts would just duplicate that account's inbox.
|
||||
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
|
||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. With a
|
||||
// single account we still surface unified when the user has opted into
|
||||
// merging group/shared inboxes — otherwise the counts would just duplicate
|
||||
// the one inbox.
|
||||
const showUnified =
|
||||
(multiAccountMode || enableUnifiedMailbox) &&
|
||||
(connectedAccounts.length > 1 || (includeGroupInUnified && hasGroupInboxes));
|
||||
const { unifiedCounts } = useEmailStore();
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
@@ -1022,22 +1031,35 @@ export function Sidebar({
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
tree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={(mailboxId) =>
|
||||
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
|
||||
}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{tree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={(mailboxId) =>
|
||||
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
|
||||
}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={isActive ? onUnreadFilterClick : 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -1062,20 +1084,33 @@ export function Sidebar({
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
ownTree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{ownTree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
))}
|
||||
{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 sendEmail = useEmailStore((s) => s.sendEmail);
|
||||
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 isScheduledView = useEmailStore((s) => s.isScheduledView);
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
|
||||
@@ -36,10 +39,18 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
const tabIdRef = useRef(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]) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await sendEmail(
|
||||
const result = await sendEmail(
|
||||
client,
|
||||
sendData.to,
|
||||
sendData.subject,
|
||||
@@ -54,9 +65,15 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
sendData.attachments,
|
||||
sendData.inReplyTo,
|
||||
sendData.references,
|
||||
sendData.delayedUntil,
|
||||
sendData.envelopeMailFrom,
|
||||
);
|
||||
|
||||
if (result.scheduled) {
|
||||
await handleScheduledSendCreated();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the original message as $answered / $forwarded so the standard
|
||||
// viewer and list reflect the action (same behaviour as inline compose).
|
||||
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);
|
||||
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(() => {
|
||||
closeTab(tabIdRef.current);
|
||||
@@ -125,6 +142,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
initialDraftText={data.initialDraftText}
|
||||
initialData={data.initialData}
|
||||
onSend={handleSend}
|
||||
onScheduledSendCreated={handleScheduledSendCreated}
|
||||
onClose={handleClose}
|
||||
onDiscardDraft={handleDiscardDraft}
|
||||
onSaveState={handleSaveState}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
@@ -58,7 +59,8 @@ export function PWAInstallPrompt() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const logoSrc = appLogoLightUrl || faviconUrl;
|
||||
const logoSrc = withBasePath(appLogoLightUrl || faviconUrl);
|
||||
const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
|
||||
@@ -75,7 +77,7 @@ export function PWAInstallPrompt() {
|
||||
)}
|
||||
{logoSrc && (
|
||||
<img
|
||||
src={appLogoDarkUrl || faviconUrl}
|
||||
src={darkLogoSrc}
|
||||
alt={appName}
|
||||
className="w-8 h-8 shrink-0 object-contain hidden dark:block"
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useUpdateStore } from '@/stores/update-store';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
@@ -123,12 +124,12 @@ export function AboutDataSettings() {
|
||||
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src="/branding/Bulwark_Logo_Color.svg"
|
||||
src={`${getPathPrefix()}/branding/Bulwark_Logo_Color.svg`}
|
||||
alt="Bulwark"
|
||||
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
<img
|
||||
src="/branding/Bulwark_Logo_White.svg"
|
||||
src={`${getPathPrefix()}/branding/Bulwark_Logo_White.svg`}
|
||||
alt="Bulwark"
|
||||
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
|
||||
@@ -4,17 +4,14 @@ import { useTranslations } from 'next-intl';
|
||||
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
|
||||
export function CalendarSettings() {
|
||||
const t = useTranslations('calendar.settings');
|
||||
const tViews = useTranslations('calendar.views');
|
||||
const tDays = useTranslations('calendar.days');
|
||||
|
||||
const { viewMode, setViewMode } = useCalendarStore();
|
||||
const {
|
||||
timeFormat,
|
||||
firstDayOfWeek,
|
||||
showTimeInMonthView,
|
||||
showWeekNumbers,
|
||||
enableCalendarTasks,
|
||||
@@ -40,28 +37,6 @@ export function CalendarSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('week_starts_on')}>
|
||||
<Select
|
||||
value={firstDayOfWeek.toString()}
|
||||
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as 0 | 1)}
|
||||
options={[
|
||||
{ value: '1', label: tDays('monday') },
|
||||
{ value: '0', label: tDays('sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('time_format')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(value) => updateSetting('timeFormat', value as '12h' | '24h')}
|
||||
options={[
|
||||
{ value: '12h', label: t('time_format_12h') },
|
||||
{ value: '24h', label: t('time_format_24h') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('show_time_in_month_view')}
|
||||
description={t('show_time_in_month_view_desc')}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
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 { X } from 'lucide-react';
|
||||
import {
|
||||
@@ -22,11 +24,14 @@ export function ComposingSettings() {
|
||||
autoSelectReplyIdentity,
|
||||
attachmentReminderEnabled,
|
||||
attachmentReminderKeywords,
|
||||
sendDelaySeconds,
|
||||
subAddressDelimiter,
|
||||
signaturePosition,
|
||||
signatureSeparatorEnabled,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { client } = useAuthStore();
|
||||
const delayedSendSupported = client?.hasDelayedSend() ?? false;
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -37,6 +42,24 @@ export function ComposingSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Select
|
||||
value={String(sendDelaySeconds)}
|
||||
onChange={(value) => updateSetting('sendDelaySeconds', Number(value) as SendDelaySeconds)}
|
||||
options={[
|
||||
{ value: '0', label: t('send_delay.off') },
|
||||
{ value: '10', label: t('send_delay.seconds', { seconds: 10 }) },
|
||||
{ value: '30', label: t('send_delay.seconds', { seconds: 30 }) },
|
||||
{ value: '60', label: t('send_delay.seconds', { seconds: 60 }) },
|
||||
]}
|
||||
/>
|
||||
{sendDelaySeconds > 0 && !delayedSendSupported && (
|
||||
<p className="max-w-64 text-right text-xs text-amber-600 dark:text-amber-400">{t('send_delay.unsupported')}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('signature_position.label')} description={t('signature_position.description')}>
|
||||
<Select
|
||||
value={signaturePosition}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from "./settings-section";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DEFAULT_ATTACHMENT_TEMPLATE,
|
||||
DEFAULT_BUNDLE_TEMPLATE,
|
||||
DEFAULT_EMAIL_TEMPLATE,
|
||||
EMAIL_TOKENS,
|
||||
ATTACHMENT_TOKENS,
|
||||
BUNDLE_TOKENS,
|
||||
bundleExportFilename,
|
||||
emailExportFilename,
|
||||
attachmentDownloadFilename,
|
||||
buildSampleEmail,
|
||||
type EmailFilenameOptions,
|
||||
} from "@/lib/download-filename";
|
||||
|
||||
function insertTokenAtCursor(
|
||||
input: HTMLInputElement,
|
||||
token: string,
|
||||
current: string,
|
||||
onChange: (next: string) => void,
|
||||
): void {
|
||||
const start = input.selectionStart ?? current.length;
|
||||
const end = input.selectionEnd ?? current.length;
|
||||
const before = current.slice(0, start);
|
||||
const after = current.slice(end);
|
||||
const insertion = `{${token}}`;
|
||||
const next = `${before}${insertion}${after}`;
|
||||
onChange(next);
|
||||
// Restore focus and place caret after the inserted token.
|
||||
requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
const caret = before.length + insertion.length;
|
||||
input.setSelectionRange(caret, caret);
|
||||
});
|
||||
}
|
||||
|
||||
interface TemplateEditorProps {
|
||||
label: string;
|
||||
description: string;
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
tokens: { token: string; description: string }[];
|
||||
preview: string;
|
||||
onChange: (next: string) => void;
|
||||
resetLabel: string;
|
||||
previewLabel: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function TemplateEditor({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
defaultValue,
|
||||
tokens,
|
||||
preview,
|
||||
onChange,
|
||||
resetLabel,
|
||||
previewLabel,
|
||||
placeholder,
|
||||
}: TemplateEditorProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<div data-search-label={label} className="space-y-3 py-3 border-b border-border last:border-0">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="flex-1 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground font-mono focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(defaultValue)}
|
||||
disabled={value === defaultValue}
|
||||
title={resetLabel}
|
||||
className={cn(
|
||||
"px-2 rounded-md border border-border text-foreground transition-colors duration-150",
|
||||
value === defaultValue
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: "hover:bg-muted cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tokens.map((t) => (
|
||||
<button
|
||||
key={t.token}
|
||||
type="button"
|
||||
title={t.description}
|
||||
onClick={() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) {
|
||||
onChange(`${value}{${t.token}}`);
|
||||
return;
|
||||
}
|
||||
insertTokenAtCursor(input, t.token, value, onChange);
|
||||
}}
|
||||
className="px-2 py-0.5 text-xs font-mono rounded bg-muted hover:bg-accent border border-border text-foreground transition-colors duration-150 cursor-pointer"
|
||||
>
|
||||
{`{${t.token}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="opacity-70">{previewLabel} </span>
|
||||
<span className="font-mono text-foreground/90 break-all">{preview}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadsSettings() {
|
||||
const t = useTranslations("settings.downloads");
|
||||
const {
|
||||
emailDownloadTemplate,
|
||||
attachmentDownloadTemplate,
|
||||
bundleDownloadTemplate,
|
||||
filenameSpaceReplacement,
|
||||
filenameLowercase,
|
||||
filenameStripDiacritics,
|
||||
filenameCollapseSeparators,
|
||||
postExportAction,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
const sampleEmail = useMemo(() => buildSampleEmail(), []);
|
||||
const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []);
|
||||
|
||||
const transform = useMemo(
|
||||
() => ({
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
}),
|
||||
[filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators],
|
||||
);
|
||||
|
||||
const emailOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ ...transform, template: emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE }),
|
||||
[transform, emailDownloadTemplate],
|
||||
);
|
||||
const attachmentOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ ...transform, template: attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE }),
|
||||
[transform, attachmentDownloadTemplate],
|
||||
);
|
||||
const bundleOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ ...transform, template: bundleDownloadTemplate || DEFAULT_BUNDLE_TEMPLATE }),
|
||||
[transform, bundleDownloadTemplate],
|
||||
);
|
||||
|
||||
const emlPreview = useMemo(
|
||||
() => emailExportFilename(sampleEmail, emailOptions),
|
||||
[sampleEmail, emailOptions],
|
||||
);
|
||||
const attachmentPreview = useMemo(
|
||||
() => attachmentDownloadFilename(sampleEmail, sampleAttachment, attachmentOptions),
|
||||
[sampleEmail, sampleAttachment, attachmentOptions],
|
||||
);
|
||||
const bundlePreview = useMemo(
|
||||
// Render with the email's fixed sample date so the preview is stable as the
|
||||
// user types in the template field.
|
||||
() => bundleExportFilename(3, bundleOptions, sampleEmail.receivedAt ?? undefined),
|
||||
[bundleOptions, sampleEmail],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
<TemplateEditor
|
||||
label={t("email_template.label")}
|
||||
description={t("email_template.description")}
|
||||
value={emailDownloadTemplate}
|
||||
defaultValue={DEFAULT_EMAIL_TEMPLATE}
|
||||
tokens={EMAIL_TOKENS}
|
||||
preview={emlPreview}
|
||||
onChange={(next) => updateSetting("emailDownloadTemplate", next)}
|
||||
resetLabel={t("reset")}
|
||||
previewLabel={t("preview")}
|
||||
placeholder={DEFAULT_EMAIL_TEMPLATE}
|
||||
/>
|
||||
<TemplateEditor
|
||||
label={t("attachment_template.label")}
|
||||
description={t("attachment_template.description")}
|
||||
value={attachmentDownloadTemplate}
|
||||
defaultValue={DEFAULT_ATTACHMENT_TEMPLATE}
|
||||
tokens={ATTACHMENT_TOKENS}
|
||||
preview={attachmentPreview}
|
||||
onChange={(next) => updateSetting("attachmentDownloadTemplate", next)}
|
||||
resetLabel={t("reset")}
|
||||
previewLabel={t("preview")}
|
||||
placeholder={DEFAULT_ATTACHMENT_TEMPLATE}
|
||||
/>
|
||||
<TemplateEditor
|
||||
label={t("bundle_template.label")}
|
||||
description={t("bundle_template.description")}
|
||||
value={bundleDownloadTemplate}
|
||||
defaultValue={DEFAULT_BUNDLE_TEMPLATE}
|
||||
tokens={BUNDLE_TOKENS}
|
||||
preview={bundlePreview}
|
||||
onChange={(next) => updateSetting("bundleDownloadTemplate", next)}
|
||||
resetLabel={t("reset")}
|
||||
previewLabel={t("preview")}
|
||||
placeholder={DEFAULT_BUNDLE_TEMPLATE}
|
||||
/>
|
||||
<SettingItem label={t("spaces.label")} description={t("spaces.description")}>
|
||||
<Select
|
||||
value={filenameSpaceReplacement}
|
||||
onChange={(value) => updateSetting("filenameSpaceReplacement", value as "keep" | "underscore" | "dash")}
|
||||
options={[
|
||||
{ value: "keep", label: t("spaces.keep") },
|
||||
{ value: "underscore", label: t("spaces.underscore") },
|
||||
{ value: "dash", label: t("spaces.dash") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("lowercase.label")} description={t("lowercase.description")}>
|
||||
<ToggleSwitch
|
||||
checked={filenameLowercase}
|
||||
onChange={(checked) => updateSetting("filenameLowercase", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("strip_diacritics.label")} description={t("strip_diacritics.description")}>
|
||||
<ToggleSwitch
|
||||
checked={filenameStripDiacritics}
|
||||
onChange={(checked) => updateSetting("filenameStripDiacritics", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("collapse_separators.label")} description={t("collapse_separators.description")}>
|
||||
<ToggleSwitch
|
||||
checked={filenameCollapseSeparators}
|
||||
onChange={(checked) => updateSetting("filenameCollapseSeparators", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("after_export.label")} description={t("after_export.description")}>
|
||||
<Select
|
||||
value={postExportAction}
|
||||
onChange={(value) => updateSetting("postExportAction", value as "keep" | "archive" | "trash")}
|
||||
options={[
|
||||
{ value: "keep", label: t("after_export.keep") },
|
||||
{ value: "archive", label: t("after_export.archive") },
|
||||
{ value: "trash", label: t("after_export.trash") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Hom
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
|
||||
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
|
||||
interface SampleFile {
|
||||
name: string;
|
||||
@@ -95,7 +96,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
||||
)}
|
||||
>
|
||||
{settings.showThumbnails && file.thumbnailUrl ? (
|
||||
<img src={file.thumbnailUrl} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
|
||||
<img src={withBasePath(file.thumbnailUrl)} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
|
||||
) : settings.showIcons ? (
|
||||
getPreviewIcon(file, settings.coloredIcons, "sm")
|
||||
) : null}
|
||||
@@ -125,7 +126,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
||||
)}
|
||||
>
|
||||
{settings.showThumbnails && file.thumbnailUrl ? (
|
||||
<img src={file.thumbnailUrl} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
<img src={withBasePath(file.thumbnailUrl)} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
) : settings.showIcons ? (
|
||||
getPreviewIcon(file, settings.coloredIcons, "lg")
|
||||
) : (
|
||||
|
||||
@@ -1,17 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { DateFormat, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section';
|
||||
|
||||
export function LanguageSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const t = useTranslations('settings.language_region');
|
||||
const tDays = useTranslations('calendar.days');
|
||||
|
||||
const { dateFormat, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
|
||||
|
||||
// Subscribe to locale changes so the preview re-renders on language switch
|
||||
// (formatDate reads it via getState() and would otherwise stay stale).
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
// Build sample timestamps for each bucket so users see what their pick
|
||||
// will look like in practice. Use offsets relative to "now" so the
|
||||
// bucketing is stable even though the wall-clock keeps moving.
|
||||
void locale; void dateFormat; void timeFormat;
|
||||
const now = new Date();
|
||||
const today = new Date(now);
|
||||
today.setHours(15, 31, 0, 0);
|
||||
const thisWeek = new Date(now);
|
||||
thisWeek.setDate(now.getDate() - 2);
|
||||
thisWeek.setHours(15, 31, 0, 0);
|
||||
const older = new Date(now);
|
||||
older.setMonth(now.getMonth() - 2);
|
||||
older.setHours(15, 31, 0, 0);
|
||||
return {
|
||||
today: formatDate(today),
|
||||
thisWeek: formatDate(thisWeek),
|
||||
older: formatDate(older),
|
||||
};
|
||||
}, [locale, dateFormat, timeFormat]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('language.label')} description={t('language.description')}>
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem label={t('language.label')} description={t('language.description')}>
|
||||
<LanguageSwitcher />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('date_format.label')} description={t('date_format.description')}>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Select
|
||||
value={dateFormat}
|
||||
onChange={(value) => updateSetting('dateFormat', value as DateFormat)}
|
||||
options={[
|
||||
{ value: 'smart', label: t('date_format.smart') },
|
||||
{ value: 'relative', label: t('date_format.relative') },
|
||||
{ value: 'full', label: t('date_format.full') },
|
||||
]}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground text-right space-y-0.5 font-mono">
|
||||
<div>
|
||||
<span className="opacity-70">{t('date_format.preview_today')} </span>
|
||||
<span className="text-foreground/90">{preview.today}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="opacity-70">{t('date_format.preview_this_week')} </span>
|
||||
<span className="text-foreground/90">{preview.thisWeek}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="opacity-70">{t('date_format.preview_older')} </span>
|
||||
<span className="text-foreground/90">{preview.older}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('time_format.label')} description={t('time_format.description')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(value) => updateSetting('timeFormat', value as TimeFormat)}
|
||||
options={[
|
||||
{ value: '12h', label: t('time_format.12h') },
|
||||
{ value: '24h', label: t('time_format.24h') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('first_day.label')} description={t('first_day.description')}>
|
||||
<Select
|
||||
value={firstDayOfWeek.toString()}
|
||||
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)}
|
||||
options={[
|
||||
{ value: '1', label: tDays('monday') },
|
||||
{ value: '0', label: tDays('sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
|
||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||
@@ -115,9 +117,11 @@ function MailLayoutPreview({
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const mailboxes = useEmailStore(s => s.mailboxes);
|
||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -177,10 +181,11 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{accounts.length > 1 && (
|
||||
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
description={t('unified_mailbox.description')}
|
||||
locked={isSettingLocked('enableUnifiedMailbox')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableUnifiedMailbox}
|
||||
@@ -189,6 +194,21 @@ export function LayoutSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
|
||||
<div className="ml-4 border-l-2 border-border pl-4 -mt-2">
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.include_group.label')}
|
||||
description={t('unified_mailbox.include_group.description')}
|
||||
locked={isSettingLocked('includeGroupInUnified')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={includeGroupInUnified}
|
||||
onChange={(v) => updateSetting('includeGroupInUnified', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
|
||||
<ToggleSwitch
|
||||
checked={proInterface}
|
||||
|
||||
@@ -120,9 +120,10 @@ export function ReadingSettings() {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
value={deleteAction}
|
||||
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
|
||||
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'trash-and-read' | 'permanent')}
|
||||
options={[
|
||||
{ value: 'trash', label: t('delete_action.trash') },
|
||||
{ value: 'trash-and-read', label: t('delete_action.trash_and_read') },
|
||||
{ value: 'permanent', label: t('delete_action.permanent') },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { avatarHooks } from "@/lib/plugin-hooks";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
||||
|
||||
@@ -236,7 +237,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}`) : null;
|
||||
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
|
||||
const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
|
||||
|
||||
|
||||
@@ -92,9 +92,11 @@ export function useAttachmentDrag(
|
||||
return;
|
||||
}
|
||||
|
||||
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium reads this on
|
||||
// drop and writes a real file at the destination.
|
||||
e.dataTransfer.setData("DownloadURL", `${type}:${encodeURIComponent(name)}:${url}`);
|
||||
// `DownloadURL` format: <mime>:<filename>:<url>. The filename must be
|
||||
// raw - URL-encoding it lands literally on disk (`%20` instead of a
|
||||
// space). Callers are expected to sanitise reserved chars (`:` etc.)
|
||||
// beforehand.
|
||||
e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`);
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
},
|
||||
[source.name, source.type, prefetch],
|
||||
|
||||
+202
-5
@@ -1,10 +1,21 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useCallback, DragEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
|
||||
import {
|
||||
bundleExportFilename,
|
||||
DEFAULT_BUNDLE_TEMPLATE,
|
||||
DEFAULT_EMAIL_TEMPLATE,
|
||||
emailExportFilename,
|
||||
type EmailFilenameOptions,
|
||||
} from "@/lib/download-filename";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface UseEmailDragOptions {
|
||||
email: Email;
|
||||
@@ -15,6 +26,7 @@ interface UseEmailDragOptions {
|
||||
interface UseEmailDragReturn {
|
||||
dragHandlers: {
|
||||
draggable: boolean;
|
||||
onPointerEnter?: () => void;
|
||||
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
@@ -44,10 +56,153 @@ function createDragPreview(count: number): HTMLElement {
|
||||
return preview;
|
||||
}
|
||||
|
||||
function bundleFilename(count: number, options: EmailFilenameOptions): string {
|
||||
return bundleExportFilename(count, options);
|
||||
}
|
||||
|
||||
// Shared bundle cache. The .zip is keyed by the sorted list of email IDs in
|
||||
// the selection, so two rows in the same selection reuse the same in-flight
|
||||
// build. When the selection changes, the previous bundle URL is scheduled for
|
||||
// revoke and a new build starts.
|
||||
type BundleEntry = {
|
||||
key: string;
|
||||
name: string;
|
||||
url: string | null;
|
||||
promise: Promise<string | null> | null;
|
||||
};
|
||||
|
||||
let currentBundle: BundleEntry | null = null;
|
||||
|
||||
function selectionKey(ids: string[]): string {
|
||||
return [...ids].sort().join(",");
|
||||
}
|
||||
|
||||
async function buildEmailZip(client: IJMAPClient, emails: Email[], options: EmailFilenameOptions): Promise<string | null> {
|
||||
const eligible = emails.filter((em) => !!em.blobId);
|
||||
if (eligible.length === 0) return null;
|
||||
const { default: JSZip } = await import("jszip");
|
||||
const zip = new JSZip();
|
||||
const used = new Set<string>();
|
||||
await Promise.all(
|
||||
eligible.map(async (em) => {
|
||||
const base = emailExportFilename(em, options).replace(/\.eml$/, "");
|
||||
let name = `${base}.eml`;
|
||||
while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`;
|
||||
used.add(name);
|
||||
try {
|
||||
const blob = await client.fetchBlob(em.blobId!, name, "message/rfc822");
|
||||
zip.file(name, blob);
|
||||
} catch {
|
||||
// Skip individual failures; remaining messages still bundle.
|
||||
}
|
||||
}),
|
||||
);
|
||||
const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" });
|
||||
return URL.createObjectURL(zipBlob);
|
||||
}
|
||||
|
||||
function prefetchEmailBundle(
|
||||
client: IJMAPClient,
|
||||
emails: Email[],
|
||||
emailOptions: EmailFilenameOptions,
|
||||
bundleOptions: EmailFilenameOptions,
|
||||
): void {
|
||||
const key = selectionKey(emails.map((e) => e.id));
|
||||
if (currentBundle && currentBundle.key === key) return;
|
||||
if (currentBundle?.url) {
|
||||
const old = currentBundle.url;
|
||||
setTimeout(() => URL.revokeObjectURL(old), 60_000);
|
||||
}
|
||||
const entry: BundleEntry = {
|
||||
key,
|
||||
name: bundleFilename(emails.length, bundleOptions),
|
||||
url: null,
|
||||
promise: null,
|
||||
};
|
||||
entry.promise = buildEmailZip(client, emails, emailOptions)
|
||||
.then((url) => {
|
||||
if (url && currentBundle === entry) entry.url = url;
|
||||
return url;
|
||||
})
|
||||
.catch(() => null);
|
||||
currentBundle = entry;
|
||||
}
|
||||
|
||||
function getReadyBundle(emails: Email[]): { url: string; name: string } | null {
|
||||
const key = selectionKey(emails.map((e) => e.id));
|
||||
if (currentBundle && currentBundle.key === key && currentBundle.url) {
|
||||
return { url: currentBundle.url, name: currentBundle.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
|
||||
const { selectedEmailIds, emails } = useEmailStore();
|
||||
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const client = useAuthStore((state) => state.client);
|
||||
const template = useSettingsStore((s) => s.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
|
||||
const bundleTemplate = useSettingsStore((s) => s.bundleDownloadTemplate) || DEFAULT_BUNDLE_TEMPLATE;
|
||||
const spaceReplacement = useSettingsStore((s) => s.filenameSpaceReplacement);
|
||||
const lowercase = useSettingsStore((s) => s.filenameLowercase);
|
||||
const stripDiacritics = useSettingsStore((s) => s.filenameStripDiacritics);
|
||||
const collapseSeparators = useSettingsStore((s) => s.filenameCollapseSeparators);
|
||||
const filenameOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }),
|
||||
[template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators],
|
||||
);
|
||||
const bundleOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ template: bundleTemplate, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }),
|
||||
[bundleTemplate, spaceReplacement, lowercase, stripDiacritics, collapseSeparators],
|
||||
);
|
||||
|
||||
const dragOutEnabled = !isMobile && isDragOutSupported() && !!client;
|
||||
const singleBlobUrlRef = useRef<string | null>(null);
|
||||
const inFlightRef = useRef<Promise<string | null> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (singleBlobUrlRef.current) {
|
||||
const url = singleBlobUrlRef.current;
|
||||
singleBlobUrlRef.current = null;
|
||||
// Defer revoke - Chromium asynchronously reads the blob: URL after the
|
||||
// drop completes, so revoking immediately can race the OS.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
inFlightRef.current = null;
|
||||
};
|
||||
}, [email.id]);
|
||||
|
||||
const prefetchSingle = useCallback(() => {
|
||||
if (!dragOutEnabled || !client || !email.blobId) return;
|
||||
if (singleBlobUrlRef.current || inFlightRef.current) return;
|
||||
const name = emailExportFilename(email, filenameOptions);
|
||||
inFlightRef.current = client
|
||||
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
|
||||
.then((url) => {
|
||||
if (url && !singleBlobUrlRef.current) singleBlobUrlRef.current = url;
|
||||
return url;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
inFlightRef.current = null;
|
||||
});
|
||||
}, [dragOutEnabled, client, email, filenameOptions]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
if (!dragOutEnabled || !client) return;
|
||||
const isSelected = selectedEmailIds.has(email.id);
|
||||
const isMulti = isSelected && selectedEmailIds.size > 1;
|
||||
if (isMulti) {
|
||||
const selected = emails.filter((em) => selectedEmailIds.has(em.id));
|
||||
// Only worth bundling when at least one selected email has a blobId.
|
||||
if (selected.some((em) => em.blobId)) {
|
||||
prefetchEmailBundle(client, selected, filenameOptions, bundleOptions);
|
||||
}
|
||||
} else {
|
||||
prefetchSingle();
|
||||
}
|
||||
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions, bundleOptions]);
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
// Determine which emails to drag:
|
||||
@@ -59,8 +214,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
? emails.filter(em => selectedEmailIds.has(em.id))
|
||||
: threadEmails || [email];
|
||||
|
||||
// Set data transfer
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData(
|
||||
"application/x-email-ids",
|
||||
JSON.stringify(emailsToDrag.map(em => em.id))
|
||||
@@ -70,6 +224,40 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
emailsToDrag.map(em => em.subject || "(no subject)").join(", ")
|
||||
);
|
||||
|
||||
// Drag-out to file explorer.
|
||||
if (dragOutEnabled && client) {
|
||||
if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) {
|
||||
const url = singleBlobUrlRef.current;
|
||||
if (url) {
|
||||
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
|
||||
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium expects
|
||||
// the filename raw - URL-encoding it ends up literally on disk
|
||||
// (e.g. `%20` instead of a space). The sanitiser already removed
|
||||
// `:` and other reserved chars, so embedding the name as-is is
|
||||
// safe. Firefox/Safari ignore this entry entirely.
|
||||
e.dataTransfer.setData(
|
||||
"DownloadURL",
|
||||
`message/rfc822:${name}:${url}`,
|
||||
);
|
||||
} else {
|
||||
// Not warmed up yet — kick off so the next attempt works. Don't
|
||||
// preventDefault: in-app drop still has to function.
|
||||
prefetchSingle();
|
||||
}
|
||||
} else if (emailsToDrag.length > 1) {
|
||||
const ready = getReadyBundle(emailsToDrag);
|
||||
if (ready) {
|
||||
e.dataTransfer.setData(
|
||||
"DownloadURL",
|
||||
`application/zip:${ready.name}:${ready.url}`,
|
||||
);
|
||||
} else {
|
||||
// Kick off the bundle build for the next attempt.
|
||||
prefetchEmailBundle(client, emailsToDrag, filenameOptions, bundleOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create custom drag image
|
||||
const dragPreview = createDragPreview(emailsToDrag.length);
|
||||
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
||||
@@ -80,10 +268,18 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
});
|
||||
|
||||
startDrag(emailsToDrag, sourceMailboxId);
|
||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails]);
|
||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
endDrag();
|
||||
// Defer-revoke the per-row single .eml URL. The shared bundle URL stays
|
||||
// cached until the selection changes - revoking it here would break a
|
||||
// subsequent drag of the same selection.
|
||||
if (singleBlobUrlRef.current) {
|
||||
const url = singleBlobUrlRef.current;
|
||||
singleBlobUrlRef.current = null;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
}, [endDrag]);
|
||||
|
||||
// Check if this specific email is being dragged
|
||||
@@ -94,6 +290,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} }
|
||||
: {
|
||||
draggable: true,
|
||||
onPointerEnter: dragOutEnabled ? handlePointerEnter : undefined,
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: handleDragEnd,
|
||||
},
|
||||
|
||||
@@ -289,6 +289,9 @@ export const KEYBOARD_SHORTCUTS = {
|
||||
{ key: "x", description: "shortcuts.threads.expand_collapse" },
|
||||
],
|
||||
composer: [
|
||||
{ key: "Ctrl + Enter", description: "shortcuts.composer.send" },
|
||||
{ key: "Ctrl + Shift + Enter", description: "shortcuts.composer.schedule_send" },
|
||||
{ key: "t", description: "shortcuts.composer.template_picker" },
|
||||
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
+49
-20
@@ -21,6 +21,27 @@ function resolveSourceAccountId(email: Email | undefined): string | null {
|
||||
return useAuthStore.getState().activeAccountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local accountId ("user@host") that owns the destination
|
||||
* mailbox. `mailbox.accountId` is the JMAP server's opaque account id, but
|
||||
* `clients`, `activeAccountId`, and `email.accountId` all live in the local
|
||||
* namespace. We map back by matching the JMAP id against each connected
|
||||
* client's `getAccountId()`. Falls back to the viewing/active account so
|
||||
* single-account flows (no connected clients map entry yet, in-memory edits,
|
||||
* etc.) still resolve correctly.
|
||||
*/
|
||||
function resolveDestAccountId(mailbox: Mailbox): string | null {
|
||||
const jmapId = mailbox.accountId;
|
||||
if (jmapId) {
|
||||
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||
for (const [localId, client] of clients) {
|
||||
if (client.getAccountId() === jmapId) return localId;
|
||||
}
|
||||
}
|
||||
return useEmailStore.getState().viewingAccountId
|
||||
?? useAuthStore.getState().activeAccountId;
|
||||
}
|
||||
|
||||
interface UseMailboxDropOptions {
|
||||
mailbox: Mailbox;
|
||||
onDropComplete?: () => void;
|
||||
@@ -60,20 +81,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
// Virtual nodes (shared folder headers) cannot be drop targets
|
||||
if (mailbox.id.startsWith("shared-")) return false;
|
||||
|
||||
// Shared (delegated) mailboxes still require the source to belong to the
|
||||
// same delegating account. Real cross-account moves between primary
|
||||
// accounts go through the cross-account path further down, but the
|
||||
// shared-folder semantics here are about ACLs rather than transport, so
|
||||
// they remain disallowed.
|
||||
if (mailbox.isShared && draggedEmails[0]) {
|
||||
const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
|
||||
if (sourceMb?.accountId !== mailbox.accountId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
|
||||
}, [isDragging, mailbox, sourceMailboxId]);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -123,7 +132,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
// Group dragged emails by source account. In single-account flows this
|
||||
// collapses to one bucket; in unified view or the Pro multi-account
|
||||
// sidebar a single drag can mix sources.
|
||||
const destAccountId = mailbox.accountId;
|
||||
const destAccountId = resolveDestAccountId(mailbox);
|
||||
const idToEmail = new Map(draggedEmails.map((em) => [em.id, em]));
|
||||
const bySource = new Map<string, string[]>();
|
||||
for (const id of emailIds) {
|
||||
@@ -133,18 +142,38 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
bySource.get(srcAccountId)!.push(id);
|
||||
}
|
||||
|
||||
// Decide whether to route via the cross-account (blob copy + import)
|
||||
// pipeline. Two cases require it:
|
||||
// 1. Destination is a delegated/shared mailbox whose owner JMAP
|
||||
// account differs from the source mailbox's JMAP account. There's
|
||||
// no atomic Email/set across accounts, even via the same client.
|
||||
// 2. Destination is a primary mailbox on a different connected local
|
||||
// account than the source — the historical multi-account case.
|
||||
const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
|
||||
const sourceJmapAccountId = sourceMb?.accountId;
|
||||
const destJmapAccountId = mailbox.accountId;
|
||||
const sourceAccountIds = Array.from(bySource.keys());
|
||||
const isCrossAccount =
|
||||
!!destAccountId &&
|
||||
|
||||
const isJmapCrossAccount =
|
||||
!!sourceJmapAccountId &&
|
||||
!!destJmapAccountId &&
|
||||
sourceJmapAccountId !== destJmapAccountId;
|
||||
const isLocalCrossAccount =
|
||||
!mailbox.isShared &&
|
||||
!!destAccountId &&
|
||||
sourceAccountIds.some((src) => src !== destAccountId);
|
||||
const isCrossAccount = isJmapCrossAccount || isLocalCrossAccount;
|
||||
|
||||
if (isCrossAccount) {
|
||||
// JMAP can't natively move an email between primary accounts, so the
|
||||
// store reuploads each source blob into the destination account and
|
||||
// then deletes the original.
|
||||
if (!destAccountId) {
|
||||
throw new Error('Could not resolve destination account');
|
||||
}
|
||||
// For a shared destination there is no separately-connected client
|
||||
// for the owner; we reuse the viewing user's client but tell the
|
||||
// import call to target the owner's JMAP account.
|
||||
const jmapDestId = mailbox.originalId || mailbox.id;
|
||||
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId);
|
||||
const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
|
||||
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride);
|
||||
} else {
|
||||
// Single-account or same-account-shared move: bulk JMAP request.
|
||||
await moveEmailsToMailbox(client, emailIds, mailbox.id);
|
||||
|
||||
+14
-2
@@ -12,9 +12,21 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'always'
|
||||
| 'as-needed';
|
||||
|
||||
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const;
|
||||
|
||||
// Fallback locale used when the visitor's Accept-Language header does not
|
||||
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
|
||||
// set this via NEXT_PUBLIC_DEFAULT_LOCALE at build time to localise greenfield
|
||||
// deployments without having every user change their preference manually.
|
||||
const envDefaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE?.trim();
|
||||
const resolvedDefaultLocale =
|
||||
envDefaultLocale && (SUPPORTED_LOCALES as readonly string[]).includes(envDefaultLocale)
|
||||
? (envDefaultLocale as (typeof SUPPORTED_LOCALES)[number])
|
||||
: 'en';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
|
||||
defaultLocale: 'en',
|
||||
locales: SUPPORTED_LOCALES,
|
||||
defaultLocale: resolvedDefaultLocale,
|
||||
localePrefix
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { plainTextToComposerBody } from "../email-composer-utils";
|
||||
import {
|
||||
plainTextToComposerBody,
|
||||
rewriteCidImagesForEditor,
|
||||
replaceInlineImagePlaceholders,
|
||||
INLINE_IMAGE_PLACEHOLDER,
|
||||
} from "../email-composer-utils";
|
||||
|
||||
describe("plainTextToComposerBody", () => {
|
||||
it("returns an empty string for empty input", () => {
|
||||
@@ -24,3 +29,86 @@ describe("plainTextToComposerBody", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteCidImagesForEditor", () => {
|
||||
it("returns input unchanged when no cid: refs are present", () => {
|
||||
const html = '<p>hi</p><img src="https://example.com/x.png">';
|
||||
expect(rewriteCidImagesForEditor(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(rewriteCidImagesForEditor("")).toBe("");
|
||||
});
|
||||
|
||||
it("rewrites a cid: src to placeholder + data-cid", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="cid:abc@x" alt="logo">'
|
||||
);
|
||||
expect(out).toContain('data-cid="abc@x"');
|
||||
expect(out).toContain(`src="${INLINE_IMAGE_PLACEHOLDER}"`);
|
||||
expect(out).toContain('alt="logo"');
|
||||
expect(out).not.toContain('src="cid:');
|
||||
});
|
||||
|
||||
it("preserves an existing data-cid attribute", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="cid:abc" data-cid="kept">'
|
||||
);
|
||||
expect(out).toContain('data-cid="kept"');
|
||||
expect(out).not.toContain('data-cid="abc"');
|
||||
});
|
||||
|
||||
it("leaves non-cid images alone", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="https://example.com/x.png"><img src="cid:y">'
|
||||
);
|
||||
expect(out).toContain('src="https://example.com/x.png"');
|
||||
expect(out).toContain('data-cid="y"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceInlineImagePlaceholders", () => {
|
||||
it("returns input unchanged when the map is empty", () => {
|
||||
const html = '<img src="..." data-cid="x">';
|
||||
expect(replaceInlineImagePlaceholders(html, new Map())).toBe(html);
|
||||
});
|
||||
|
||||
it("swaps the placeholder src to the data URL for matching cids", () => {
|
||||
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="abc">`;
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="data:image/png;base64,AAAA"');
|
||||
expect(out).toContain('data-cid="abc"');
|
||||
});
|
||||
|
||||
it("also rewrites raw cid: src refs that lack a placeholder", () => {
|
||||
const html = '<img src="cid:abc" data-cid="abc">';
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="data:image/png;base64,AAAA"');
|
||||
});
|
||||
|
||||
it("does not overwrite images the user has re-pointed away from the cid", () => {
|
||||
const html =
|
||||
'<img src="https://example.com/other.png" data-cid="abc">';
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="https://example.com/other.png"');
|
||||
expect(out).not.toContain("data:image/png;base64,AAAA");
|
||||
});
|
||||
|
||||
it("leaves unknown cids untouched", () => {
|
||||
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="missing">`;
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,34 @@ function createClient(): JMAPClient {
|
||||
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 {
|
||||
0: string;
|
||||
1: Record<string, unknown>;
|
||||
@@ -62,7 +90,7 @@ function mockSendEmailFlow() {
|
||||
payload = {
|
||||
methodResponses: [
|
||||
['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.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' } } })]),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,8 @@ function buildMailboxPathMap(tree: MailboxNode[]): Map<string, string> {
|
||||
const pathMap = new Map<string, string>();
|
||||
const walk = (nodes: MailboxNode[], parentPath = '') => {
|
||||
for (const node of nodes) {
|
||||
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||
const segment = node.role === 'inbox' ? 'INBOX' : node.name;
|
||||
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
|
||||
pathMap.set(node.id, fullPath);
|
||||
if (node.children.length > 0) walk(node.children, fullPath);
|
||||
}
|
||||
@@ -47,7 +48,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
const mailboxes = [makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' })];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('inbox')).toBe('Inbox');
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
});
|
||||
|
||||
it('should produce correct path for a single-level subfolder', () => {
|
||||
@@ -57,7 +58,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('sub1')).toBe('Inbox/Projects');
|
||||
expect(paths.get('sub1')).toBe('INBOX/Projects');
|
||||
});
|
||||
|
||||
it('should produce correct path for deeply nested subfolders', () => {
|
||||
@@ -68,7 +69,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('sub2')).toBe('Inbox/Test/Test2');
|
||||
expect(paths.get('sub2')).toBe('INBOX/Test/Test2');
|
||||
});
|
||||
|
||||
it('should handle multiple root-level folders', () => {
|
||||
@@ -79,7 +80,7 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('inbox')).toBe('Inbox');
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
expect(paths.get('archive')).toBe('Archive');
|
||||
expect(paths.get('sub1')).toBe('Archive/Work');
|
||||
});
|
||||
@@ -99,9 +100,25 @@ describe('mailbox path building for sieve fileinto', () => {
|
||||
expect(paths.has(node.id)).toBe(true);
|
||||
}
|
||||
|
||||
expect(paths.get('inbox')).toBe('Inbox');
|
||||
expect(paths.get('sub1')).toBe('Inbox/Projects');
|
||||
expect(paths.get('sub2')).toBe('Inbox/Projects/Active');
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
expect(paths.get('sub1')).toBe('INBOX/Projects');
|
||||
expect(paths.get('sub2')).toBe('INBOX/Projects/Active');
|
||||
});
|
||||
|
||||
it('uses canonical INBOX even when JMAP returns a localized inbox name', () => {
|
||||
// Stalwart returns localized display names for the inbox based on the
|
||||
// user's locale (e.g. "Entrada" for pt-BR). Sieve fileinto must still
|
||||
// target the IMAP-canonical "INBOX" so the message is filed correctly.
|
||||
const mailboxes = [
|
||||
makeMailbox({ id: 'inbox', name: 'Entrada', role: 'inbox' }),
|
||||
makeMailbox({ id: 'host', name: 'Host', parentId: 'inbox' }),
|
||||
makeMailbox({ id: 'eveo', name: 'EVEO', parentId: 'host' }),
|
||||
];
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const paths = buildMailboxPathMap(tree);
|
||||
expect(paths.get('inbox')).toBe('INBOX');
|
||||
expect(paths.get('host')).toBe('INBOX/Host');
|
||||
expect(paths.get('eveo')).toBe('INBOX/Host/EVEO');
|
||||
});
|
||||
|
||||
it('should preserve depth info in flattened tree', () => {
|
||||
|
||||
@@ -146,6 +146,23 @@ describe('oauth/discovery', () => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts private/loopback endpoints when validateEndpoint is omitted (admin opted in)', async () => {
|
||||
// Split-DNS deployments: mail.example.com resolves to an RFC-1918 address
|
||||
// locally. With the SSRF validator off, discovery must succeed.
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
issuer: 'https://mail.example.com',
|
||||
authorization_endpoint: 'http://10.0.0.5/authorize',
|
||||
token_endpoint: 'http://10.0.0.5/token',
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await discoverOAuth('https://mail.example.com');
|
||||
|
||||
expect(result?.token_endpoint).toBe('http://10.0.0.5/token');
|
||||
});
|
||||
|
||||
it('caches results - second call for same server URL does not re-fetch', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -153,6 +153,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
|
||||
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' },
|
||||
oauthAllowPrivateEndpoints: { envVar: 'OAUTH_ALLOW_PRIVATE_ENDPOINTS', type: 'boolean', defaultValue: false },
|
||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
|
||||
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
|
||||
|
||||
@@ -75,6 +75,26 @@ export function apiFetch(input: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount-prefix-aware wrapper for URL strings used in `<img src>`, `<link href>`,
|
||||
* `window.location.*`, etc. — anything the browser resolves itself, where
|
||||
* `apiFetch` can't help.
|
||||
*
|
||||
* Idempotent: passing an already-prefixed value, an external URL, a
|
||||
* protocol-relative URL, or an empty/falsy value returns it unchanged. So it's
|
||||
* safe to wrap admin-configurable values that might be either a local path
|
||||
* (`/branding/foo.svg`, `/api/admin/branding/...`) or a full URL.
|
||||
*/
|
||||
export function withBasePath(url: string | null | undefined): string {
|
||||
if (!url) return url ?? '';
|
||||
if (url.charCodeAt(0) !== 47) return url; // not absolute (e.g. https://, data:, blob:)
|
||||
if (url.charCodeAt(1) === 47) return url; // protocol-relative //cdn...
|
||||
const prefix = getPathPrefix();
|
||||
if (!prefix) return url;
|
||||
if (url === prefix || url.startsWith(prefix + '/')) return url;
|
||||
return prefix + url;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extracts the locale from the current URL, skipping any mount prefix.
|
||||
|
||||
+10
-3
@@ -24,8 +24,14 @@ export interface TimedEventLayout {
|
||||
export function getEventStartDate(
|
||||
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
|
||||
): Date {
|
||||
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
|
||||
return parseISO(source);
|
||||
// Prefer utcStart for timed events but fall back to start if utcStart is
|
||||
// missing or unparseable - a malformed utcStart used to surface as an
|
||||
// Invalid Date that crashed downstream format() calls (#316).
|
||||
if (!event.showWithoutTime && event.utcStart) {
|
||||
const utc = parseISO(event.utcStart);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
return parseISO(event.start);
|
||||
}
|
||||
|
||||
export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
|
||||
@@ -56,7 +62,8 @@ export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWe
|
||||
|
||||
export function getEventEndDate(event: CalendarEvent): Date {
|
||||
if (!event.showWithoutTime && event.utcEnd) {
|
||||
return parseISO(event.utcEnd);
|
||||
const utc = parseISO(event.utcEnd);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
|
||||
const start = getEventStartDate(event);
|
||||
|
||||
+119
-14
@@ -1,5 +1,5 @@
|
||||
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 { getDemoData, type DemoData } from './demo-data';
|
||||
import { generateDemoId } from './demo-utils';
|
||||
@@ -11,6 +11,7 @@ import { generateDemoId } from './demo-utils';
|
||||
export class DemoJMAPClient implements IJMAPClient {
|
||||
private data: DemoData;
|
||||
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 stateChangeCallback: ((change: StateChange) => void) | null = null;
|
||||
private lastStates: AccountStates = {};
|
||||
@@ -49,15 +50,15 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
// ── Capabilities ──────────────────────────────────────────────
|
||||
|
||||
hasAccountCapability(_capability: string, _accountId?: string): boolean {
|
||||
return false;
|
||||
hasAccountCapability(capability: string, _accountId?: string): boolean {
|
||||
return capability === 'urn:ietf:params:jmap:submission';
|
||||
}
|
||||
|
||||
getCapabilities(): Record<string, unknown> {
|
||||
return {
|
||||
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
|
||||
'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:contacts': {},
|
||||
'urn:ietf:params:jmap:calendars': {},
|
||||
@@ -70,6 +71,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
getMaxSizeUpload(): number { return 50_000_000; }
|
||||
getMaxCallsInRequest(): number { return 16; }
|
||||
getMaxObjectsInGet(): number { return 500; }
|
||||
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
|
||||
hasDelayedSend(): boolean { return true; }
|
||||
getEventSourceUrl(): string | null { return null; }
|
||||
supportsEmailSubmission(): boolean { return true; }
|
||||
supportsQuota(): boolean { return true; }
|
||||
@@ -264,10 +267,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async moveToTrash(emailId: string, trashMailboxId: string): Promise<void> {
|
||||
async moveToTrash(emailId: string, trashMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
email.mailboxIds = { [trashMailboxId]: true };
|
||||
if (markAsRead) email.keywords.$seen = true;
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
@@ -277,10 +281,13 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
|
||||
async batchMoveEmails(emailIds: string[], toMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
for (const id of emailIds) {
|
||||
const email = this.data.emails.find(e => e.id === id);
|
||||
if (email) email.mailboxIds = { [toMailboxId]: true };
|
||||
if (email) {
|
||||
email.mailboxIds = { [toMailboxId]: true };
|
||||
if (markAsRead) email.keywords.$seen = true;
|
||||
}
|
||||
}
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
@@ -289,6 +296,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
mode: 'single' | 'year' | 'month',
|
||||
_existingMailboxes: Mailbox[],
|
||||
_accountId?: string,
|
||||
): Promise<void> {
|
||||
if (emails.length === 0) return;
|
||||
if (mode === 'single') {
|
||||
@@ -355,10 +364,13 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return count;
|
||||
}
|
||||
|
||||
async markAsSpam(emailId: string): Promise<void> {
|
||||
async markAsSpam(emailId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
|
||||
if (email && junkMb) email.mailboxIds = { [junkMb.id]: true };
|
||||
if (email && junkMb) {
|
||||
email.mailboxIds = { [junkMb.id]: true };
|
||||
if (markAsRead) email.keywords.$seen = true;
|
||||
}
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
@@ -449,7 +461,9 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
inReplyTo?: string[],
|
||||
references?: string[],
|
||||
): Promise<void> {
|
||||
delayedUntil?: string,
|
||||
_envelopeMailFrom?: string,
|
||||
): Promise<SendEmailResult> {
|
||||
// Remove draft if updating
|
||||
if (draftId) {
|
||||
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
|
||||
@@ -457,8 +471,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
|
||||
const email: Email = {
|
||||
id: generateDemoId('email'), threadId: generateDemoId('thread'),
|
||||
mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true },
|
||||
keywords: { $seen: true },
|
||||
mailboxIds: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: true },
|
||||
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
|
||||
size: body.length + (htmlBody?.length || 0),
|
||||
receivedAt: new Date().toISOString(),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
@@ -478,7 +492,22 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
references: references?.length ? references : undefined,
|
||||
};
|
||||
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();
|
||||
return delayedUntil
|
||||
? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt: delayedUntil }
|
||||
: { scheduled: false, emailId: email.id };
|
||||
}
|
||||
|
||||
async sendImipReply(): Promise<void> { /* no-op in demo */ }
|
||||
@@ -487,9 +516,17 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
// ── Blobs ─────────────────────────────────────────────────────
|
||||
|
||||
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
||||
async uploadBlob(
|
||||
file: File,
|
||||
opts?: { onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal },
|
||||
): Promise<{ blobId: string; size: number; type: string }> {
|
||||
if (opts?.signal?.aborted) {
|
||||
throw new DOMException('Upload aborted', 'AbortError');
|
||||
}
|
||||
opts?.onProgress?.(0, file.size);
|
||||
const blobId = generateDemoId('blob');
|
||||
this.blobStore.set(blobId, file);
|
||||
opts?.onProgress?.(file.size, file.size);
|
||||
return { blobId, size: file.size, type: file.type };
|
||||
}
|
||||
|
||||
@@ -903,7 +940,75 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
|
||||
// Allow any Unicode letter or digit (so umlauts, accents, CJK survive) plus a
|
||||
// small set of safe punctuation. Everything else - emojis, RTL/zero-width
|
||||
// marks, control chars, and the filesystem-reserved `<>:"/\|?*` - collapses
|
||||
// to `_`. Keeps filenames usable across Windows/macOS/Linux without flattening
|
||||
// non-ASCII scripts.
|
||||
const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu;
|
||||
|
||||
export type SpaceReplacement = "keep" | "underscore" | "dash";
|
||||
|
||||
export interface FilenameTransformOptions {
|
||||
spaceReplacement?: SpaceReplacement;
|
||||
lowercase?: boolean;
|
||||
stripDiacritics?: boolean;
|
||||
collapseSeparators?: boolean;
|
||||
}
|
||||
|
||||
export interface EmailFilenameOptions extends FilenameTransformOptions {
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TRANSFORM: Required<FilenameTransformOptions> = {
|
||||
spaceReplacement: "keep",
|
||||
lowercase: false,
|
||||
stripDiacritics: false,
|
||||
collapseSeparators: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}";
|
||||
export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}";
|
||||
export const DEFAULT_BUNDLE_TEMPLATE = "emails-{count}";
|
||||
|
||||
export const EMAIL_TOKENS: { token: string; description: string }[] = [
|
||||
{ token: "date", description: "Full date and time, e.g. 2026-05-22 14.05.33" },
|
||||
{ token: "date_short", description: "Date only, e.g. 2026-05-22" },
|
||||
{ token: "time", description: "Time only, e.g. 14.05.33" },
|
||||
{ token: "year", description: "4-digit year" },
|
||||
{ token: "month", description: "2-digit month" },
|
||||
{ token: "day", description: "2-digit day" },
|
||||
{ token: "from", description: "Sender display name (falls back to email user part)" },
|
||||
{ token: "from_email", description: "Sender full email address" },
|
||||
{ token: "from_name", description: "Sender name only" },
|
||||
{ token: "to", description: "First recipient display name" },
|
||||
{ token: "to_email", description: "First recipient full email address" },
|
||||
{ token: "to_name", description: "First recipient name only" },
|
||||
{ token: "subject", description: "Email subject" },
|
||||
];
|
||||
|
||||
export const ATTACHMENT_TOKENS: { token: string; description: string }[] = [
|
||||
...EMAIL_TOKENS,
|
||||
{ token: "filename", description: "Original attachment filename including extension" },
|
||||
{ token: "name", description: "Attachment filename without extension" },
|
||||
{ token: "ext", description: "Attachment file extension without leading dot" },
|
||||
];
|
||||
|
||||
export const BUNDLE_TOKENS: { token: string; description: string }[] = [
|
||||
{ token: "count", description: "Number of emails in the bundle" },
|
||||
{ token: "date", description: "Current date and time, e.g. 2026-05-22 14.05.33" },
|
||||
{ token: "date_short", description: "Current date, e.g. 2026-05-22" },
|
||||
{ token: "time", description: "Current time, e.g. 14.05.33" },
|
||||
{ token: "year", description: "4-digit year" },
|
||||
{ token: "month", description: "2-digit month" },
|
||||
{ token: "day", description: "2-digit day" },
|
||||
];
|
||||
|
||||
function sanitizePart(input: string, maxLen = 80): string {
|
||||
const cleaned = input
|
||||
.replace(SAFE_CHARS, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/^[._-]+|[._-]+$/g, "");
|
||||
return cleaned.slice(0, maxLen);
|
||||
}
|
||||
|
||||
function applyTransforms(input: string, opts: FilenameTransformOptions): string {
|
||||
let s = input;
|
||||
if (opts.stripDiacritics) {
|
||||
// NFD splits "ä" into "a" + U+0308 (combining diaeresis); stripping all
|
||||
// combining marks then leaves plain ASCII letters. `ß` has no
|
||||
// decomposition so it survives as-is.
|
||||
s = s.normalize("NFD").replace(/\p{M}+/gu, "");
|
||||
}
|
||||
const repl = opts.spaceReplacement ?? "keep";
|
||||
if (repl === "underscore") s = s.replace(/ +/g, "_");
|
||||
else if (repl === "dash") s = s.replace(/ +/g, "-");
|
||||
if (opts.collapseSeparators ?? true) {
|
||||
s = s.replace(/_+/g, "_").replace(/-+/g, "-").replace(/ +/g, " ");
|
||||
}
|
||||
if (opts.lowercase) s = s.toLocaleLowerCase();
|
||||
return s.replace(/^[._\- ]+|[._\- ]+$/g, "");
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function dateParts(iso: string | null | undefined) {
|
||||
const d = iso ? new Date(iso) : new Date();
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return {
|
||||
date: "0000-00-00 00.00.00",
|
||||
date_short: "0000-00-00",
|
||||
time: "00.00.00",
|
||||
year: "0000",
|
||||
month: "00",
|
||||
day: "00",
|
||||
};
|
||||
}
|
||||
const year = String(d.getFullYear());
|
||||
const month = pad2(d.getMonth() + 1);
|
||||
const day = pad2(d.getDate());
|
||||
const time = `${pad2(d.getHours())}.${pad2(d.getMinutes())}.${pad2(d.getSeconds())}`;
|
||||
return {
|
||||
date: `${year}-${month}-${day} ${time}`,
|
||||
date_short: `${year}-${month}-${day}`,
|
||||
time,
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
};
|
||||
}
|
||||
|
||||
function addrLabel(addr: { name?: string | null; email: string } | undefined): {
|
||||
name: string;
|
||||
email: string;
|
||||
label: string;
|
||||
} {
|
||||
if (!addr) return { name: "", email: "", label: "unknown" };
|
||||
const name = (addr.name && addr.name.trim()) || "";
|
||||
const email = addr.email || "";
|
||||
const label = name || email.split("@")[0] || email || "unknown";
|
||||
return { name, email, label };
|
||||
}
|
||||
|
||||
export function emailVars(email: Email): Record<string, string> {
|
||||
const dp = dateParts(email.receivedAt || email.sentAt);
|
||||
const from = addrLabel(email.from?.[0]);
|
||||
const to = addrLabel(email.to?.[0]);
|
||||
return {
|
||||
...dp,
|
||||
from: from.label,
|
||||
from_email: from.email,
|
||||
from_name: from.name,
|
||||
to: to.label,
|
||||
to_email: to.email,
|
||||
to_name: to.name,
|
||||
subject: email.subject || "no subject",
|
||||
};
|
||||
}
|
||||
|
||||
export interface AttachmentLike {
|
||||
name?: string | null;
|
||||
type?: string | null;
|
||||
}
|
||||
|
||||
export function attachmentVars(email: Email, attachment: AttachmentLike): Record<string, string> {
|
||||
const filename = (attachment.name || "attachment").trim();
|
||||
const dot = filename.lastIndexOf(".");
|
||||
const hasExt = dot > 0 && dot < filename.length - 1;
|
||||
const name = hasExt ? filename.slice(0, dot) : filename;
|
||||
const ext = hasExt ? filename.slice(dot + 1) : "";
|
||||
return {
|
||||
...emailVars(email),
|
||||
filename,
|
||||
name,
|
||||
ext,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRaw(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined) return "";
|
||||
return sanitizePart(value);
|
||||
});
|
||||
}
|
||||
|
||||
export function emailExportFilename(
|
||||
email: Email,
|
||||
options: EmailFilenameOptions | string = {},
|
||||
): string {
|
||||
const opts = typeof options === "string" ? { template: options } : options;
|
||||
const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE;
|
||||
const rendered = renderRaw(template, emailVars(email));
|
||||
const cleaned = sanitizePart(rendered, 200);
|
||||
const transformed = applyTransforms(cleaned, opts);
|
||||
const stem = transformed.slice(0, 200) || "email";
|
||||
return `${stem}.eml`;
|
||||
}
|
||||
|
||||
export function attachmentDownloadFilename(
|
||||
email: Email | null | undefined,
|
||||
attachment: AttachmentLike,
|
||||
options: EmailFilenameOptions | string = {},
|
||||
): string {
|
||||
const opts = typeof options === "string" ? { template: options } : options;
|
||||
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
|
||||
if (!email) {
|
||||
const filename = (attachment.name || "attachment").trim();
|
||||
const cleaned = sanitizePart(filename, 200) || "attachment";
|
||||
return applyTransforms(cleaned, opts) || cleaned;
|
||||
}
|
||||
const vars = attachmentVars(email, attachment);
|
||||
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined) return "";
|
||||
// Preserve dots in {filename} so the original extension survives the
|
||||
// sanitiser (it strips trailing dots otherwise).
|
||||
return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value);
|
||||
});
|
||||
const templateMentionsExt = /\{(ext|filename)\}/.test(template);
|
||||
const cleaned = sanitizePart(rendered, 200) || "attachment";
|
||||
if (templateMentionsExt) {
|
||||
return applyTransforms(cleaned, opts) || cleaned;
|
||||
}
|
||||
const transformedStem = applyTransforms(cleaned, opts) || cleaned;
|
||||
const ext = vars.ext;
|
||||
if (!ext) return transformedStem;
|
||||
const transformedExt = opts.lowercase ? ext.toLocaleLowerCase() : ext;
|
||||
return `${transformedStem}.${transformedExt}`;
|
||||
}
|
||||
|
||||
export function bundleVars(count: number, iso?: string): Record<string, string> {
|
||||
const dp = dateParts(iso ?? new Date().toISOString());
|
||||
return { ...dp, count: String(count) };
|
||||
}
|
||||
|
||||
export function bundleExportFilename(
|
||||
count: number,
|
||||
options: EmailFilenameOptions | string = {},
|
||||
iso?: string,
|
||||
): string {
|
||||
const opts = typeof options === "string" ? { template: options } : options;
|
||||
const template = opts.template ?? DEFAULT_BUNDLE_TEMPLATE;
|
||||
const rendered = renderRaw(template, bundleVars(count, iso));
|
||||
const cleaned = sanitizePart(rendered, 200);
|
||||
const transformed = applyTransforms(cleaned, opts);
|
||||
const stem = transformed.slice(0, 200) || "emails";
|
||||
return `${stem}.zip`;
|
||||
}
|
||||
|
||||
// Build a synthetic email for previewing templates in the settings UI.
|
||||
export function buildSampleEmail(): Email {
|
||||
// Use a fixed date so the preview doesn't churn as the user types.
|
||||
const iso = "2026-05-22T14:05:33Z";
|
||||
return {
|
||||
id: "sample-1",
|
||||
threadId: "sample-thread-1",
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 12345,
|
||||
receivedAt: iso,
|
||||
sentAt: iso,
|
||||
from: [{ name: "Alice Sender", email: "alice@example.com" }],
|
||||
to: [{ name: "Bob Recipient", email: "bob@example.com" }],
|
||||
cc: [],
|
||||
subject: "Benachrichtigung von Ihrem Gerät",
|
||||
preview: "",
|
||||
hasAttachment: true,
|
||||
blobId: "sample-blob",
|
||||
};
|
||||
}
|
||||
@@ -21,3 +21,59 @@ export function plainTextToComposerBody(text: string): string {
|
||||
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Transparent 1x1 GIF used as a stand-in src while the real inline image is
|
||||
// being fetched from JMAP. Browsers cannot render `cid:` URLs directly, so
|
||||
// without this swap the editor would show a broken-image icon (issue #163).
|
||||
export const INLINE_IMAGE_PLACEHOLDER =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
|
||||
/**
|
||||
* Rewrites `<img src="cid:xxx">` references into `<img src="<placeholder>" data-cid="xxx">`
|
||||
* so TipTap can render the editor (the original cid: URL would 404) while still
|
||||
* carrying the cid through edits. The placeholder is swapped to the actual
|
||||
* image data once the corresponding inline blob has been fetched.
|
||||
*/
|
||||
export function rewriteCidImagesForEditor(html: string): string {
|
||||
if (!html || html.indexOf("cid:") === -1) return html;
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
||||
let touched = false;
|
||||
doc.querySelectorAll("img").forEach((img) => {
|
||||
const src = img.getAttribute("src") || "";
|
||||
if (!/^cid:/i.test(src)) return;
|
||||
const cid = src.slice(4);
|
||||
if (!cid) return;
|
||||
if (!img.getAttribute("data-cid")) {
|
||||
img.setAttribute("data-cid", cid);
|
||||
}
|
||||
img.setAttribute("src", INLINE_IMAGE_PLACEHOLDER);
|
||||
touched = true;
|
||||
});
|
||||
return touched ? doc.body.innerHTML : html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the placeholder src on `<img data-cid="...">` elements with the
|
||||
* resolved data URL once the inline blob has been fetched. Leaves images
|
||||
* whose src has been edited away from the placeholder/cid alone.
|
||||
*/
|
||||
export function replaceInlineImagePlaceholders(
|
||||
html: string,
|
||||
cidToDataUrl: Map<string, string>
|
||||
): string {
|
||||
if (!html || cidToDataUrl.size === 0) return html;
|
||||
if (html.indexOf("data-cid") === -1) return html;
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
||||
let changed = false;
|
||||
doc.querySelectorAll("img[data-cid]").forEach((img) => {
|
||||
const cid = img.getAttribute("data-cid");
|
||||
if (!cid) return;
|
||||
const dataUrl = cidToDataUrl.get(cid);
|
||||
if (!dataUrl) return;
|
||||
const currentSrc = img.getAttribute("src") || "";
|
||||
if (currentSrc !== INLINE_IMAGE_PLACEHOLDER && !/^cid:/i.test(currentSrc)) return;
|
||||
img.setAttribute("src", dataUrl);
|
||||
changed = true;
|
||||
});
|
||||
return changed ? doc.body.innerHTML : html;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface ImportableEmail {
|
||||
name: string;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
const EMAIL_MIME = "message/rfc822";
|
||||
|
||||
function isEmlName(name: string): boolean {
|
||||
return /\.eml$/i.test(name);
|
||||
}
|
||||
|
||||
function isZipName(name: string): boolean {
|
||||
return /\.zip$/i.test(name);
|
||||
}
|
||||
|
||||
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
|
||||
const { default: JSZip } = await import("jszip");
|
||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||
const out: ImportableEmail[] = [];
|
||||
const entries = Object.values(zip.files);
|
||||
for (const entry of entries) {
|
||||
if (entry.dir) continue;
|
||||
if (!isEmlName(entry.name)) continue;
|
||||
const data = await entry.async("arraybuffer");
|
||||
out.push({
|
||||
name: entry.name.split(/[\\/]/).pop() || entry.name,
|
||||
blob: new Blob([data], { type: EMAIL_MIME }),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function expandImportableEmails(
|
||||
files: File[],
|
||||
): Promise<ImportableEmail[]> {
|
||||
const out: ImportableEmail[] = [];
|
||||
for (const file of files) {
|
||||
if (isZipName(file.name) || file.type === "application/zip") {
|
||||
out.push(...(await extractEmlsFromZip(file)));
|
||||
continue;
|
||||
}
|
||||
const blob = new Blob([await file.arrayBuffer()], { type: EMAIL_MIME });
|
||||
out.push({ name: file.name, blob });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const EML_IMPORT_ACCEPT = ".eml,.zip,message/rfc822,application/zip";
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
@@ -31,6 +31,8 @@ export interface IJMAPClient {
|
||||
getMaxSizeUpload(): number;
|
||||
getMaxCallsInRequest(): number;
|
||||
getMaxObjectsInGet(): number;
|
||||
getMaxDelayedSend(accountId?: string): number;
|
||||
hasDelayedSend(accountId?: string): boolean;
|
||||
getEventSourceUrl(): string | null;
|
||||
supportsEmailSubmission(): boolean;
|
||||
supportsQuota(): boolean;
|
||||
@@ -96,9 +98,9 @@ export interface IJMAPClient {
|
||||
setKeyword(emailId: string, keyword: string): Promise<void>;
|
||||
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
||||
deleteEmail(emailId: string): Promise<void>;
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>;
|
||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
batchArchiveEmails(
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
@@ -110,7 +112,7 @@ export interface IJMAPClient {
|
||||
emptyMailbox(mailboxId: string): Promise<number>;
|
||||
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
|
||||
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
|
||||
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
||||
markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
|
||||
|
||||
// ── Threads ───────────────────────────────────────────────────
|
||||
@@ -146,8 +148,15 @@ export interface IJMAPClient {
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
inReplyTo?: string[],
|
||||
references?: string[],
|
||||
delayedUntil?: 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: {
|
||||
organizerEmail: string;
|
||||
@@ -169,7 +178,16 @@ export interface IJMAPClient {
|
||||
sendImipCancellation(event: CalendarEvent): Promise<void>;
|
||||
|
||||
// ── Blobs ─────────────────────────────────────────────────────
|
||||
uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }>;
|
||||
uploadBlob(
|
||||
file: File,
|
||||
optsOrAccountId?:
|
||||
| string
|
||||
| {
|
||||
accountId?: string;
|
||||
onProgress?: (loaded: number, total: number) => void;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<{ blobId: string; size: number; type: string }>;
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string;
|
||||
fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>;
|
||||
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
|
||||
@@ -274,7 +292,6 @@ export interface IJMAPClient {
|
||||
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
||||
|
||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>): Promise<string>;
|
||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
||||
submitEmail(emailId: string, identityId: string): Promise<void>;
|
||||
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
|
||||
}
|
||||
|
||||
+509
-49
@@ -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 { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -61,6 +61,12 @@ interface JMAPEmailHeader {
|
||||
|
||||
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
|
||||
type JMAPResponseResult = Record<string, any>;
|
||||
|
||||
@@ -93,6 +99,8 @@ const EMAIL_LIST_PROPERTIES = [
|
||||
"subject",
|
||||
"preview",
|
||||
"hasAttachment",
|
||||
// Needed so list rows can serve drag-out to the file system as .eml.
|
||||
"blobId",
|
||||
] as const;
|
||||
|
||||
// Stalwart's default property list for Calendar/get omits shareWith, isVisible,
|
||||
@@ -310,6 +318,27 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
|
||||
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.
|
||||
* Lines longer than 75 octets MUST be split with CRLF + a single linear white space character.
|
||||
@@ -345,6 +374,33 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string {
|
||||
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 {
|
||||
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
||||
|
||||
@@ -701,7 +757,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -1224,16 +1280,14 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void> {
|
||||
async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const patch: Record<string, unknown> = { mailboxIds: { [trashMailboxId]: true } };
|
||||
if (markAsRead) patch["keywords/$seen"] = true;
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: targetAccountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
mailboxIds: { [trashMailboxId]: true },
|
||||
},
|
||||
},
|
||||
update: { [emailId]: patch },
|
||||
}, "0"],
|
||||
]);
|
||||
}
|
||||
@@ -1249,10 +1303,15 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void> {
|
||||
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
if (emailIds.length === 0) return;
|
||||
|
||||
const updates = Object.fromEntries(emailIds.map(id => [id, { mailboxIds: { [toMailboxId]: true } }]));
|
||||
const buildPatch = () => {
|
||||
const patch: Record<string, unknown> = { mailboxIds: { [toMailboxId]: true } };
|
||||
if (markAsRead) patch["keywords/$seen"] = true;
|
||||
return patch;
|
||||
};
|
||||
const updates = Object.fromEntries(emailIds.map(id => [id, buildPatch()]));
|
||||
await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||||
]);
|
||||
@@ -1505,7 +1564,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
return totalMarked;
|
||||
}
|
||||
|
||||
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
|
||||
async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
const mailboxes = await this.getMailboxes();
|
||||
@@ -1524,14 +1583,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
? junkMailbox.originalId
|
||||
: junkMailbox.id;
|
||||
|
||||
const patch: Record<string, unknown> = { mailboxIds: { [mailboxId]: true } };
|
||||
if (markAsRead) patch["keywords/$seen"] = true;
|
||||
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: targetAccountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
mailboxIds: { [mailboxId]: true },
|
||||
},
|
||||
},
|
||||
update: { [emailId]: patch },
|
||||
}, "0"],
|
||||
]);
|
||||
}
|
||||
@@ -2090,8 +2148,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
inReplyTo?: string[],
|
||||
references?: string[],
|
||||
delayedUntil?: string,
|
||||
envelopeMailFrom?: string
|
||||
): Promise<void> {
|
||||
): Promise<SendEmailResult> {
|
||||
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||||
const emailId = `send-${Date.now()}`;
|
||||
const mailboxes = await this.getMailboxes();
|
||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||
@@ -2193,12 +2253,19 @@ export class JMAPClient implements IJMAPClient {
|
||||
// e.g. sending from a domain-catch-all alias without a dedicated Identity),
|
||||
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
|
||||
// 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 };
|
||||
if (envelopeMailFrom) {
|
||||
if (holdForSeconds || envelopeMailFrom) {
|
||||
const envelopeRecipients = [...to, ...(cc || []), ...(bcc || [])]
|
||||
.map((email) => email.trim())
|
||||
.filter(Boolean)
|
||||
.map((email) => ({ email }));
|
||||
create.envelope = {
|
||||
mailFrom: { email: envelopeMailFrom },
|
||||
rcptTo: [...to, ...(cc || []), ...(bcc || [])].map((email) => ({ email })),
|
||||
mailFrom: {
|
||||
email: envelopeMailFrom || fromEmail || this.username,
|
||||
...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}),
|
||||
},
|
||||
rcptTo: envelopeRecipients,
|
||||
};
|
||||
}
|
||||
return { [submissionId]: create };
|
||||
@@ -2215,8 +2282,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "1"]);
|
||||
methodCalls.push(["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
create: submissionCreate("1"),
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: buildSubmissionCreate("1"),
|
||||
onSuccessUpdateEmail,
|
||||
}, "2"]);
|
||||
} else {
|
||||
@@ -2225,14 +2292,18 @@ export class JMAPClient implements IJMAPClient {
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "0"]);
|
||||
methodCalls.push(["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
create: submissionCreate("1"),
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: buildSubmissionCreate("1"),
|
||||
onSuccessUpdateEmail,
|
||||
}, "1"]);
|
||||
}
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
|
||||
let createdEmailId: string | undefined;
|
||||
let emailSubmissionId: string | undefined;
|
||||
let serverSendAt: string | undefined;
|
||||
|
||||
if (response.methodResponses) {
|
||||
for (const [methodName, result] of response.methodResponses) {
|
||||
if (methodName.endsWith('/error')) {
|
||||
@@ -2264,8 +2335,24 @@ export class JMAPClient implements IJMAPClient {
|
||||
`${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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2427,7 +2514,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "0"],
|
||||
["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
||||
onSuccessUpdateEmail: {
|
||||
"#sub-1": {
|
||||
@@ -2605,7 +2692,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "0"],
|
||||
["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
||||
onSuccessUpdateEmail: {
|
||||
"#sub-1": {
|
||||
@@ -2757,7 +2844,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "0"],
|
||||
["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
||||
onSuccessUpdateEmail: {
|
||||
"#sub-1": {
|
||||
@@ -2784,7 +2871,71 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
||||
private xhrUpload(
|
||||
url: string,
|
||||
file: File,
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Upload aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url, true);
|
||||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
||||
xhr.setRequestHeader('Authorization', this.authHeader);
|
||||
xhr.responseType = 'text';
|
||||
|
||||
const onAbort = () => xhr.abort();
|
||||
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||||
const cleanup = () => signal?.removeEventListener('abort', onAbort);
|
||||
|
||||
if (onProgress) {
|
||||
// Fire 0% immediately so the UI leaves its initial state even
|
||||
// before the first network packet flushes.
|
||||
onProgress(0, file.size);
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
// ev.total is only meaningful when lengthComputable; fall back
|
||||
// to file.size so callers always get a usable denominator.
|
||||
const total = ev.lengthComputable ? ev.total : file.size;
|
||||
onProgress(ev.loaded, total);
|
||||
};
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
cleanup();
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.responseText);
|
||||
} else {
|
||||
reject(new Error(`Failed to upload file: ${xhr.status} - ${xhr.responseText}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => { cleanup(); reject(new Error('Upload network error')); };
|
||||
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload aborted', 'AbortError')); };
|
||||
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Signature accepts either the legacy positional accountId string OR
|
||||
// the options bag introduced for progress / signal so existing
|
||||
// call-sites keep compiling without touching every plugin.
|
||||
async uploadBlob(
|
||||
file: File,
|
||||
optsOrAccountId?:
|
||||
| string
|
||||
| {
|
||||
accountId?: string;
|
||||
onProgress?: (loaded: number, total: number) => void;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<{ blobId: string; size: number; type: string }> {
|
||||
const opts =
|
||||
typeof optsOrAccountId === 'string'
|
||||
? { accountId: optsOrAccountId }
|
||||
: optsOrAccountId ?? {};
|
||||
if (!this.session) {
|
||||
throw new Error('Not connected. Call connect() first.');
|
||||
}
|
||||
@@ -2794,19 +2945,33 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error('Upload URL not available');
|
||||
}
|
||||
|
||||
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(this.accountId));
|
||||
const response = await this.authenticatedFetch(finalUploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||
body: file,
|
||||
});
|
||||
const targetAccountId = opts.accountId || this.accountId;
|
||||
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId));
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to upload file: ${response.status} - ${errorText}`);
|
||||
// XHR path: fetch() does not expose upload progress events, so when the
|
||||
// caller wants progress (or an AbortSignal) we use XMLHttpRequest. The
|
||||
// fetch path is kept for callers that don't need either, to preserve
|
||||
// existing 401/retry behaviour through authenticatedFetch().
|
||||
let responseText: string;
|
||||
if (opts.onProgress || opts.signal) {
|
||||
responseText = await this.xhrUpload(
|
||||
finalUploadUrl,
|
||||
file,
|
||||
opts.onProgress,
|
||||
opts.signal,
|
||||
);
|
||||
} else {
|
||||
const response = await this.authenticatedFetch(finalUploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||
body: file,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to upload file: ${response.status} - ${errorText}`);
|
||||
}
|
||||
responseText = await response.text();
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(responseText);
|
||||
@@ -2824,7 +2989,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
// Nested format: { [accountId]: { blobId, type, size } }
|
||||
const blobInfo = result[this.accountId];
|
||||
const blobInfo = result[targetAccountId];
|
||||
if (blobInfo?.blobId) {
|
||||
return {
|
||||
blobId: blobInfo.blobId,
|
||||
@@ -2893,6 +3058,85 @@ export class JMAPClient implements IJMAPClient {
|
||||
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 {
|
||||
if (!this.session) return null;
|
||||
|
||||
@@ -4117,6 +4361,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const createMap: Record<string, Partial<CalendarEvent>> = {};
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent;
|
||||
cleanRecurrenceRules(clean as unknown as Record<string, unknown>);
|
||||
createMap[`new-${i}`] = clean;
|
||||
}
|
||||
|
||||
@@ -5297,20 +5542,29 @@ export class JMAPClient implements IJMAPClient {
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
/** Import a raw MIME message blob into the account. */
|
||||
/**
|
||||
* Import a raw MIME message blob into the account. Pass `accountId` to
|
||||
* target a delegated account the caller has rights on (e.g. importing into
|
||||
* a shared mailbox owned by another user). When omitted, falls back to the
|
||||
* client's own primary account.
|
||||
*/
|
||||
async importRawEmail(
|
||||
blob: Blob,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
keywords?: Record<string, boolean>,
|
||||
accountId?: string,
|
||||
): Promise<string> {
|
||||
// First upload the blob
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
// First upload the blob. Blob uploads are scoped to an account too —
|
||||
// when importing into a delegated account, upload there so the resulting
|
||||
// blobId is visible to Email/import on that account.
|
||||
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
||||
const { blobId } = await this.uploadBlob(file);
|
||||
const { blobId } = await this.uploadBlob(file, targetAccountId);
|
||||
|
||||
// Then import via Email/import
|
||||
const response = await this.request([
|
||||
['Email/import', {
|
||||
accountId: this.accountId,
|
||||
accountId: targetAccountId,
|
||||
emails: {
|
||||
'smime-import': {
|
||||
blobId,
|
||||
@@ -5338,7 +5592,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
async submitEmail(emailId: string, identityId: string): Promise<void> {
|
||||
const response = await this.request([
|
||||
['EmailSubmission/set', {
|
||||
accountId: this.accountId,
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: { 'smime-submit': { emailId, identityId } },
|
||||
}, '0'],
|
||||
]);
|
||||
@@ -5359,7 +5613,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
identityId: string,
|
||||
sentMailboxId: string,
|
||||
draftMailboxId?: string,
|
||||
): Promise<void> {
|
||||
delayedUntil?: string,
|
||||
envelopeRecipients?: string[],
|
||||
): Promise<SendEmailResult> {
|
||||
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||||
// Upload the raw message
|
||||
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
||||
const { blobId } = await this.uploadBlob(file);
|
||||
@@ -5367,6 +5624,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
// Import into Drafts first, then move to Sent after submission succeeds.
|
||||
// This avoids encrypt-on-append affecting the SMTP send. See #188.
|
||||
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][] = [
|
||||
['Email/import', {
|
||||
accountId: this.accountId,
|
||||
@@ -5379,11 +5640,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
},
|
||||
}, '0'],
|
||||
['EmailSubmission/set', {
|
||||
accountId: this.accountId,
|
||||
accountId: this.getSubmissionAccountId(),
|
||||
create: {
|
||||
'raw-submit': {
|
||||
emailId: '#raw-import',
|
||||
identityId,
|
||||
...(envelope ? { envelope } : {}),
|
||||
},
|
||||
},
|
||||
...(draftMailboxId ? {
|
||||
@@ -5399,6 +5661,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
];
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
let emailId: string | undefined;
|
||||
let emailSubmissionId: string | undefined;
|
||||
let serverSendAt: string | undefined;
|
||||
|
||||
// Check for errors
|
||||
for (const [methodName, result] of response.methodResponses ?? []) {
|
||||
@@ -5410,6 +5675,201 @@ export class JMAPClient implements IJMAPClient {
|
||||
const firstErr = Object.values(r.notCreated)[0];
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5488,4 +5948,4 @@ export class JMAPClient implements IJMAPClient {
|
||||
['urn:ietf:params:jmap:core'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -42,6 +42,32 @@ export interface Email {
|
||||
// Unified mailbox support - set when displaying emails from multiple accounts
|
||||
accountId?: 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 {
|
||||
@@ -785,4 +811,4 @@ export const UNIFIED_ROLE_BY_ID: Record<string, UnifiedMailboxRole> = Object.fro
|
||||
|
||||
export function isUnifiedMailboxId(id: string): boolean {
|
||||
return id in UNIFIED_ROLE_BY_ID;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
||||
import type { EndpointValidator, OAuthMetadata } from '@/lib/oauth/discovery';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
|
||||
|
||||
// SSRF guard for OAuth discovery. When `oauthAllowPrivateEndpoints` is set,
|
||||
// the admin opts in to discovery resolving to RFC-1918 / loopback hosts —
|
||||
// required for split-DNS deployments where the JMAP server's public hostname
|
||||
// resolves to an internal IP locally. The guard remains in force for any
|
||||
// caller that passes a user-supplied serverUrl (see totp-token-exchange).
|
||||
export function getDiscoveryValidator(): EndpointValidator | undefined {
|
||||
const allowPrivate = configManager.get<boolean>('oauthAllowPrivateEndpoints', false);
|
||||
return allowPrivate ? undefined : isPublicHttpUrl;
|
||||
}
|
||||
|
||||
function getGlobalClientSecret(): string {
|
||||
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
||||
if (adminSecret) return adminSecret;
|
||||
@@ -47,7 +57,7 @@ function getClientSecret(serverId?: string | null): string {
|
||||
|
||||
export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
|
||||
const { discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||
if (!metadata?.token_endpoint) {
|
||||
throw new Error('OAuth token endpoint not found');
|
||||
}
|
||||
@@ -56,7 +66,7 @@ export async function getTokenEndpoint(serverId?: string | null): Promise<string
|
||||
|
||||
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
|
||||
const { discoveryUrl } = getRequiredConfig(serverId);
|
||||
return discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
return discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||
}
|
||||
|
||||
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
// installing their own code. Verification kicks in for server-managed
|
||||
// bundles only (the `managed: true` flag on `InstalledPlugin`).
|
||||
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
let cachedPubKey: CryptoKey | null = null;
|
||||
let pubKeyPromise: Promise<CryptoKey | null> | null = null;
|
||||
|
||||
@@ -26,7 +28,7 @@ async function importEd25519PublicKey(raw: Uint8Array): Promise<CryptoKey | null
|
||||
|
||||
async function fetchPublicKey(): Promise<CryptoKey | null> {
|
||||
try {
|
||||
const res = await fetch('/api/plugin-signing-pubkey', { credentials: 'same-origin' });
|
||||
const res = await apiFetch('/api/plugin-signing-pubkey', { credentials: 'same-origin' });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { algorithm?: string; publicKey?: string };
|
||||
if (data.algorithm !== 'ed25519' || typeof data.publicKey !== 'string') return null;
|
||||
|
||||
+32
-4
@@ -6,6 +6,11 @@ export interface UnifiedAccountClient {
|
||||
accountLabel: string;
|
||||
client: IJMAPClient;
|
||||
mailboxes: Mailbox[];
|
||||
// When true, this entry represents a group/shared account owned by
|
||||
// `accountId` but accessed through someone else's `client`. JMAP requests
|
||||
// must use the mailbox's `originalId` and explicitly target this accountId
|
||||
// so the server routes to the owner's data.
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface UnifiedFetchResult {
|
||||
@@ -60,10 +65,11 @@ export async function fetchUnifiedEmails(
|
||||
const mailbox = findMailboxByRole(account.mailboxes, role);
|
||||
if (!mailbox) return null;
|
||||
|
||||
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
|
||||
try {
|
||||
const result = await account.client.getEmails(
|
||||
mailbox.id,
|
||||
undefined,
|
||||
jmapMailboxId,
|
||||
jmapAccountId,
|
||||
limit,
|
||||
position,
|
||||
);
|
||||
@@ -131,7 +137,8 @@ export async function searchUnifiedEmails(
|
||||
position: number,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
|
||||
return account.client.searchEmails(query, mailbox.id, undefined, limit, position);
|
||||
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
|
||||
return account.client.searchEmails(query, jmapMailboxId, jmapAccountId, limit, position);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,10 +156,31 @@ export async function advancedSearchUnifiedEmails(
|
||||
position: number,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
|
||||
return account.client.advancedSearchEmails(filterFor(mailbox.id), undefined, limit, position);
|
||||
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
|
||||
return account.client.advancedSearchEmails(filterFor(jmapMailboxId), jmapAccountId, limit, position);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the JMAP-side mailbox id and accountId for a mailbox living inside
|
||||
* a UnifiedAccountClient. For personal-account entries we use the JMAP id as
|
||||
* returned by the primary client; for shared-owner entries the mailbox id is
|
||||
* namespaced (`${ownerId}:${origId}`) so we must use `originalId` and pass the
|
||||
* owner's accountId through the request.
|
||||
*/
|
||||
function resolveJmapTarget(
|
||||
account: UnifiedAccountClient,
|
||||
mailbox: Mailbox,
|
||||
): { jmapMailboxId: string; jmapAccountId: string | undefined } {
|
||||
if (account.isShared) {
|
||||
return {
|
||||
jmapMailboxId: mailbox.originalId ?? mailbox.id,
|
||||
jmapAccountId: account.accountId,
|
||||
};
|
||||
}
|
||||
return { jmapMailboxId: mailbox.id, jmapAccountId: undefined };
|
||||
}
|
||||
|
||||
async function fanOutUnifiedQuery(
|
||||
accounts: UnifiedAccountClient[],
|
||||
role: UnifiedMailboxRole,
|
||||
|
||||
+76
-12
@@ -3,6 +3,8 @@ import { twMerge } from "tailwind-merge";
|
||||
import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types";
|
||||
import type { UnifiedMailboxRole } from "./jmap/types";
|
||||
import { debug } from "./debug";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -40,24 +42,86 @@ export function generateUUID(): string {
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a received-at date for the email list. The output style is
|
||||
* controlled by the `dateFormat` user setting:
|
||||
*
|
||||
* - `smart` (default) — locale-aware, age-bucketed:
|
||||
* today → time only ("15:31" or "3:31 PM")
|
||||
* last 7 days → short weekday+time ("Fr 15:31", "Fri 3:31 PM")
|
||||
* older → full locale date ("28.04.2026", "04/28/2026")
|
||||
* - `relative` — legacy en-US relative format ("1h ago", "2d ago").
|
||||
* - `full` — always the full locale date+time.
|
||||
*
|
||||
* Both the locale (from the language picker) and 12h/24h preference are
|
||||
* read via `getState()` so this stays SSR-safe.
|
||||
*/
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
const localeRaw = useLocaleStore.getState().locale;
|
||||
const locale = localeRaw && localeRaw.length > 0 ? localeRaw : "en";
|
||||
// `en` alone resolves to en-US in Intl; everything else uses the language
|
||||
// subtag as-is and lets the runtime pick a sensible default region.
|
||||
const intlLocale = locale === "en" ? "en-US" : locale;
|
||||
const { dateFormat, timeFormat } = useSettingsStore.getState();
|
||||
const hour12 = timeFormat === "12h";
|
||||
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
if (dateFormat === "relative") {
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
if (dateFormat === "full") {
|
||||
return d.toLocaleString(intlLocale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12,
|
||||
});
|
||||
}
|
||||
|
||||
// 'smart' (default)
|
||||
const timeStr = d.toLocaleTimeString(intlLocale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12,
|
||||
});
|
||||
|
||||
const isSameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
if (isSameDay) return timeStr;
|
||||
|
||||
const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000);
|
||||
if (daysAgo < 7) {
|
||||
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
|
||||
// strip it so the result reads cleanly next to the time.
|
||||
const weekday = d
|
||||
.toLocaleDateString(intlLocale, { weekday: "short" })
|
||||
.replace(/\.$/, "");
|
||||
return `${weekday} ${timeStr}`;
|
||||
}
|
||||
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
export interface WebDAVResource {
|
||||
href: string;
|
||||
@@ -32,7 +33,7 @@ export class WebDAVClient {
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
return fetch(this.proxyUrl, {
|
||||
return apiFetch(this.proxyUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: options?.body,
|
||||
@@ -118,7 +119,7 @@ export class WebDAVClient {
|
||||
// Use XMLHttpRequest for progress tracking
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', this.proxyUrl);
|
||||
xhr.open('POST', withBasePath(this.proxyUrl));
|
||||
xhr.setRequestHeader('X-WebDAV-Method', 'PUT');
|
||||
xhr.setRequestHeader('X-WebDAV-Path', path);
|
||||
const slotHeaders = getActiveAccountSlotHeaders();
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"shared": "Sdílené",
|
||||
"mail": "Pošta",
|
||||
"nav_label": "Navigace",
|
||||
"add_app": "Aplikace"
|
||||
"add_app": "Aplikace",
|
||||
"scheduled": "Naplánováno"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Výchozí aplikace",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Vyprázdnit složku",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"no_email_selected": "Není vybrána žádná zpráva",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Tisk",
|
||||
"view_source": "Zobrazit zdrojový kód",
|
||||
"export_email": "Exportovat jako .eml",
|
||||
"import_email": "Importovat .eml",
|
||||
"import_email": "Importovat .eml nebo .zip",
|
||||
"keyboard_shortcuts": "Klávesové zkratky (?)",
|
||||
"email_source": "Zdrojový kód zprávy",
|
||||
"draft_banner": "Tato zpráva je koncept",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "Verdikt AI",
|
||||
"account": "Účet",
|
||||
"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": {
|
||||
"new_message": "Nová zpráva",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "Přidat odkaz",
|
||||
"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": "Potvrdit",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "Pokračovat v úpravách",
|
||||
"tabs": {
|
||||
"appearance": "Vzhled",
|
||||
"language": "Jazyk a region",
|
||||
"language": "Jazyk, region a čas",
|
||||
"email": "Chování e-mailu",
|
||||
"composer": "Psaní zpráv",
|
||||
"privacy": "Soukromí a bezpečnost",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "Vzhled",
|
||||
"reading": "Čtení",
|
||||
"composing": "Psaní",
|
||||
"downloads": "Stažené",
|
||||
"content_senders": "Obsah a odesílatelé",
|
||||
"about_data": "Info a data",
|
||||
"debug": "Ladění"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Sjednocená schránka",
|
||||
"description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů"
|
||||
"description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů",
|
||||
"include_group": {
|
||||
"label": "Zahrnout skupinové schránky",
|
||||
"description": "Zahrnout do sjednoceného zobrazení také sdílené/skupinové schránky."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Barevné ikony postranního panelu",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Jazyk a region",
|
||||
"description": "Nakonfigurujte jazykové a místní předvolby",
|
||||
"title": "Jazyk, region a čas",
|
||||
"description": "Jazyk, formát data, formát času a další regionální předvolby",
|
||||
"language": {
|
||||
"label": "Jazyk",
|
||||
"description": "Vyberte preferovaný jazyk",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Formát data",
|
||||
"description": "Jak se mají zobrazovat data",
|
||||
"regional": "Místní",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Vlastní"
|
||||
"description": "Jak se zobrazují data v seznamu e-mailů",
|
||||
"smart": "Chytrý (regionální)",
|
||||
"relative": "Relativní (před 1 h, před 2 d)",
|
||||
"full": "Vždy úplné datum",
|
||||
"preview_today": "Dnes:",
|
||||
"preview_this_week": "Tento týden:",
|
||||
"preview_older": "Starší:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Formát času",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "Zobrazit náhledy obrázků v přílohách",
|
||||
"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": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"categories_description": "Přejmenovat kategorie kontaktů",
|
||||
"no_categories": "Nenalezeny žádné kategorie"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Stažené",
|
||||
"description": "Přizpůsobte si, jak se pojmenovávají stažené e-maily a přílohy.",
|
||||
"reset": "Obnovit výchozí",
|
||||
"preview": "Náhled:",
|
||||
"email_template": {
|
||||
"label": "Název souboru e-mailu (.eml)",
|
||||
"description": "Šablona použitá při exportu e-mailu nebo jeho přetažení do souborového systému. Přípona .eml se přidává automaticky."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Název souboru přílohy",
|
||||
"description": "Šablona použitá při stahování nebo přetahování přílohy. Pokud vynecháte '{filename}' a '{ext}', původní přípona se zachová."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Název .zip souboru s více e-maily",
|
||||
"description": "Šablona použitá při přetažení nebo stažení několika vybraných e-mailů jako jediného .zip archivu. Přípona .zip se přidává automaticky."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Mezery",
|
||||
"description": "Nahradit mezery ve výsledném názvu souboru jiným znakem.",
|
||||
"keep": "Zachovat mezery",
|
||||
"underscore": "Nahradit za _",
|
||||
"dash": "Nahradit za -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Malá písmena",
|
||||
"description": "Vynutit celý název souboru malými písmeny."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Odstranit diakritiku",
|
||||
"description": "Převést písmena s diakritikou na ASCII ekvivalenty (á → a, é → e). Užitečné pro nástroje, které špatně zpracovávají Unicode názvy."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Sloučit opakované oddělovače",
|
||||
"description": "Sloučit posloupnosti mezer, podtržítek nebo pomlček do jednoho znaku."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Po exportu",
|
||||
"description": "Po exportu jako .eml lze e-mail volitelně přesunout.",
|
||||
"keep": "Ponechat ve schránce",
|
||||
"archive": "Přesunout do archivu",
|
||||
"trash": "Přesunout do koše"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtry e-mailů",
|
||||
"description": "Vytvářejte pravidla pro automatické třídění, štítkování a správu příchozích e-mailů",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "Štítek",
|
||||
"remove_color": "Odebrat štítek",
|
||||
"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": {
|
||||
"mark_folder_read": "Označit složku jako přečtenou",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "Nová podsložka...",
|
||||
"new_folder": "Nová složka...",
|
||||
"rename": "Přejmenovat...",
|
||||
"import_email": "Importovat .eml...",
|
||||
"import_email": "Importovat .eml nebo .zip...",
|
||||
"empty_folder": "Vyprázdnit složku",
|
||||
"empty_folder_generic": "Vyprázdnit složku",
|
||||
"delete_folder": "Smazat složku",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "Rozbalit/sbalit vlákno"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Odeslat e-mail",
|
||||
"schedule_send": "Naplánovat odeslání",
|
||||
"template_picker": "Otevřít výběr šablon"
|
||||
}
|
||||
},
|
||||
|
||||
+111
-16
@@ -132,7 +132,8 @@
|
||||
"shared": "Delt",
|
||||
"mail": "Mail",
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps"
|
||||
"add_app": "Apps",
|
||||
"scheduled": "Planlagt"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Standardapps",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Tøm mappe",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"no_email_selected": "Ingen e-mail valgt",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Udskriv",
|
||||
"view_source": "Vis kilde",
|
||||
"export_email": "Eksportér som .eml",
|
||||
"import_email": "Importér .eml",
|
||||
"import_email": "Importér .eml eller .zip",
|
||||
"keyboard_shortcuts": "Tastaturgenveje (?)",
|
||||
"email_source": "E-mail-kilde",
|
||||
"draft_banner": "Denne besked er en kladde",
|
||||
@@ -514,7 +524,17 @@
|
||||
"collapse": "Skjul detaljer"
|
||||
},
|
||||
"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": {
|
||||
"new_message": "Ny besked",
|
||||
@@ -607,7 +627,17 @@
|
||||
"send_anyway": "Send alligevel",
|
||||
"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": "Bekræft",
|
||||
@@ -755,7 +785,7 @@
|
||||
"search_no_results": "Ingen match i indstillinger",
|
||||
"tabs": {
|
||||
"appearance": "Udseende",
|
||||
"language": "Sprog & region",
|
||||
"language": "Sprog, region & tid",
|
||||
"email": "E-mail-adfærd",
|
||||
"composer": "Komponist",
|
||||
"privacy": "Privatliv & sikkerhed",
|
||||
@@ -778,6 +808,7 @@
|
||||
"layout": "Layout",
|
||||
"reading": "Læsning",
|
||||
"composing": "Skrivning",
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Indhold & afsendere",
|
||||
"about_data": "Om & data",
|
||||
"debug": "Debug"
|
||||
@@ -846,7 +877,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Samlet postkasse",
|
||||
"description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti"
|
||||
"description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti",
|
||||
"include_group": {
|
||||
"label": "Inkluder gruppepostkasser",
|
||||
"description": "Inkluder også delte/gruppepostkasser i den samlede visning."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Farverige sidepane-ikoner",
|
||||
@@ -930,8 +965,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Sprog & region",
|
||||
"description": "Konfigurér sprog og regionale præferencer",
|
||||
"title": "Sprog, region & tid",
|
||||
"description": "Sprog, datoformat, tidsformat og andre regionale indstillinger",
|
||||
"language": {
|
||||
"label": "Sprog",
|
||||
"description": "Vælg dit foretrukne sprog",
|
||||
@@ -940,10 +975,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Datoformat",
|
||||
"description": "Hvordan datoer skal vises",
|
||||
"regional": "Regionalt",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Brugerdefineret"
|
||||
"description": "Sådan vises datoer på e-mail-listen",
|
||||
"smart": "Smart (regionalt)",
|
||||
"relative": "Relativ (for 1 t siden, for 2 d siden)",
|
||||
"full": "Altid fuld dato",
|
||||
"preview_today": "I dag:",
|
||||
"preview_this_week": "Denne uge:",
|
||||
"preview_older": "Ældre:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Tidsformat",
|
||||
@@ -1129,6 +1167,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "Vis billedforhåndsvisninger i vedhæftninger",
|
||||
"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": {
|
||||
@@ -1498,6 +1543,50 @@
|
||||
"categories_description": "Omdøb kontaktkategorier",
|
||||
"no_categories": "Ingen kategorier fundet"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
"description": "Tilpas hvordan downloadede e-mails og vedhæftede filer navngives.",
|
||||
"reset": "Gendan standard",
|
||||
"preview": "Forhåndsvisning:",
|
||||
"email_template": {
|
||||
"label": "E-mail-filnavn (.eml)",
|
||||
"description": "Skabelon som bruges når du eksporterer en e-mail eller trækker den ud i filsystemet. Endelsen .eml tilføjes automatisk."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Filnavn på vedhæftet fil",
|
||||
"description": "Skabelon som bruges når du downloader eller trækker en vedhæftet fil ud. Hvis du udelader '{filename}' og '{ext}', bevares den oprindelige endelse."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Filnavn for .zip med flere e-mails",
|
||||
"description": "Skabelon som bruges når du trækker ud eller downloader flere valgte e-mails som ét .zip-arkiv. Endelsen .zip tilføjes automatisk."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Mellemrum",
|
||||
"description": "Erstat mellemrum i det endelige filnavn med et andet tegn.",
|
||||
"keep": "Behold mellemrum",
|
||||
"underscore": "Erstat med _",
|
||||
"dash": "Erstat med -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Små bogstaver",
|
||||
"description": "Tving hele filnavnet til små bogstaver."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Fjern diakritiske tegn",
|
||||
"description": "Konvertér bogstaver med accenter til deres ASCII-pendant (ä → a, é → e). Nyttigt for værktøjer, der ikke håndterer Unicode-navne korrekt."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Slå gentagne skilletegn sammen",
|
||||
"description": "Slå serier af mellemrum, understreger eller bindestreger sammen til ét tegn."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Efter eksport",
|
||||
"description": "Flyt eventuelt e-mailen efter den er eksporteret som .eml.",
|
||||
"keep": "Behold i postkassen",
|
||||
"archive": "Flyt til arkiv",
|
||||
"trash": "Flyt til papirkurv"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-mail-filtre",
|
||||
"description": "Opret regler til automatisk at sortere, mærke og administrere indkommende e-mails",
|
||||
@@ -1751,7 +1840,11 @@
|
||||
"color_tag": "Tag",
|
||||
"remove_color": "Fjern tag",
|
||||
"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": {
|
||||
"mark_folder_read": "Markér mappe som læst",
|
||||
@@ -1760,7 +1853,7 @@
|
||||
"new_subfolder": "Ny undermappe...",
|
||||
"new_folder": "Ny mappe...",
|
||||
"rename": "Omdøb...",
|
||||
"import_email": "Importér .eml...",
|
||||
"import_email": "Importér .eml eller .zip...",
|
||||
"empty_folder": "Tøm mappe",
|
||||
"empty_folder_generic": "Tøm mappe",
|
||||
"delete_folder": "Slet mappe",
|
||||
@@ -1829,6 +1922,8 @@
|
||||
"expand_collapse": "Udvid/skjul tråd"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Send e-mail",
|
||||
"schedule_send": "Planlæg afsendelse",
|
||||
"template_picker": "Åbn skabelonvælger"
|
||||
}
|
||||
},
|
||||
@@ -2948,4 +3043,4 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Søgning er ikke tilgængelig i den samlede visning"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "E-Mail",
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps",
|
||||
"shared": "Geteilt"
|
||||
"shared": "Geteilt",
|
||||
"scheduled": "Geplant"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Standard-Apps",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Ordner leeren",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"no_email_selected": "Keine E-Mail ausgewählt",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Drucken",
|
||||
"view_source": "Quelltext anzeigen",
|
||||
"export_email": "Als .eml exportieren",
|
||||
"import_email": ".eml importieren",
|
||||
"import_email": ".eml oder .zip importieren",
|
||||
"keyboard_shortcuts": "Tastaturkürzel (?)",
|
||||
"email_source": "E-Mail-Quelltext",
|
||||
"draft_banner": "Diese Nachricht ist ein Entwurf",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "KI-Urteil",
|
||||
"account": "Konto",
|
||||
"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": {
|
||||
"new_message": "Neue Nachricht",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "Link hinzufügen",
|
||||
"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": "Bestätigen",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "Weiter bearbeiten",
|
||||
"tabs": {
|
||||
"appearance": "Darstellung",
|
||||
"language": "Sprache & Region",
|
||||
"language": "Sprache, Region & Zeit",
|
||||
"email": "E-Mail-Verhalten",
|
||||
"composer": "Editor",
|
||||
"privacy": "Datenschutz & Sicherheit",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "Layout",
|
||||
"reading": "Lesen",
|
||||
"composing": "Verfassen",
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Inhalte & Absender",
|
||||
"about_data": "Über & Daten",
|
||||
"debug": "Debug"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Gemeinsames Postfach",
|
||||
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen"
|
||||
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen",
|
||||
"include_group": {
|
||||
"label": "Gruppenpostfächer einbeziehen",
|
||||
"description": "Gemeinsam genutzte/Gruppenpostfächer ebenfalls in die vereinheitlichte Ansicht aufnehmen."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Farbige Seitenleistensymbole",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Sprache & Region",
|
||||
"description": "Konfigurieren Sie Sprach- und Regionaleinstellungen",
|
||||
"title": "Sprache, Region & Zeit",
|
||||
"description": "Sprache, Datums- und Zeitformat sowie weitere regionale Einstellungen",
|
||||
"language": {
|
||||
"label": "Sprache",
|
||||
"description": "Wählen Sie Ihre bevorzugte Sprache",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Datumsformat",
|
||||
"description": "Wie Daten angezeigt werden sollen",
|
||||
"regional": "Regional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Benutzerdefiniert"
|
||||
"description": "Wie Daten in der E-Mail-Liste angezeigt werden",
|
||||
"smart": "Intelligent (gebietsschemaabhängig)",
|
||||
"relative": "Relativ (vor 1 Std., vor 2 Tagen)",
|
||||
"full": "Immer vollständiges Datum",
|
||||
"preview_today": "Heute:",
|
||||
"preview_this_week": "Diese Woche:",
|
||||
"preview_older": "Älter:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Zeitformat",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "Bildvorschau in Anhängen 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": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen",
|
||||
"group_by_letter_label": "Nach Anfangsbuchstaben gruppieren"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
"description": "Lege fest, wie heruntergeladene E-Mails und Anhänge benannt werden.",
|
||||
"reset": "Standard wiederherstellen",
|
||||
"preview": "Vorschau:",
|
||||
"email_template": {
|
||||
"label": "E-Mail-Dateiname (.eml)",
|
||||
"description": "Vorlage, die beim Exportieren oder Herausziehen einer E-Mail verwendet wird. Die Endung .eml wird automatisch ergänzt."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Anhang-Dateiname",
|
||||
"description": "Vorlage zum Herunterladen oder Herausziehen eines Anhangs. Wenn '{filename}' und '{ext}' fehlen, bleibt die ursprüngliche Endung erhalten."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Mehrere E-Mails als .zip",
|
||||
"description": "Vorlage, die beim Herausziehen oder Herunterladen mehrerer ausgewählter E-Mails als einzelnes .zip-Archiv verwendet wird. Die Endung .zip wird automatisch ergänzt."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Leerzeichen",
|
||||
"description": "Ersetze Leerzeichen im Dateinamen durch ein anderes Zeichen.",
|
||||
"keep": "Leerzeichen behalten",
|
||||
"underscore": "Durch _ ersetzen",
|
||||
"dash": "Durch - ersetzen"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Kleinschreibung",
|
||||
"description": "Wandle den gesamten Dateinamen in Kleinbuchstaben um."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Diakritika entfernen",
|
||||
"description": "Wandle Buchstaben mit Akzenten in ASCII-Äquivalente um (ä → a, é → e). Nützlich für Tools, die Unicode-Dateinamen verstümmeln."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Trennzeichen zusammenfassen",
|
||||
"description": "Aufeinanderfolgende Leerzeichen, Unterstriche oder Bindestriche zu einem Zeichen zusammenfassen."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Nach dem Export",
|
||||
"description": "E-Mail nach dem Export als .eml optional verschieben.",
|
||||
"keep": "Im Postfach behalten",
|
||||
"archive": "Ins Archiv verschieben",
|
||||
"trash": "In den Papierkorb verschieben"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-Mail-Filter",
|
||||
"description": "Erstellen Sie Regeln, um eingehende E-Mails automatisch zu sortieren, zu kennzeichnen und zu verwalten",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "Label",
|
||||
"remove_color": "Label entfernen",
|
||||
"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": {
|
||||
"mark_folder_read": "Ordner als gelesen markieren",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "Neuer Unterordner...",
|
||||
"new_folder": "Neuer Ordner...",
|
||||
"rename": "Umbenennen...",
|
||||
"import_email": ".eml importieren...",
|
||||
"import_email": ".eml oder .zip importieren...",
|
||||
"empty_folder": "Ordner leeren",
|
||||
"empty_folder_generic": "Ordner leeren",
|
||||
"delete_folder": "Ordner löschen",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "Unterhaltung erweitern/einklappen"
|
||||
},
|
||||
"composer": {
|
||||
"send": "E-Mail senden",
|
||||
"schedule_send": "Senden planen",
|
||||
"template_picker": "Vorlagenauswahl öffnen"
|
||||
}
|
||||
},
|
||||
|
||||
+111
-15
@@ -132,7 +132,8 @@
|
||||
"shared": "Shared",
|
||||
"mail": "Mail",
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps"
|
||||
"add_app": "Apps",
|
||||
"scheduled": "Scheduled"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Default apps",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Empty folder",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"no_email_selected": "No email selected",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Print",
|
||||
"view_source": "View source",
|
||||
"export_email": "Export as .eml",
|
||||
"import_email": "Import .eml",
|
||||
"import_email": "Import .eml or .zip",
|
||||
"keyboard_shortcuts": "Keyboard shortcuts (?)",
|
||||
"email_source": "Email Source",
|
||||
"draft_banner": "This message is a draft",
|
||||
@@ -514,7 +524,17 @@
|
||||
"collapse": "Hide details"
|
||||
},
|
||||
"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": {
|
||||
"new_message": "New Message",
|
||||
@@ -607,7 +627,17 @@
|
||||
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||
"send_anyway": "Send anyway",
|
||||
"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": "Confirm",
|
||||
@@ -755,7 +785,7 @@
|
||||
"search_no_results": "No matching settings",
|
||||
"tabs": {
|
||||
"appearance": "Appearance",
|
||||
"language": "Language & Region",
|
||||
"language": "Language, Region & Time",
|
||||
"email": "Email Behavior",
|
||||
"composer": "Composer",
|
||||
"privacy": "Privacy & Security",
|
||||
@@ -778,6 +808,7 @@
|
||||
"layout": "Layout",
|
||||
"reading": "Reading",
|
||||
"composing": "Composing",
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Content & Senders",
|
||||
"about_data": "About & Data",
|
||||
"debug": "Debug"
|
||||
@@ -846,7 +877,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Unified Mailbox",
|
||||
"description": "Show combined folders (Inbox, Sent, etc.) across all connected accounts"
|
||||
"description": "Show combined folders (Inbox, Sent, etc.) across all connected accounts",
|
||||
"include_group": {
|
||||
"label": "Include group inboxes",
|
||||
"description": "Also merge shared/group inboxes into the unified view."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Colorful Sidebar Icons",
|
||||
@@ -930,8 +965,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Language & Region",
|
||||
"description": "Configure language and regional preferences",
|
||||
"title": "Language, Region & Time",
|
||||
"description": "Language, date format, time format, and other regional preferences",
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"description": "Choose your preferred language",
|
||||
@@ -940,10 +975,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Date Format",
|
||||
"description": "How dates should be displayed",
|
||||
"regional": "Regional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Custom"
|
||||
"description": "How dates are shown in the email list",
|
||||
"smart": "Smart (locale-aware)",
|
||||
"relative": "Relative (1h ago, 2d ago)",
|
||||
"full": "Always full date",
|
||||
"preview_today": "Today:",
|
||||
"preview_this_week": "This week:",
|
||||
"preview_older": "Older:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Time Format",
|
||||
@@ -973,6 +1011,7 @@
|
||||
"label": "Delete Action",
|
||||
"description": "What happens when you delete an email",
|
||||
"trash": "Move to Trash",
|
||||
"trash_and_read": "Move to Trash and mark as read",
|
||||
"permanent": "Delete Permanently",
|
||||
"warning": "Emails will be permanently deleted and cannot be recovered. This action is irreversible."
|
||||
},
|
||||
@@ -1129,6 +1168,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "Show image previews in attachments",
|
||||
"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": {
|
||||
@@ -1498,6 +1544,50 @@
|
||||
"categories_description": "Rename contact categories",
|
||||
"no_categories": "No categories found"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
"description": "Customize how downloaded emails and attachments are named.",
|
||||
"reset": "Restore default",
|
||||
"preview": "Preview:",
|
||||
"email_template": {
|
||||
"label": "Email (.eml) filename",
|
||||
"description": "Template used when you export an email or drag one out to the file system. The .eml extension is added automatically."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Attachment filename",
|
||||
"description": "Template used when downloading or dragging out an attachment. If you omit '{filename}' and '{ext}', the original extension is preserved."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Multi-email .zip filename",
|
||||
"description": "Template used when you drag out or download several selected emails as a single .zip archive. The .zip extension is added automatically."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Spaces",
|
||||
"description": "Replace spaces in the resulting filename with another character.",
|
||||
"keep": "Keep spaces",
|
||||
"underscore": "Replace with _",
|
||||
"dash": "Replace with -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Lowercase",
|
||||
"description": "Force the entire filename to lowercase."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Strip diacritics",
|
||||
"description": "Convert accented letters to their ASCII equivalents (ä → a, é → e). Useful for tools that mangle Unicode filenames."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Collapse repeated separators",
|
||||
"description": "Collapse runs of spaces, underscores, or dashes to a single character."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "After export",
|
||||
"description": "Optionally move the email after exporting it as .eml.",
|
||||
"keep": "Keep in mailbox",
|
||||
"archive": "Move to archive",
|
||||
"trash": "Move to trash"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Email Filters",
|
||||
"description": "Create rules to automatically sort, label, and manage incoming emails",
|
||||
@@ -1751,7 +1841,11 @@
|
||||
"color_tag": "Tag",
|
||||
"remove_color": "Remove tag",
|
||||
"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": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
@@ -1760,7 +1854,7 @@
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"import_email": "Import .eml...",
|
||||
"import_email": "Import .eml or .zip...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
@@ -1829,6 +1923,8 @@
|
||||
"expand_collapse": "Expand/collapse thread"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Send email",
|
||||
"schedule_send": "Schedule send",
|
||||
"template_picker": "Open template picker"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "Correo",
|
||||
"nav_label": "Navegación",
|
||||
"add_app": "Apps",
|
||||
"shared": "Compartido"
|
||||
"shared": "Compartido",
|
||||
"scheduled": "Programados"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Aplicaciones predeterminadas",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Vaciar carpeta",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"no_email_selected": "Ningún correo seleccionado",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Imprimir",
|
||||
"view_source": "Ver código fuente",
|
||||
"export_email": "Exportar como .eml",
|
||||
"import_email": "Importar .eml",
|
||||
"import_email": "Importar .eml o .zip",
|
||||
"keyboard_shortcuts": "Atajos de teclado (?)",
|
||||
"email_source": "Código Fuente del Correo",
|
||||
"draft_banner": "Este mensaje es un borrador",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "Veredicto de IA",
|
||||
"account": "Cuenta",
|
||||
"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": {
|
||||
"new_message": "Nuevo Mensaje",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "Añadir enlace",
|
||||
"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": "Confirmar",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "Seguir editando",
|
||||
"tabs": {
|
||||
"appearance": "Apariencia",
|
||||
"language": "Idioma y Región",
|
||||
"language": "Idioma, Región y Hora",
|
||||
"email": "Comportamiento del Correo",
|
||||
"composer": "Editor",
|
||||
"privacy": "Privacidad y Seguridad",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "Diseño",
|
||||
"reading": "Lectura",
|
||||
"composing": "Redacción",
|
||||
"downloads": "Descargas",
|
||||
"content_senders": "Contenido y remitentes",
|
||||
"about_data": "Acerca de y datos",
|
||||
"debug": "Depuración"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Buzón unificado",
|
||||
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas"
|
||||
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas",
|
||||
"include_group": {
|
||||
"label": "Incluir buzones de grupo",
|
||||
"description": "Incluir también los buzones compartidos o de grupo en la vista unificada."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Iconos de barra lateral a color",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Idioma y Región",
|
||||
"description": "Configure las preferencias de idioma y región",
|
||||
"title": "Idioma, Región y Hora",
|
||||
"description": "Idioma, formato de fecha, formato de hora y otras preferencias regionales",
|
||||
"language": {
|
||||
"label": "Idioma",
|
||||
"description": "Elija su idioma preferido",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Formato de Fecha",
|
||||
"description": "Cómo se deben mostrar las fechas",
|
||||
"regional": "Regional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Personalizado"
|
||||
"description": "Cómo se muestran las fechas en la lista de correos",
|
||||
"smart": "Inteligente (según región)",
|
||||
"relative": "Relativo (hace 1 h, hace 2 d)",
|
||||
"full": "Fecha completa siempre",
|
||||
"preview_today": "Hoy:",
|
||||
"preview_this_week": "Esta semana:",
|
||||
"preview_older": "Más antiguo:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Formato de Hora",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"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"
|
||||
},
|
||||
"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": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos",
|
||||
"group_by_letter_label": "Agrupar por primera letra"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Descargas",
|
||||
"description": "Personaliza cómo se nombran los correos y archivos adjuntos descargados.",
|
||||
"reset": "Restaurar predeterminado",
|
||||
"preview": "Vista previa:",
|
||||
"email_template": {
|
||||
"label": "Nombre del archivo de correo (.eml)",
|
||||
"description": "Plantilla usada al exportar un correo o al arrastrarlo al sistema de archivos. La extensión .eml se añade automáticamente."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Nombre del archivo adjunto",
|
||||
"description": "Plantilla usada al descargar o arrastrar un adjunto. Si omites '{filename}' y '{ext}', se conserva la extensión original."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Nombre de archivo .zip multi-correo",
|
||||
"description": "Plantilla utilizada al arrastrar o descargar varios correos seleccionados como un único archivo .zip. La extensión .zip se añade automáticamente."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Espacios",
|
||||
"description": "Reemplaza los espacios del nombre de archivo resultante por otro carácter.",
|
||||
"keep": "Conservar espacios",
|
||||
"underscore": "Reemplazar por _",
|
||||
"dash": "Reemplazar por -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Minúsculas",
|
||||
"description": "Forzar todo el nombre del archivo a minúsculas."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Eliminar diacríticos",
|
||||
"description": "Convierte las letras acentuadas en sus equivalentes ASCII (ä → a, é → e). Útil para herramientas que estropean nombres Unicode."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Combinar separadores repetidos",
|
||||
"description": "Combina secuencias de espacios, guiones bajos o guiones en un único carácter."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Después de exportar",
|
||||
"description": "Opcionalmente, mover el correo después de exportarlo como .eml.",
|
||||
"keep": "Mantener en el buzón",
|
||||
"archive": "Mover al archivo",
|
||||
"trash": "Mover a la papelera"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtros de correo",
|
||||
"description": "Cree reglas para ordenar, etiquetar y gestionar automáticamente los correos entrantes",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
"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": {
|
||||
"mark_folder_read": "Marcar carpeta como leída",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "Nueva subcarpeta...",
|
||||
"new_folder": "Nueva carpeta...",
|
||||
"rename": "Renombrar...",
|
||||
"import_email": "Importar .eml...",
|
||||
"import_email": "Importar .eml o .zip...",
|
||||
"empty_folder": "Vaciar carpeta",
|
||||
"empty_folder_generic": "Vaciar carpeta",
|
||||
"delete_folder": "Eliminar carpeta",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "Expandir/contraer conversación"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Enviar correo",
|
||||
"schedule_send": "Programar envío",
|
||||
"template_picker": "Abrir selector de plantillas"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "Messagerie",
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps",
|
||||
"shared": "Partagé"
|
||||
"shared": "Partagé",
|
||||
"scheduled": "Planifiés"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Applications par défaut",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Vider le dossier",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"no_email_selected": "Aucun email sélectionné",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Imprimer",
|
||||
"view_source": "Voir la source",
|
||||
"export_email": "Exporter en .eml",
|
||||
"import_email": "Importer un .eml",
|
||||
"import_email": "Importer .eml ou .zip",
|
||||
"keyboard_shortcuts": "Raccourcis clavier (?)",
|
||||
"email_source": "Source de l'email",
|
||||
"draft_banner": "Ce message est un brouillon",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "Verdict IA",
|
||||
"account": "Compte",
|
||||
"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": {
|
||||
"new_message": "Nouveau message",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "Ajouter un lien",
|
||||
"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": "Confirmer",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "Continuer l'édition",
|
||||
"tabs": {
|
||||
"appearance": "Apparence",
|
||||
"language": "Langue et région",
|
||||
"language": "Langue, région et heure",
|
||||
"email": "Comportement email",
|
||||
"composer": "Compositeur",
|
||||
"privacy": "Confidentialité et sécurité",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "Mise en page",
|
||||
"reading": "Lecture",
|
||||
"composing": "Rédaction",
|
||||
"downloads": "Téléchargements",
|
||||
"content_senders": "Contenu et expéditeurs",
|
||||
"about_data": "À propos et données",
|
||||
"debug": "Débogage"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Boîte aux lettres unifiée",
|
||||
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés"
|
||||
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés",
|
||||
"include_group": {
|
||||
"label": "Inclure les boîtes de groupe",
|
||||
"description": "Inclure également les boîtes partagées ou de groupe dans la vue unifiée."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Icônes colorées dans la barre latérale",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Langue et région",
|
||||
"description": "Configurez vos préférences linguistiques et régionales",
|
||||
"title": "Langue, région et heure",
|
||||
"description": "Langue, format de date, format d'heure et autres préférences régionales",
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"description": "Choisissez votre langue préférée",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Format de date",
|
||||
"description": "Comment les dates doivent être affichées",
|
||||
"regional": "Régional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Personnalisé"
|
||||
"description": "Comment les dates apparaissent dans la liste des e-mails",
|
||||
"smart": "Intelligent (adapté à la région)",
|
||||
"relative": "Relatif (il y a 1 h, il y a 2 j)",
|
||||
"full": "Toujours la date complète",
|
||||
"preview_today": "Aujourd'hui :",
|
||||
"preview_this_week": "Cette semaine :",
|
||||
"preview_older": "Plus ancien :"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Format d'heure",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"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"
|
||||
},
|
||||
"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": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts",
|
||||
"group_by_letter_label": "Grouper par première lettre"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Téléchargements",
|
||||
"description": "Personnalisez la façon dont les e-mails et pièces jointes téléchargés sont nommés.",
|
||||
"reset": "Restaurer la valeur par défaut",
|
||||
"preview": "Aperçu :",
|
||||
"email_template": {
|
||||
"label": "Nom du fichier e-mail (.eml)",
|
||||
"description": "Modèle utilisé lors de l'export ou du glisser-déposer d'un e-mail vers le système de fichiers. L'extension .eml est ajoutée automatiquement."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Nom du fichier de pièce jointe",
|
||||
"description": "Modèle utilisé lors du téléchargement ou du glisser-déposer d'une pièce jointe. Si vous omettez '{filename}' et '{ext}', l'extension d'origine est conservée."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Nom du fichier .zip multi-e-mails",
|
||||
"description": "Modèle utilisé lorsque vous faites glisser ou téléchargez plusieurs e-mails sélectionnés sous forme d'archive .zip unique. L'extension .zip est ajoutée automatiquement."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Espaces",
|
||||
"description": "Remplacer les espaces dans le nom de fichier final par un autre caractère.",
|
||||
"keep": "Conserver les espaces",
|
||||
"underscore": "Remplacer par _",
|
||||
"dash": "Remplacer par -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Minuscules",
|
||||
"description": "Forcer tout le nom de fichier en minuscules."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Supprimer les diacritiques",
|
||||
"description": "Convertir les lettres accentuées en leur équivalent ASCII (ä → a, é → e). Utile pour les outils qui gèrent mal les noms Unicode."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Fusionner les séparateurs répétés",
|
||||
"description": "Fusionner les suites d’espaces, de tirets bas ou de tirets en un seul caractère."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Après l’export",
|
||||
"description": "Déplacer éventuellement l’e-mail après l’avoir exporté en .eml.",
|
||||
"keep": "Garder dans la boîte",
|
||||
"archive": "Déplacer vers les archives",
|
||||
"trash": "Déplacer vers la corbeille"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres de courrier",
|
||||
"description": "Créez des règles pour trier, étiqueter et gérer automatiquement les courriers entrants",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "Étiquette",
|
||||
"remove_color": "Supprimer l'étiquette",
|
||||
"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": {
|
||||
"mark_folder_read": "Marquer le dossier comme lu",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "Nouveau sous-dossier...",
|
||||
"new_folder": "Nouveau dossier...",
|
||||
"rename": "Renommer...",
|
||||
"import_email": "Importer un .eml...",
|
||||
"import_email": "Importer .eml ou .zip...",
|
||||
"empty_folder": "Vider le dossier",
|
||||
"empty_folder_generic": "Vider le dossier",
|
||||
"delete_folder": "Supprimer le dossier",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "Développer/réduire la conversation"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Envoyer l'e-mail",
|
||||
"schedule_send": "Planifier l'envoi",
|
||||
"template_picker": "Ouvrir le sélecteur de modèles"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "Posta",
|
||||
"nav_label": "Navigazione",
|
||||
"add_app": "App",
|
||||
"shared": "Condiviso"
|
||||
"shared": "Condiviso",
|
||||
"scheduled": "Programmate"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "App predefinite",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Svuota cartella",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"no_email_selected": "Nessun messaggio selezionato",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Stampa",
|
||||
"view_source": "Visualizza sorgente",
|
||||
"export_email": "Esporta come .eml",
|
||||
"import_email": "Importa .eml",
|
||||
"import_email": "Importa .eml o .zip",
|
||||
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
|
||||
"email_source": "Sorgente del messaggio",
|
||||
"draft_banner": "Questo messaggio è una bozza",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "Verdetto IA",
|
||||
"account": "Account",
|
||||
"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": {
|
||||
"new_message": "Nuovo messaggio",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "Aggiungi link",
|
||||
"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": "Conferma",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "Continua a modificare",
|
||||
"tabs": {
|
||||
"appearance": "Aspetto",
|
||||
"language": "Lingua e regione",
|
||||
"language": "Lingua, regione e ora",
|
||||
"email": "Comportamento email",
|
||||
"composer": "Editor",
|
||||
"privacy": "Privacy e sicurezza",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "Layout",
|
||||
"reading": "Lettura",
|
||||
"composing": "Composizione",
|
||||
"downloads": "Download",
|
||||
"content_senders": "Contenuto e mittenti",
|
||||
"about_data": "Informazioni e dati",
|
||||
"debug": "Debug"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Casella di posta unificata",
|
||||
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati"
|
||||
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati",
|
||||
"include_group": {
|
||||
"label": "Includi le caselle di gruppo",
|
||||
"description": "Includi anche le caselle condivise o di gruppo nella vista unificata."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Icone colorate nella barra laterale",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Lingua e regione",
|
||||
"description": "Configura le preferenze di lingua e regionali",
|
||||
"title": "Lingua, regione e ora",
|
||||
"description": "Lingua, formato data, formato ora e altre preferenze regionali",
|
||||
"language": {
|
||||
"label": "Lingua",
|
||||
"description": "Scegli la tua lingua preferita",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Formato data",
|
||||
"description": "Come devono essere visualizzate le date",
|
||||
"regional": "Regionale",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Personalizzato"
|
||||
"description": "Come vengono visualizzate le date nell'elenco delle e-mail",
|
||||
"smart": "Intelligente (in base alla regione)",
|
||||
"relative": "Relativo (1 h fa, 2 g fa)",
|
||||
"full": "Sempre data completa",
|
||||
"preview_today": "Oggi:",
|
||||
"preview_this_week": "Questa settimana:",
|
||||
"preview_older": "Più vecchio:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Formato ora",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "Mostra anteprime delle immagini negli allegati",
|
||||
"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": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti",
|
||||
"group_by_letter_label": "Raggruppa per prima lettera"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Download",
|
||||
"description": "Personalizza come vengono nominati gli email e gli allegati scaricati.",
|
||||
"reset": "Ripristina predefinito",
|
||||
"preview": "Anteprima:",
|
||||
"email_template": {
|
||||
"label": "Nome file email (.eml)",
|
||||
"description": "Modello usato quando esporti un'email o la trascini sul file system. L'estensione .eml viene aggiunta automaticamente."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Nome file dell’allegato",
|
||||
"description": "Modello usato quando scarichi o trascini un allegato. Se ometti '{filename}' e '{ext}', l'estensione originale viene mantenuta."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Nome del file .zip multi-email",
|
||||
"description": "Modello usato quando trascini o scarichi più email selezionate come un singolo archivio .zip. L'estensione .zip viene aggiunta automaticamente."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Spazi",
|
||||
"description": "Sostituisci gli spazi nel nome file risultante con un altro carattere.",
|
||||
"keep": "Mantieni gli spazi",
|
||||
"underscore": "Sostituisci con _",
|
||||
"dash": "Sostituisci con -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Minuscolo",
|
||||
"description": "Forza l'intero nome file in minuscolo."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Rimuovi i segni diacritici",
|
||||
"description": "Converti le lettere accentate nei loro equivalenti ASCII (ä → a, é → e). Utile per strumenti che gestiscono male i nomi Unicode."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Riduci i separatori ripetuti",
|
||||
"description": "Riduci sequenze di spazi, underscore o trattini a un solo carattere."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Dopo l’esportazione",
|
||||
"description": "Sposta facoltativamente l’email dopo averla esportata come .eml.",
|
||||
"keep": "Mantieni nella casella",
|
||||
"archive": "Sposta nell’archivio",
|
||||
"trash": "Sposta nel cestino"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtri email",
|
||||
"description": "Crea regole per ordinare, etichettare e gestire automaticamente le email in arrivo",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "Etichetta",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
"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": {
|
||||
"mark_folder_read": "Segna cartella come letta",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "Nuova sottocartella...",
|
||||
"new_folder": "Nuova cartella...",
|
||||
"rename": "Rinomina...",
|
||||
"import_email": "Importa .eml...",
|
||||
"import_email": "Importa .eml o .zip...",
|
||||
"empty_folder": "Svuota cartella",
|
||||
"empty_folder_generic": "Svuota cartella",
|
||||
"delete_folder": "Elimina cartella",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "Espandi/comprimi conversazione"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Invia email",
|
||||
"schedule_send": "Programma invio",
|
||||
"template_picker": "Apri selettore modelli"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "メール",
|
||||
"nav_label": "ナビゲーション",
|
||||
"add_app": "アプリ",
|
||||
"shared": "共有"
|
||||
"shared": "共有",
|
||||
"scheduled": "予約済み"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "既定のアプリ",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "フォルダを空にする",
|
||||
"junk_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": {
|
||||
"no_email_selected": "メールが選択されていません",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "印刷",
|
||||
"view_source": "ソースを表示",
|
||||
"export_email": ".emlとしてエクスポート",
|
||||
"import_email": ".emlをインポート",
|
||||
"import_email": ".eml または .zip をインポート",
|
||||
"keyboard_shortcuts": "キーボードショートカット (?)",
|
||||
"email_source": "メールソース",
|
||||
"draft_banner": "このメッセージは下書きです",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "AIの判定",
|
||||
"account": "アカウント",
|
||||
"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": {
|
||||
"new_message": "新規メッセージ",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "リンクを追加",
|
||||
"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": "確認",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "編集を続ける",
|
||||
"tabs": {
|
||||
"appearance": "外観",
|
||||
"language": "言語と地域",
|
||||
"language": "言語、地域、時刻",
|
||||
"email": "メール動作",
|
||||
"composer": "作成",
|
||||
"privacy": "プライバシーとセキュリティ",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "レイアウト",
|
||||
"reading": "閲覧",
|
||||
"composing": "作成",
|
||||
"downloads": "ダウンロード",
|
||||
"content_senders": "コンテンツと送信者",
|
||||
"about_data": "情報とデータ",
|
||||
"debug": "デバッグ"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "統合メールボックス",
|
||||
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示"
|
||||
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示",
|
||||
"include_group": {
|
||||
"label": "グループ受信トレイを含める",
|
||||
"description": "共有/グループ受信トレイも統合ビューに含めます。"
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "カラフルなサイドバーアイコン",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "言語と地域",
|
||||
"description": "言語と地域の設定を構成",
|
||||
"title": "言語、地域、時刻",
|
||||
"description": "言語、日付形式、時刻形式、その他の地域設定",
|
||||
"language": {
|
||||
"label": "言語",
|
||||
"description": "お好みの言語を選択",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "日付形式",
|
||||
"description": "日付の表示形式",
|
||||
"regional": "地域設定",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "カスタム"
|
||||
"description": "メール一覧での日付の表示方法",
|
||||
"smart": "スマート(地域に合わせる)",
|
||||
"relative": "相対表示(1時間前、2日前)",
|
||||
"full": "常に完全な日付",
|
||||
"preview_today": "今日:",
|
||||
"preview_this_week": "今週:",
|
||||
"preview_older": "それ以前:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "時刻形式",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "添付ファイル内の画像プレビューを表示",
|
||||
"description": "画像の添付ファイルを汎用ファイルアイコンではなくサムネイルカードとして表示します"
|
||||
},
|
||||
"send_delay": {
|
||||
"label": "送信取り消し / 送信遅延",
|
||||
"description": "通常の送信をサーバー側で短時間遅延します。",
|
||||
"off": "オフ",
|
||||
"seconds": "{seconds} 秒",
|
||||
"unsupported": "現在のアカウントは遅延送信のサポートを通知していません。この設定は他のアカウント用に保存されます。"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示",
|
||||
"group_by_letter_label": "頭文字でグループ化"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "ダウンロード",
|
||||
"description": "ダウンロードしたメールや添付ファイルの名前の付け方をカスタマイズします。",
|
||||
"reset": "既定に戻す",
|
||||
"preview": "プレビュー:",
|
||||
"email_template": {
|
||||
"label": "メールのファイル名 (.eml)",
|
||||
"description": "メールをエクスポートするか、ファイルシステムにドラッグしたときに使用するテンプレートです。.eml 拡張子は自動的に付加されます。"
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "添付ファイルのファイル名",
|
||||
"description": "添付ファイルをダウンロードまたはドラッグするときに使用するテンプレートです。'{filename}' と '{ext}' を省略すると、元の拡張子が保持されます。"
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "複数メールの .zip ファイル名",
|
||||
"description": "複数の選択したメールを 1 つの .zip としてドラッグまたはダウンロードするときに使用するテンプレートです。拡張子 .zip は自動的に付加されます。"
|
||||
},
|
||||
"spaces": {
|
||||
"label": "スペース",
|
||||
"description": "最終的なファイル名のスペースを別の文字に置き換えます。",
|
||||
"keep": "スペースを残す",
|
||||
"underscore": "_ に置換",
|
||||
"dash": "- に置換"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "小文字",
|
||||
"description": "ファイル名全体を小文字に強制します。"
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "ダイアクリティカルマークを除去",
|
||||
"description": "アクセント付き文字を ASCII の同等の文字に変換します (ä → a, é → e)。Unicode のファイル名を正しく扱えないツールで便利です。"
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "連続した区切り文字をまとめる",
|
||||
"description": "連続するスペース、アンダースコア、ハイフンを 1 文字にまとめます。"
|
||||
},
|
||||
"after_export": {
|
||||
"label": "エクスポート後",
|
||||
"description": "メールを .eml としてエクスポートした後、必要に応じて移動します。",
|
||||
"keep": "メールボックスに残す",
|
||||
"archive": "アーカイブへ移動",
|
||||
"trash": "ごみ箱へ移動"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "メールフィルター",
|
||||
"description": "受信メールを自動で振り分け、ラベル付け、管理するルールを作成します",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "ラベル",
|
||||
"remove_color": "ラベルを削除",
|
||||
"items_selected": "{count}件のメールを選択",
|
||||
"edit_draft": "下書きを編集"
|
||||
"edit_draft": "下書きを編集",
|
||||
"cancel_scheduled_send": "送信をキャンセル",
|
||||
"reschedule_send": "再予約",
|
||||
"cancel_and_edit": "キャンセルして編集",
|
||||
"cancel_and_compose_again": "キャンセルして新規作成"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "フォルダーを既読にする",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "新しいサブフォルダー...",
|
||||
"new_folder": "新しいフォルダー...",
|
||||
"rename": "名前を変更...",
|
||||
"import_email": ".eml をインポート...",
|
||||
"import_email": ".eml または .zip をインポート...",
|
||||
"empty_folder": "フォルダーを空にする",
|
||||
"empty_folder_generic": "フォルダーを空にする",
|
||||
"delete_folder": "フォルダーを削除",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "スレッドの展開/折りたたみ"
|
||||
},
|
||||
"composer": {
|
||||
"send": "メールを送信",
|
||||
"schedule_send": "送信を予約",
|
||||
"template_picker": "テンプレートピッカーを開く"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "메일",
|
||||
"nav_label": "내비게이션",
|
||||
"add_app": "앱",
|
||||
"shared": "공유됨"
|
||||
"shared": "공유됨",
|
||||
"scheduled": "예약됨"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "기본 앱",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "폴더 비우기",
|
||||
"junk_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": {
|
||||
"no_email_selected": "메일이 선택되지 않았어요",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "인쇄",
|
||||
"view_source": "원본 보기",
|
||||
"export_email": ".eml 파일로 내보내기",
|
||||
"import_email": ".eml 파일 가져오기",
|
||||
"import_email": ".eml 또는 .zip 가져오기",
|
||||
"keyboard_shortcuts": "단축키 (?)",
|
||||
"email_source": "메일 원본",
|
||||
"draft_banner": "작성 중인 임시보관 메일이에요",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "AI 판정",
|
||||
"account": "계정",
|
||||
"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": {
|
||||
"new_message": "새 메시지",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "링크 추가",
|
||||
"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": "확인",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "계속 수정하기",
|
||||
"tabs": {
|
||||
"appearance": "화면 설정",
|
||||
"language": "언어 및 지역",
|
||||
"language": "언어, 지역 및 시간",
|
||||
"email": "메일 동작",
|
||||
"composer": "메일 쓰기",
|
||||
"privacy": "개인정보 및 보안",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "레이아웃",
|
||||
"reading": "읽기",
|
||||
"composing": "작성",
|
||||
"downloads": "다운로드",
|
||||
"content_senders": "콘텐츠 및 발신자",
|
||||
"about_data": "정보 및 데이터",
|
||||
"debug": "디버그"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "통합 메일함",
|
||||
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다"
|
||||
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다",
|
||||
"include_group": {
|
||||
"label": "그룹 받은편지함 포함",
|
||||
"description": "공유/그룹 받은편지함도 통합 보기에 포함합니다."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "컬러풀한 사이드바 아이콘",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "언어 및 지역",
|
||||
"description": "언어와 지역 형식을 설정해 주세요",
|
||||
"title": "언어, 지역 및 시간",
|
||||
"description": "언어, 날짜 형식, 시간 형식 및 기타 지역 환경설정",
|
||||
"language": {
|
||||
"label": "언어",
|
||||
"description": "사용할 언어를 선택해 주세요",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "날짜 형식",
|
||||
"description": "날짜가 표시되는 방식을 설정해요",
|
||||
"regional": "지역 설정",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "사용자 지정"
|
||||
"description": "이메일 목록에 날짜가 표시되는 방식",
|
||||
"smart": "스마트 (지역에 맞춤)",
|
||||
"relative": "상대 시간 (1시간 전, 2일 전)",
|
||||
"full": "항상 전체 날짜",
|
||||
"preview_today": "오늘:",
|
||||
"preview_this_week": "이번 주:",
|
||||
"preview_older": "이전:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "시간 형식",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "첨부 파일에 이미지 미리보기 표시",
|
||||
"description": "이미지 첨부 파일을 일반 파일 아이콘 대신 썸네일 카드로 표시합니다"
|
||||
},
|
||||
"send_delay": {
|
||||
"label": "보내기 취소 / 보내기 지연",
|
||||
"description": "일반 보내기를 서버 측 짧은 시간 동안 지연합니다.",
|
||||
"off": "끔",
|
||||
"seconds": "{seconds}초",
|
||||
"unsupported": "현재 계정은 지연 보내기 지원을 알리지 않습니다. 설정은 다른 계정을 위해 계속 저장됩니다."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시",
|
||||
"group_by_letter_label": "첫 글자로 그룹화"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "다운로드",
|
||||
"description": "다운로드한 이메일과 첨부 파일의 이름을 지정하는 방식을 사용자 정의합니다.",
|
||||
"reset": "기본값 복원",
|
||||
"preview": "미리 보기:",
|
||||
"email_template": {
|
||||
"label": "이메일 파일 이름 (.eml)",
|
||||
"description": "이메일을 내보내거나 파일 시스템으로 드래그할 때 사용하는 템플릿입니다. .eml 확장자는 자동으로 추가됩니다."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "첨부 파일 이름",
|
||||
"description": "첨부 파일을 다운로드하거나 드래그할 때 사용하는 템플릿입니다. '{filename}'과 '{ext}'를 생략하면 원래 확장자가 유지됩니다."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "다중 이메일 .zip 파일 이름",
|
||||
"description": "여러 선택한 이메일을 하나의 .zip 아카이브로 드래그하거나 다운로드할 때 사용하는 템플릿입니다. .zip 확장자는 자동으로 추가됩니다."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "공백",
|
||||
"description": "결과 파일 이름의 공백을 다른 문자로 바꿉니다.",
|
||||
"keep": "공백 유지",
|
||||
"underscore": "_로 바꾸기",
|
||||
"dash": "-로 바꾸기"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "소문자",
|
||||
"description": "전체 파일 이름을 소문자로 강제 변환합니다."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "발음 구별 부호 제거",
|
||||
"description": "악센트가 있는 문자를 ASCII 등가 문자로 변환합니다 (ä → a, é → e). 유니코드 파일 이름을 제대로 처리하지 못하는 도구에 유용합니다."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "반복된 구분자 결합",
|
||||
"description": "연속된 공백, 밑줄, 대시를 단일 문자로 결합합니다."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "내보낸 후",
|
||||
"description": ".eml로 내보낸 후 이메일을 선택적으로 이동합니다.",
|
||||
"keep": "메일함에 유지",
|
||||
"archive": "보관함으로 이동",
|
||||
"trash": "휴지통으로 이동"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "이메일 필터",
|
||||
"description": "새로 온 메일을 자동으로 분류하고 라벨을 붙이는 규칙을 만들어 보세요",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "태그",
|
||||
"remove_color": "태그 제거",
|
||||
"items_selected": "{count}개의 메일 선택됨",
|
||||
"edit_draft": "임시보관 메일 수정"
|
||||
"edit_draft": "임시보관 메일 수정",
|
||||
"cancel_scheduled_send": "보내기 취소",
|
||||
"reschedule_send": "다시 예약",
|
||||
"cancel_and_edit": "취소하고 편집",
|
||||
"cancel_and_compose_again": "취소하고 다시 작성"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "폴더를 읽음으로 표시",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "새 하위 폴더...",
|
||||
"new_folder": "새 폴더...",
|
||||
"rename": "이름 바꾸기...",
|
||||
"import_email": ".eml 가져오기...",
|
||||
"import_email": ".eml 또는 .zip 가져오기...",
|
||||
"empty_folder": "폴더 비우기",
|
||||
"empty_folder_generic": "폴더 비우기",
|
||||
"delete_folder": "폴더 삭제",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "대화 펼치기/접기"
|
||||
},
|
||||
"composer": {
|
||||
"send": "이메일 보내기",
|
||||
"schedule_send": "보내기 예약",
|
||||
"template_picker": "템플릿 선택기 열기"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "Pasts",
|
||||
"nav_label": "Navigācija",
|
||||
"add_app": "Lietotnes",
|
||||
"shared": "Koplietots"
|
||||
"shared": "Koplietots",
|
||||
"scheduled": "Ieplānots"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Noklusējuma lietotnes",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Iztīrīt mapi",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"no_email_selected": "Nav atlasīta neviena vēstule",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Drukāt",
|
||||
"view_source": "Skatīt avota kodu",
|
||||
"export_email": "Eksportēt kā .eml",
|
||||
"import_email": "Importēt .eml",
|
||||
"import_email": "Importēt .eml vai .zip",
|
||||
"keyboard_shortcuts": "Īsinājumtaustiņi (?)",
|
||||
"email_source": "Vēstules avota kods",
|
||||
"draft_banner": "Šī vēstule ir melnraksts",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "AI vērtējums",
|
||||
"account": "Konts",
|
||||
"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": {
|
||||
"new_message": "Jauns ziņojums",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "Pievienot saiti",
|
||||
"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": "Apstiprināt",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "Turpināt rediģēšanu",
|
||||
"tabs": {
|
||||
"appearance": "Izskats",
|
||||
"language": "Valoda un reģions",
|
||||
"language": "Valoda, reģions un laiks",
|
||||
"email": "Pasta darbība",
|
||||
"composer": "Redaktors",
|
||||
"privacy": "Privātums un drošība",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "Izkārtojums",
|
||||
"reading": "Lasīšana",
|
||||
"composing": "Rakstīšana",
|
||||
"downloads": "Lejupielādes",
|
||||
"content_senders": "Saturs un sūtītāji",
|
||||
"about_data": "Par un dati",
|
||||
"debug": "Atkļūdošana"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Apvienotā pastkaste",
|
||||
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem"
|
||||
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem",
|
||||
"include_group": {
|
||||
"label": "Iekļaut grupas pastkastes",
|
||||
"description": "Iekļaut apvienotajā skatā arī koplietotās/grupas pastkastes."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Krāsainas sānjoslas ikonas",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Valoda un reģions",
|
||||
"description": "Iestatiet valodas un reģionālās preferences",
|
||||
"title": "Valoda, reģions un laiks",
|
||||
"description": "Valoda, datuma formāts, laika formāts un citas reģionālās preferences",
|
||||
"language": {
|
||||
"label": "Valoda",
|
||||
"description": "Izvēlieties vēlamo valodu",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Datuma formāts",
|
||||
"description": "Kā attēlot datumus",
|
||||
"regional": "Reģionālais",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Pielāgots"
|
||||
"description": "Kā datumi tiek attēloti e-pasta sarakstā",
|
||||
"smart": "Gudrs (atbilstoši reģionam)",
|
||||
"relative": "Relatīvs (pirms 1 st., pirms 2 d.)",
|
||||
"full": "Vienmēr pilns datums",
|
||||
"preview_today": "Šodien:",
|
||||
"preview_this_week": "Šajā nedēļā:",
|
||||
"preview_older": "Vecāks:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Laika formāts",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"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"
|
||||
},
|
||||
"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": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā",
|
||||
"group_by_letter_label": "Grupēt pēc pirmā burta"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Lejupielādes",
|
||||
"description": "Pielāgojiet, kā tiek nosaukti lejupielādētie e-pasti un pielikumi.",
|
||||
"reset": "Atjaunot noklusējumu",
|
||||
"preview": "Priekšskatījums:",
|
||||
"email_template": {
|
||||
"label": "E-pasta faila nosaukums (.eml)",
|
||||
"description": "Veidne, ko izmanto, eksportējot e-pastu vai velkot to uz failu sistēmu. Paplašinājums .eml tiek pievienots automātiski."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Pielikuma faila nosaukums",
|
||||
"description": "Veidne, ko izmanto, lejupielādējot vai velkot pielikumu. Ja izlaižat '{filename}' un '{ext}', sākotnējais paplašinājums tiek saglabāts."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Vairāku e-pastu .zip faila nosaukums",
|
||||
"description": "Veidne, ko izmanto, kad velkat vai lejupielādējat vairākus atlasītos e-pastus kā vienu .zip arhīvu. Paplašinājums .zip tiek pievienots automātiski."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Atstarpes",
|
||||
"description": "Aizstāt atstarpes izvades faila nosaukumā ar citu rakstzīmi.",
|
||||
"keep": "Saglabāt atstarpes",
|
||||
"underscore": "Aizstāt ar _",
|
||||
"dash": "Aizstāt ar -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Mazie burti",
|
||||
"description": "Piespiest visu faila nosaukumu uz mazajiem burtiem."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Noņemt diakritikas zīmes",
|
||||
"description": "Pārvērst burtus ar akcentiem to ASCII ekvivalentos (ä → a, é → e). Noderīgi rīkiem, kas slikti apstrādā Unicode failu nosaukumus."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Apvienot atkārtotus atdalītājus",
|
||||
"description": "Apvienot virknes atstarpju, pasvītrojumu vai defisu vienā rakstzīmē."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Pēc eksporta",
|
||||
"description": "Pēc e-pasta eksportēšanas kā .eml to var pārvietot.",
|
||||
"keep": "Paturēt pastkastē",
|
||||
"archive": "Pārvietot uz arhīvu",
|
||||
"trash": "Pārvietot uz miskasti"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Pasta filtri",
|
||||
"description": "Izveidojiet noteikumus automātiskai vēstuļu šķirošanai un pārvaldībai",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "Tags",
|
||||
"remove_color": "Noņemt tagu",
|
||||
"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": {
|
||||
"mark_folder_read": "Atzīmēt mapi kā lasītu",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "Jauna apakšmape...",
|
||||
"new_folder": "Jauna mape...",
|
||||
"rename": "Pārsaukt...",
|
||||
"import_email": "Importēt .eml...",
|
||||
"import_email": "Importēt .eml vai .zip...",
|
||||
"empty_folder": "Iztukšot mapi",
|
||||
"empty_folder_generic": "Iztukšot mapi",
|
||||
"delete_folder": "Dzēst mapi",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "Izvērst/sairt sarunu"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Nosūtīt e-pastu",
|
||||
"schedule_send": "Ieplānot nosūtīšanu",
|
||||
"template_picker": "Atvērt veidņu izvēli"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navigatie",
|
||||
"add_app": "Apps",
|
||||
"shared": "Gedeeld"
|
||||
"shared": "Gedeeld",
|
||||
"scheduled": "Gepland"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Standaardapps",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Map legen",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"no_email_selected": "Geen e-mail geselecteerd",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Afdrukken",
|
||||
"view_source": "Bron bekijken",
|
||||
"export_email": "Exporteren als .eml",
|
||||
"import_email": ".eml importeren",
|
||||
"import_email": ".eml of .zip importeren",
|
||||
"keyboard_shortcuts": "Sneltoetsen (?)",
|
||||
"email_source": "E-mailbron",
|
||||
"draft_banner": "Dit bericht is een concept",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "AI-oordeel",
|
||||
"account": "Account",
|
||||
"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": {
|
||||
"new_message": "Nieuw bericht",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "Link toevoegen",
|
||||
"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": "Bevestigen",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "Doorgaan met bewerken",
|
||||
"tabs": {
|
||||
"appearance": "Uiterlijk",
|
||||
"language": "Taal & Regio",
|
||||
"language": "Taal, Regio & Tijd",
|
||||
"email": "E-mailgedrag",
|
||||
"composer": "Opstellen",
|
||||
"privacy": "Privacy & Beveiliging",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "Indeling",
|
||||
"reading": "Lezen",
|
||||
"composing": "Opstellen",
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Inhoud en afzenders",
|
||||
"about_data": "Over en gegevens",
|
||||
"debug": "Debuggen"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Gecombineerd postvak",
|
||||
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven"
|
||||
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven",
|
||||
"include_group": {
|
||||
"label": "Groepspostvakken meenemen",
|
||||
"description": "Gedeelde/groepspostvakken ook in de gecombineerde weergave opnemen."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Gekleurde zijbalkpictogrammen",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Taal & Regio",
|
||||
"description": "Configureer taal- en regiovoorkeuren",
|
||||
"title": "Taal, Regio & Tijd",
|
||||
"description": "Taal, datumnotatie, tijdnotatie en andere regionale voorkeuren",
|
||||
"language": {
|
||||
"label": "Taal",
|
||||
"description": "Kies je voorkeurstaal",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Datumnotatie",
|
||||
"description": "Hoe datums moeten worden weergegeven",
|
||||
"regional": "Regionaal",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Aangepast"
|
||||
"description": "Hoe datums worden weergegeven in de e-maillijst",
|
||||
"smart": "Slim (regionaal)",
|
||||
"relative": "Relatief (1 u geleden, 2 d geleden)",
|
||||
"full": "Altijd volledige datum",
|
||||
"preview_today": "Vandaag:",
|
||||
"preview_this_week": "Deze week:",
|
||||
"preview_older": "Ouder:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Tijdnotatie",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "Voorbeelden van afbeeldingen tonen in bijlagen",
|
||||
"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": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst",
|
||||
"group_by_letter_label": "Groeperen op eerste letter"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
"description": "Pas aan hoe gedownloade e-mails en bijlagen worden genoemd.",
|
||||
"reset": "Standaard herstellen",
|
||||
"preview": "Voorbeeld:",
|
||||
"email_template": {
|
||||
"label": "E-mailbestandsnaam (.eml)",
|
||||
"description": "Sjabloon dat wordt gebruikt bij het exporteren of slepen van een e-mail naar het bestandssysteem. De extensie .eml wordt automatisch toegevoegd."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Bestandsnaam van bijlage",
|
||||
"description": "Sjabloon bij het downloaden of slepen van een bijlage. Als je '{filename}' en '{ext}' weglaat, blijft de oorspronkelijke extensie behouden."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Bestandsnaam .zip met meerdere e-mails",
|
||||
"description": "Sjabloon dat wordt gebruikt wanneer je meerdere geselecteerde e-mails als één .zip-archief sleept of downloadt. De extensie .zip wordt automatisch toegevoegd."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Spaties",
|
||||
"description": "Vervang spaties in de uiteindelijke bestandsnaam door een ander teken.",
|
||||
"keep": "Spaties behouden",
|
||||
"underscore": "Vervangen door _",
|
||||
"dash": "Vervangen door -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Kleine letters",
|
||||
"description": "Forceer de hele bestandsnaam in kleine letters."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Diakritische tekens verwijderen",
|
||||
"description": "Zet letters met accenten om naar hun ASCII-equivalent (ä → a, é → e). Handig voor tools die Unicode-namen niet goed verwerken."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Herhaalde scheidingstekens samenvoegen",
|
||||
"description": "Voeg reeksen spaties, underscores of streepjes samen tot één teken."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Na exporteren",
|
||||
"description": "Verplaats de e-mail eventueel nadat deze als .eml is geëxporteerd.",
|
||||
"keep": "In postvak houden",
|
||||
"archive": "Naar archief verplaatsen",
|
||||
"trash": "Naar prullenbak verplaatsen"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-mailfilters",
|
||||
"description": "Maak regels om inkomende e-mails automatisch te sorteren, labelen en beheren",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "Label",
|
||||
"remove_color": "Label verwijderen",
|
||||
"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": {
|
||||
"mark_folder_read": "Map markeren als gelezen",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "Nieuwe submap...",
|
||||
"new_folder": "Nieuwe map...",
|
||||
"rename": "Hernoemen...",
|
||||
"import_email": ".eml importeren...",
|
||||
"import_email": ".eml of .zip importeren...",
|
||||
"empty_folder": "Map leegmaken",
|
||||
"empty_folder_generic": "Map leegmaken",
|
||||
"delete_folder": "Map verwijderen",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "Gesprek uitklappen/inklappen"
|
||||
},
|
||||
"composer": {
|
||||
"send": "E-mail verzenden",
|
||||
"schedule_send": "Verzenden plannen",
|
||||
"template_picker": "Sjabloonkiezer openen"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "Poczta",
|
||||
"nav_label": "Nawigacja",
|
||||
"add_app": "Aplikacje",
|
||||
"shared": "Udostępnione"
|
||||
"shared": "Udostępnione",
|
||||
"scheduled": "Zaplanowane"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Aplikacje domyślne",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Opróżnij folder",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"no_email_selected": "Nie wybrano wiadomości",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Drukuj",
|
||||
"view_source": "Pokaż źródło",
|
||||
"export_email": "Eksportuj jako .eml",
|
||||
"import_email": "Importuj .eml",
|
||||
"import_email": "Importuj .eml lub .zip",
|
||||
"keyboard_shortcuts": "Skróty klawiszowe (?)",
|
||||
"email_source": "Źródło wiadomości",
|
||||
"draft_banner": "Ta wiadomość jest szkicem",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "Werdykt AI",
|
||||
"account": "Konto",
|
||||
"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": {
|
||||
"new_message": "Nowa wiadomość",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "Dodaj link",
|
||||
"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": "Potwierdź",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "Kontynuuj edycję",
|
||||
"tabs": {
|
||||
"appearance": "Wygląd",
|
||||
"language": "Język i region",
|
||||
"language": "Język, region i czas",
|
||||
"email": "Zachowanie poczty e-mail",
|
||||
"composer": "Redagowanie",
|
||||
"privacy": "Prywatność i bezpieczeństwo",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "Układ",
|
||||
"reading": "Czytanie",
|
||||
"composing": "Tworzenie",
|
||||
"downloads": "Pobrane",
|
||||
"content_senders": "Treść i nadawcy",
|
||||
"about_data": "O programie i dane",
|
||||
"debug": "Debugowanie"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Wspólna skrzynka",
|
||||
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont"
|
||||
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont",
|
||||
"include_group": {
|
||||
"label": "Uwzględnij skrzynki grupowe",
|
||||
"description": "Dodaj również udostępnione/grupowe skrzynki do widoku wspólnego."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Kolorowe ikony paska bocznego",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Język i region",
|
||||
"description": "Skonfiguruj preferencje językowe i regionalne",
|
||||
"title": "Język, region i czas",
|
||||
"description": "Język, format daty, format godziny i inne preferencje regionalne",
|
||||
"language": {
|
||||
"label": "Język",
|
||||
"description": "Wybierz preferowany język",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Format daty",
|
||||
"description": "Jak mają być wyświetlane daty",
|
||||
"regional": "Regionalny",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Niestandardowy"
|
||||
"description": "Jak daty są wyświetlane na liście e-maili",
|
||||
"smart": "Inteligentny (regionalny)",
|
||||
"relative": "Względny (1 g temu, 2 d temu)",
|
||||
"full": "Zawsze pełna data",
|
||||
"preview_today": "Dzisiaj:",
|
||||
"preview_this_week": "W tym tygodniu:",
|
||||
"preview_older": "Starsze:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Format czasu",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "Pokaż podgląd obrazów w załącznikach",
|
||||
"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": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów",
|
||||
"group_by_letter_label": "Grupuj według pierwszej litery"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Pobrane",
|
||||
"description": "Dostosuj sposób nazywania pobranych wiadomości i załączników.",
|
||||
"reset": "Przywróć domyślne",
|
||||
"preview": "Podgląd:",
|
||||
"email_template": {
|
||||
"label": "Nazwa pliku wiadomości (.eml)",
|
||||
"description": "Szablon używany przy eksporcie wiadomości lub jej przeciąganiu do systemu plików. Rozszerzenie .eml jest dodawane automatycznie."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Nazwa pliku załącznika",
|
||||
"description": "Szablon używany przy pobieraniu lub przeciąganiu załącznika. Jeśli pominiesz '{filename}' i '{ext}', oryginalne rozszerzenie zostanie zachowane."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Nazwa pliku .zip wielu wiadomości",
|
||||
"description": "Szablon używany przy przeciąganiu lub pobieraniu kilku wybranych wiadomości jako jednego archiwum .zip. Rozszerzenie .zip jest dodawane automatycznie."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Spacje",
|
||||
"description": "Zastąp spacje w nazwie pliku innym znakiem.",
|
||||
"keep": "Zachowaj spacje",
|
||||
"underscore": "Zamień na _",
|
||||
"dash": "Zamień na -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Małe litery",
|
||||
"description": "Wymuś małe litery w całej nazwie pliku."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Usuń znaki diakrytyczne",
|
||||
"description": "Zamień litery z akcentami na ich odpowiedniki ASCII (ą → a, é → e). Przydatne dla narzędzi, które źle obsługują nazwy Unicode."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Łącz powtórzone separatory",
|
||||
"description": "Połącz ciągi spacji, podkreśleń lub myślników w jeden znak."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Po eksporcie",
|
||||
"description": "Opcjonalnie przenieś wiadomość po wyeksportowaniu jako .eml.",
|
||||
"keep": "Zachowaj w skrzynce",
|
||||
"archive": "Przenieś do archiwum",
|
||||
"trash": "Przenieś do kosza"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtry wiadomości e-mail",
|
||||
"description": "Twórz reguły, aby automatycznie sortować, etykietować i zarządzać przychodzącymi wiadomościami e-mail",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "Etykieta",
|
||||
"remove_color": "Usuń etykietę",
|
||||
"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": {
|
||||
"mark_folder_read": "Oznacz folder jako przeczytany",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "Nowy podfolder...",
|
||||
"new_folder": "Nowy folder...",
|
||||
"rename": "Zmień nazwę...",
|
||||
"import_email": "Importuj .eml...",
|
||||
"import_email": "Importuj .eml lub .zip...",
|
||||
"empty_folder": "Opróżnij folder",
|
||||
"empty_folder_generic": "Opróżnij folder",
|
||||
"delete_folder": "Usuń folder",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "Rozwiń/zwiń wątek"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Wyślij e-mail",
|
||||
"schedule_send": "Zaplanuj wysyłkę",
|
||||
"template_picker": "Otwórz wybór szablonu"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navegação",
|
||||
"add_app": "Apps",
|
||||
"shared": "Compartilhado"
|
||||
"shared": "Compartilhado",
|
||||
"scheduled": "Agendados"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Aplicativos padrão",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Esvaziar pasta",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"no_email_selected": "Nenhum e-mail selecionado",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Imprimir",
|
||||
"view_source": "Ver código-fonte",
|
||||
"export_email": "Exportar como .eml",
|
||||
"import_email": "Importar .eml",
|
||||
"import_email": "Importar .eml ou .zip",
|
||||
"keyboard_shortcuts": "Atalhos de teclado (?)",
|
||||
"email_source": "Código-fonte do E-mail",
|
||||
"draft_banner": "Esta mensagem é um rascunho",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "Veredito da IA",
|
||||
"account": "Conta",
|
||||
"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": {
|
||||
"new_message": "Nova Mensagem",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "Adicionar link",
|
||||
"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": "Confirmar",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "Continuar editando",
|
||||
"tabs": {
|
||||
"appearance": "Aparência",
|
||||
"language": "Idioma e Região",
|
||||
"language": "Idioma, Região e Hora",
|
||||
"email": "Comportamento de E-mail",
|
||||
"composer": "Editor",
|
||||
"privacy": "Privacidade e Segurança",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "Layout",
|
||||
"reading": "Leitura",
|
||||
"composing": "Composição",
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Conteúdo e remetentes",
|
||||
"about_data": "Sobre e dados",
|
||||
"debug": "Depuração"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Caixa de correio unificada",
|
||||
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas"
|
||||
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas",
|
||||
"include_group": {
|
||||
"label": "Incluir caixas de grupo",
|
||||
"description": "Incluir também as caixas partilhadas ou de grupo na vista unificada."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Ícones coloridos na barra lateral",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Idioma e Região",
|
||||
"description": "Configure preferências de idioma e região",
|
||||
"title": "Idioma, Região e Hora",
|
||||
"description": "Idioma, formato de data, formato de hora e outras preferências regionais",
|
||||
"language": {
|
||||
"label": "Idioma",
|
||||
"description": "Escolha seu idioma preferido",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Formato de Data",
|
||||
"description": "Como as datas devem ser exibidas",
|
||||
"regional": "Regional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Personalizado"
|
||||
"description": "Como as datas são mostradas na lista de e-mails",
|
||||
"smart": "Inteligente (regional)",
|
||||
"relative": "Relativo (há 1 h, há 2 d)",
|
||||
"full": "Sempre data completa",
|
||||
"preview_today": "Hoje:",
|
||||
"preview_this_week": "Esta semana:",
|
||||
"preview_older": "Mais antigo:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Formato de Hora",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "Mostrar prévias de imagens em anexos",
|
||||
"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": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos",
|
||||
"group_by_letter_label": "Agrupar pela primeira letra"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
"description": "Personalize como os e-mails e anexos baixados são nomeados.",
|
||||
"reset": "Restaurar padrão",
|
||||
"preview": "Pré-visualização:",
|
||||
"email_template": {
|
||||
"label": "Nome do arquivo de e-mail (.eml)",
|
||||
"description": "Modelo usado ao exportar um e-mail ou arrastá-lo para o sistema de arquivos. A extensão .eml é adicionada automaticamente."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Nome do arquivo do anexo",
|
||||
"description": "Modelo usado ao baixar ou arrastar um anexo. Se você omitir '{filename}' e '{ext}', a extensão original é preservada."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Nome do arquivo .zip multi-e-mails",
|
||||
"description": "Modelo usado ao arrastar ou baixar vários e-mails selecionados como um único arquivo .zip. A extensão .zip é adicionada automaticamente."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Espaços",
|
||||
"description": "Substitui espaços no nome de arquivo resultante por outro caractere.",
|
||||
"keep": "Manter espaços",
|
||||
"underscore": "Substituir por _",
|
||||
"dash": "Substituir por -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Minúsculas",
|
||||
"description": "Forçar todo o nome do arquivo para minúsculas."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Remover acentos",
|
||||
"description": "Converte letras acentuadas para o equivalente ASCII (ä → a, é → e). Útil para ferramentas que estragam nomes Unicode."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Combinar separadores repetidos",
|
||||
"description": "Combina sequências de espaços, sublinhados ou hifens em um único caractere."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Após exportar",
|
||||
"description": "Opcionalmente, mover o e-mail depois de exportá-lo como .eml.",
|
||||
"keep": "Manter na caixa",
|
||||
"archive": "Mover para o arquivo",
|
||||
"trash": "Mover para a lixeira"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtros de e-mail",
|
||||
"description": "Crie regras para classificar, rotular e gerenciar automaticamente os e-mails recebidos",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Remover etiqueta",
|
||||
"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": {
|
||||
"mark_folder_read": "Marcar pasta como lida",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "Nova subpasta...",
|
||||
"new_folder": "Nova pasta...",
|
||||
"rename": "Renomear...",
|
||||
"import_email": "Importar .eml...",
|
||||
"import_email": "Importar .eml ou .zip...",
|
||||
"empty_folder": "Esvaziar pasta",
|
||||
"empty_folder_generic": "Esvaziar pasta",
|
||||
"delete_folder": "Excluir pasta",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "Expandir/recolher conversa"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Enviar e-mail",
|
||||
"schedule_send": "Agendar envio",
|
||||
"template_picker": "Abrir seletor de modelos"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "Почта",
|
||||
"nav_label": "Навигация",
|
||||
"add_app": "Приложения",
|
||||
"shared": "Общие"
|
||||
"shared": "Общие",
|
||||
"scheduled": "Запланировано"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Приложения по умолчанию",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Очистить папку",
|
||||
"junk_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": {
|
||||
"no_email_selected": "Письмо не выбрано",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Распечатать",
|
||||
"view_source": "Просмотреть исходный код",
|
||||
"export_email": "Экспортировать как .eml",
|
||||
"import_email": "Импортировать .eml",
|
||||
"import_email": "Импортировать .eml или .zip",
|
||||
"keyboard_shortcuts": "Сочетания клавиш (?)",
|
||||
"email_source": "Исходный код письма",
|
||||
"draft_banner": "Это письмо является черновиком",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "Вердикт ИИ",
|
||||
"account": "Аккаунт",
|
||||
"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": {
|
||||
"new_message": "Новое письмо",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "Добавить ссылку",
|
||||
"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": "Подтвердить",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "Продолжить редактирование",
|
||||
"tabs": {
|
||||
"appearance": "Внешний вид",
|
||||
"language": "Язык и регион",
|
||||
"language": "Язык, регион и время",
|
||||
"email": "Поведение почты",
|
||||
"composer": "Редактор",
|
||||
"privacy": "Конфиденциальность и безопасность",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "Макет",
|
||||
"reading": "Чтение",
|
||||
"composing": "Написание",
|
||||
"downloads": "Загрузки",
|
||||
"content_senders": "Содержимое и отправители",
|
||||
"about_data": "О программе и данные",
|
||||
"debug": "Отладка"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Общий почтовый ящик",
|
||||
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов"
|
||||
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов",
|
||||
"include_group": {
|
||||
"label": "Включать групповые ящики",
|
||||
"description": "Также объединять общие/групповые ящики в едином представлении."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Цветные значки боковой панели",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Язык и регион",
|
||||
"description": "Настройте языковые и региональные предпочтения",
|
||||
"title": "Язык, регион и время",
|
||||
"description": "Язык, формат даты, формат времени и другие региональные настройки",
|
||||
"language": {
|
||||
"label": "Язык",
|
||||
"description": "Выберите предпочтительный язык",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Формат даты",
|
||||
"description": "Как отображать даты",
|
||||
"regional": "Региональный",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Пользовательский"
|
||||
"description": "Как отображаются даты в списке писем",
|
||||
"smart": "Умный (по региону)",
|
||||
"relative": "Относительный (1 ч назад, 2 д назад)",
|
||||
"full": "Всегда полная дата",
|
||||
"preview_today": "Сегодня:",
|
||||
"preview_this_week": "На этой неделе:",
|
||||
"preview_older": "Старее:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Формат времени",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "Показывать миниатюры изображений во вложениях",
|
||||
"description": "Отображать вложенные изображения в виде миниатюр вместо обычных значков файлов"
|
||||
},
|
||||
"send_delay": {
|
||||
"label": "Отмена отправки / задержка отправки",
|
||||
"description": "Задерживает обычную отправку на короткий серверный интервал.",
|
||||
"off": "Выкл.",
|
||||
"seconds": "{seconds} сек.",
|
||||
"unsupported": "Текущая учетная запись не заявляет поддержку отложенной отправки. Настройка сохранится для других учетных записей."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов",
|
||||
"group_by_letter_label": "Группировать по первой букве"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Загрузки",
|
||||
"description": "Настройте, как именуются загруженные письма и вложения.",
|
||||
"reset": "Восстановить по умолчанию",
|
||||
"preview": "Предпросмотр:",
|
||||
"email_template": {
|
||||
"label": "Имя файла письма (.eml)",
|
||||
"description": "Шаблон, используемый при экспорте письма или при перетаскивании его в файловую систему. Расширение .eml добавляется автоматически."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Имя файла вложения",
|
||||
"description": "Шаблон, используемый при загрузке или перетаскивании вложения. Если опустить '{filename}' и '{ext}', исходное расширение сохраняется."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Имя файла .zip с несколькими письмами",
|
||||
"description": "Шаблон, используемый при перетаскивании или загрузке нескольких выбранных писем одним .zip-архивом. Расширение .zip добавляется автоматически."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Пробелы",
|
||||
"description": "Заменять пробелы в итоговом имени файла другим символом.",
|
||||
"keep": "Сохранять пробелы",
|
||||
"underscore": "Заменять на _",
|
||||
"dash": "Заменять на -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Нижний регистр",
|
||||
"description": "Принудительно перевести всё имя файла в нижний регистр."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Удалять диакритику",
|
||||
"description": "Преобразовать буквы с акцентами в их ASCII-эквиваленты (ä → a, é → e). Полезно для инструментов, плохо работающих с Unicode-именами."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Сжимать повторяющиеся разделители",
|
||||
"description": "Сжимать последовательности пробелов, подчёркиваний или дефисов до одного символа."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "После экспорта",
|
||||
"description": "При необходимости переместить письмо после экспорта в .eml.",
|
||||
"keep": "Оставить в ящике",
|
||||
"archive": "Переместить в архив",
|
||||
"trash": "Переместить в корзину"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Фильтры почты",
|
||||
"description": "Создавайте правила для автоматической сортировки, маркировки и управления входящими письмами",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "Тег",
|
||||
"remove_color": "Удалить тег",
|
||||
"items_selected": "{count} писем выбрано",
|
||||
"edit_draft": "Редактировать черновик"
|
||||
"edit_draft": "Редактировать черновик",
|
||||
"cancel_scheduled_send": "Отменить отправку",
|
||||
"reschedule_send": "Перенести",
|
||||
"cancel_and_edit": "Отменить и изменить",
|
||||
"cancel_and_compose_again": "Отменить и написать заново"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Отметить папку как прочитанную",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "Новая вложенная папка...",
|
||||
"new_folder": "Новая папка...",
|
||||
"rename": "Переименовать...",
|
||||
"import_email": "Импортировать .eml...",
|
||||
"import_email": "Импортировать .eml или .zip...",
|
||||
"empty_folder": "Очистить папку",
|
||||
"empty_folder_generic": "Очистить папку",
|
||||
"delete_folder": "Удалить папку",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "Развернуть/свернуть цепочку"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Отправить письмо",
|
||||
"schedule_send": "Запланировать отправку",
|
||||
"template_picker": "Открыть выбор шаблонов"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"shared": "Paylaşılan",
|
||||
"mail": "Posta",
|
||||
"nav_label": "Gezinme",
|
||||
"add_app": "Uygulamalar"
|
||||
"add_app": "Uygulamalar",
|
||||
"scheduled": "Zamanlandı"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Varsayılan uygulamalar",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Klasörü boşalt",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"no_email_selected": "E-posta seçilmedi",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Yazdır",
|
||||
"view_source": "Kaynağı görüntüle",
|
||||
"export_email": ".eml olarak dışa aktar",
|
||||
"import_email": ".eml içe aktar",
|
||||
"import_email": ".eml veya .zip içe aktar",
|
||||
"keyboard_shortcuts": "Klavye kısayolları (?)",
|
||||
"email_source": "E-posta Kaynağı",
|
||||
"draft_banner": "Bu ileti bir taslaktır",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "Yapay zeka kararı",
|
||||
"account": "Hesap",
|
||||
"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": {
|
||||
"new_message": "Yeni İleti",
|
||||
@@ -607,7 +627,19 @@
|
||||
"message": "İletinizde \"{keyword}\" geçiyor ancak hiçbir dosya eklenmemiş. Yine de gönderilsin mi?",
|
||||
"send_anyway": "Yine de gönder",
|
||||
"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": "Onayla",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "Düzenlemeye devam et",
|
||||
"tabs": {
|
||||
"appearance": "Görünüm",
|
||||
"language": "Dil ve Bölge",
|
||||
"language": "Dil, Bölge ve Saat",
|
||||
"email": "E-posta Davranışı",
|
||||
"composer": "Yazıcı",
|
||||
"privacy": "Gizlilik ve Güvenlik",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "Düzen",
|
||||
"reading": "Okuma",
|
||||
"composing": "Yazma",
|
||||
"downloads": "İndirilenler",
|
||||
"content_senders": "İçerik ve Göndericiler",
|
||||
"about_data": "Hakkında ve Veriler",
|
||||
"debug": "Hata Ayıklama"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Birleşik Posta Kutusu",
|
||||
"description": "Bağlı tüm hesaplardaki birleşik klasörleri (Gelen Kutusu, Gönderilenler vb.) göster"
|
||||
"description": "Bağlı tüm hesaplardaki birleşik klasörleri (Gelen Kutusu, Gönderilenler vb.) göster",
|
||||
"include_group": {
|
||||
"label": "Grup gelen kutularını dahil et",
|
||||
"description": "Paylaşılan/grup gelen kutularını da birleşik görünüme dahil et."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Renkli Kenar Çubuğu Simgeleri",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Dil ve Bölge",
|
||||
"description": "Dil ve bölgesel tercihleri yapılandırın",
|
||||
"title": "Dil, Bölge ve Saat",
|
||||
"description": "Dil, tarih biçimi, saat biçimi ve diğer bölgesel tercihler",
|
||||
"language": {
|
||||
"label": "Dil",
|
||||
"description": "Tercih ettiğiniz dili seçin",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Tarih Biçimi",
|
||||
"description": "Tarihlerin nasıl görüntüleneceği",
|
||||
"regional": "Bölgesel",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Özel"
|
||||
"description": "Tarihlerin e-posta listesinde nasıl gösterileceği",
|
||||
"smart": "Akıllı (bölgeye duyarlı)",
|
||||
"relative": "Göreceli (1 sa önce, 2 g önce)",
|
||||
"full": "Her zaman tam tarih",
|
||||
"preview_today": "Bugün:",
|
||||
"preview_this_week": "Bu hafta:",
|
||||
"preview_older": "Daha eski:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Saat Biçimi",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "Eklerde resim önizlemelerini 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": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"categories_description": "Kişi kategorilerini yeniden adlandırın",
|
||||
"no_categories": "Kategori bulunamadı"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "İndirilenler",
|
||||
"description": "İndirilen e-postaların ve eklerin nasıl adlandırılacağını özelleştirin.",
|
||||
"reset": "Varsayılana sıfırla",
|
||||
"preview": "Önizleme:",
|
||||
"email_template": {
|
||||
"label": "E-posta dosya adı (.eml)",
|
||||
"description": "Bir e-postayı dışa aktarırken veya dosya sistemine sürüklediğinizde kullanılan şablon. .eml uzantısı otomatik olarak eklenir."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Ek dosyası adı",
|
||||
"description": "Bir eki indirirken veya sürüklerken kullanılan şablon. '{filename}' ve '{ext}' yazılmazsa orijinal uzantı korunur."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Çoklu e-posta .zip dosya adı",
|
||||
"description": "Birden çok seçili e-postayı tek bir .zip arşivi olarak sürüklediğinizde veya indirdiğinizde kullanılan şablon. .zip uzantısı otomatik olarak eklenir."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Boşluklar",
|
||||
"description": "Sonuçtaki dosya adındaki boşlukları başka bir karakterle değiştirin.",
|
||||
"keep": "Boşlukları koru",
|
||||
"underscore": "_ ile değiştir",
|
||||
"dash": "- ile değiştir"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Küçük harf",
|
||||
"description": "Tüm dosya adını küçük harfe zorla."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Aksanları kaldır",
|
||||
"description": "Aksanlı harfleri ASCII karşılıklarına dönüştür (ä → a, é → e). Unicode dosya adlarıyla sorun yaşayan araçlar için yararlıdır."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Tekrarlanan ayırıcıları birleştir",
|
||||
"description": "Art arda gelen boşluk, alt çizgi veya tireleri tek bir karaktere indir."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Dışa aktarmadan sonra",
|
||||
"description": "E-postayı .eml olarak dışa aktardıktan sonra isteğe bağlı olarak taşı.",
|
||||
"keep": "Posta kutusunda tut",
|
||||
"archive": "Arşive taşı",
|
||||
"trash": "Çöp kutusuna taşı"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-posta Filtreleri",
|
||||
"description": "Gelen e-postaları otomatik olarak sıralamak, etiketlemek ve yönetmek için kurallar oluşturun",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "Etiket",
|
||||
"remove_color": "Etiketi kaldır",
|
||||
"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": {
|
||||
"mark_folder_read": "Klasörü okundu olarak işaretle",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "Yeni alt klasör...",
|
||||
"new_folder": "Yeni klasör...",
|
||||
"rename": "Yeniden adlandır...",
|
||||
"import_email": ".eml içe aktar...",
|
||||
"import_email": ".eml veya .zip içe aktar...",
|
||||
"empty_folder": "Klasörü boşalt",
|
||||
"empty_folder_generic": "Klasörü boşalt",
|
||||
"delete_folder": "Klasörü sil",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "İleti dizisini genişlet/daralt"
|
||||
},
|
||||
"composer": {
|
||||
"send": "E-posta gönder",
|
||||
"schedule_send": "Göndermeyi planla",
|
||||
"template_picker": "Şablon seçiciyi aç"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "Пошта",
|
||||
"nav_label": "Навігація",
|
||||
"add_app": "програми",
|
||||
"shared": "Спільні"
|
||||
"shared": "Спільні",
|
||||
"scheduled": "Заплановано"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Програми за замовчуванням",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "Порожня папка",
|
||||
"junk_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": {
|
||||
"no_email_selected": "Електронна адреса не вибрана",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "Роздрукувати",
|
||||
"view_source": "Переглянути джерело",
|
||||
"export_email": "Експортувати як .eml",
|
||||
"import_email": "Імпорт .eml",
|
||||
"import_email": "Імпорт .eml або .zip",
|
||||
"keyboard_shortcuts": "Комбінації клавіш (?)",
|
||||
"email_source": "Джерело електронної пошти",
|
||||
"draft_banner": "Це повідомлення є чернеткою",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "Висновок ШІ",
|
||||
"account": "Обліковий запис",
|
||||
"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": {
|
||||
"new_message": "Нове повідомлення",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "Додати посилання",
|
||||
"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": "Підтвердити",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "Продовжуйте редагувати",
|
||||
"tabs": {
|
||||
"appearance": "Зовнішній вигляд",
|
||||
"language": "Мова та регіон",
|
||||
"language": "Мова, регіон і час",
|
||||
"email": "Поведінка електронної пошти",
|
||||
"composer": "Композитор",
|
||||
"privacy": "Конфіденційність і безпека",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "Макет",
|
||||
"reading": "Читання",
|
||||
"composing": "Написання",
|
||||
"downloads": "Завантаження",
|
||||
"content_senders": "Вміст і відправники",
|
||||
"about_data": "Про програму та дані",
|
||||
"debug": "Налагодження"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Спільна поштова скринька",
|
||||
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів"
|
||||
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів",
|
||||
"include_group": {
|
||||
"label": "Включати групові скриньки",
|
||||
"description": "Також об'єднувати спільні/групові скриньки у спільному перегляді."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Кольорові значки бічної панелі",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Мова та регіон",
|
||||
"description": "Налаштуйте мовні та регіональні параметри",
|
||||
"title": "Мова, регіон і час",
|
||||
"description": "Мова, формат дати, формат часу та інші регіональні налаштування",
|
||||
"language": {
|
||||
"label": "Мова",
|
||||
"description": "Виберіть бажану мову",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Формат дати",
|
||||
"description": "Як мають відображатися дати",
|
||||
"regional": "Регіональний",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Довільний"
|
||||
"description": "Як відображаються дати у списку листів",
|
||||
"smart": "Розумний (за регіоном)",
|
||||
"relative": "Відносний (1 год тому, 2 дн тому)",
|
||||
"full": "Завжди повна дата",
|
||||
"preview_today": "Сьогодні:",
|
||||
"preview_this_week": "Цього тижня:",
|
||||
"preview_older": "Старіше:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Формат часу",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "Показувати попередній перегляд зображень у вкладеннях",
|
||||
"description": "Відображати вкладені зображення як ескізи замість загальних піктограм файлів"
|
||||
},
|
||||
"send_delay": {
|
||||
"label": "Скасувати надсилання / затримка надсилання",
|
||||
"description": "Затримує звичайне надсилання на короткий серверний інтервал.",
|
||||
"off": "Вимкнено",
|
||||
"seconds": "{seconds} с",
|
||||
"unsupported": "Поточний обліковий запис не повідомляє про підтримку відкладеного надсилання. Налаштування залишиться збереженим для інших облікових записів."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів",
|
||||
"group_by_letter_label": "Групувати за першою літерою"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Завантаження",
|
||||
"description": "Налаштуйте, як називаються завантажені листи та вкладення.",
|
||||
"reset": "Відновити стандартне",
|
||||
"preview": "Попередній перегляд:",
|
||||
"email_template": {
|
||||
"label": "Ім'я файлу листа (.eml)",
|
||||
"description": "Шаблон, що використовується при експорті листа або перетягуванні його у файлову систему. Розширення .eml додається автоматично."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Ім'я файлу вкладення",
|
||||
"description": "Шаблон, що використовується при завантаженні або перетягуванні вкладення. Якщо пропустити '{filename}' та '{ext}', оригінальне розширення зберігається."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Ім'я файлу .zip з кількома листами",
|
||||
"description": "Шаблон, що використовується при перетягуванні або завантаженні кількох вибраних листів одним .zip-архівом. Розширення .zip додається автоматично."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Пробіли",
|
||||
"description": "Замінити пробіли в підсумковому імені файлу іншим символом.",
|
||||
"keep": "Залишити пробіли",
|
||||
"underscore": "Замінити на _",
|
||||
"dash": "Замінити на -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Нижній регістр",
|
||||
"description": "Примусово перетворити все ім'я файлу на нижній регістр."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Видалити діакритику",
|
||||
"description": "Перетворити літери з акцентами на ASCII-еквіваленти (ä → a, é → e). Корисно для інструментів, що погано обробляють Unicode."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Об'єднати повторювані роздільники",
|
||||
"description": "Об'єднати послідовності пробілів, підкреслень або дефісів в один символ."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Після експорту",
|
||||
"description": "За потреби перемістити лист після експорту як .eml.",
|
||||
"keep": "Залишити у скриньці",
|
||||
"archive": "Перемістити в архів",
|
||||
"trash": "Перемістити у кошик"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Фільтри електронної пошти",
|
||||
"description": "Створіть правила для автоматичного сортування, позначення та керування вхідними електронними листами",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "Мітка",
|
||||
"remove_color": "Видалити мітку",
|
||||
"items_selected": "Вибрано електронних листів: {count}",
|
||||
"edit_draft": "Редагувати чернетку"
|
||||
"edit_draft": "Редагувати чернетку",
|
||||
"cancel_scheduled_send": "Скасувати надсилання",
|
||||
"reschedule_send": "Перенести",
|
||||
"cancel_and_edit": "Скасувати й редагувати",
|
||||
"cancel_and_compose_again": "Скасувати й написати знову"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Позначити папку як прочитану",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "Нова вкладена папка...",
|
||||
"new_folder": "Нова папка...",
|
||||
"rename": "Перейменувати...",
|
||||
"import_email": "Імпортувати .eml...",
|
||||
"import_email": "Імпортувати .eml або .zip...",
|
||||
"empty_folder": "Очистити папку",
|
||||
"empty_folder_generic": "Очистити папку",
|
||||
"delete_folder": "Видалити папку",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "Розгорнути/згорнути ланцюжок"
|
||||
},
|
||||
"composer": {
|
||||
"send": "Надіслати лист",
|
||||
"schedule_send": "Запланувати надсилання",
|
||||
"template_picker": "Відкрити засіб вибору шаблону"
|
||||
}
|
||||
},
|
||||
|
||||
+112
-15
@@ -132,7 +132,8 @@
|
||||
"mail": "邮件",
|
||||
"nav_label": "导航",
|
||||
"add_app": "应用",
|
||||
"shared": "共享"
|
||||
"shared": "共享",
|
||||
"scheduled": "已计划"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "默认应用",
|
||||
@@ -239,7 +240,16 @@
|
||||
"confirm_button": "清空文件夹",
|
||||
"junk_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": {
|
||||
"no_email_selected": "未选择邮件",
|
||||
@@ -270,7 +280,7 @@
|
||||
"print": "打印",
|
||||
"view_source": "查看源码",
|
||||
"export_email": "导出为 .eml",
|
||||
"import_email": "导入 .eml",
|
||||
"import_email": "导入 .eml 或 .zip",
|
||||
"keyboard_shortcuts": "键盘快捷键(?)",
|
||||
"email_source": "邮件源码",
|
||||
"draft_banner": "这是一封草稿邮件",
|
||||
@@ -514,7 +524,17 @@
|
||||
"ai_verdict": "AI 判断",
|
||||
"account": "账户",
|
||||
"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": {
|
||||
"new_message": "新邮件",
|
||||
@@ -607,7 +627,19 @@
|
||||
},
|
||||
"add_link": "添加链接",
|
||||
"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": "确认",
|
||||
@@ -752,7 +784,7 @@
|
||||
"keep_editing": "继续编辑",
|
||||
"tabs": {
|
||||
"appearance": "外观",
|
||||
"language": "语言及地区",
|
||||
"language": "语言、地区与时间",
|
||||
"email": "邮件行为",
|
||||
"composer": "邮件撰写",
|
||||
"privacy": "隐私与安全",
|
||||
@@ -775,6 +807,7 @@
|
||||
"layout": "布局",
|
||||
"reading": "阅读",
|
||||
"composing": "撰写",
|
||||
"downloads": "下载",
|
||||
"content_senders": "内容和发件人",
|
||||
"about_data": "关于和数据",
|
||||
"debug": "调试"
|
||||
@@ -843,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "统一邮箱",
|
||||
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)"
|
||||
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)",
|
||||
"include_group": {
|
||||
"label": "包含群组收件箱",
|
||||
"description": "在统一视图中也合并共享/群组收件箱。"
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "彩色侧边栏图标",
|
||||
@@ -927,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "语言及地区",
|
||||
"description": "配置语言和区域首选项",
|
||||
"title": "语言、地区与时间",
|
||||
"description": "语言、日期格式、时间格式以及其他区域设置",
|
||||
"language": {
|
||||
"label": "语言",
|
||||
"description": "选择您的首选语言",
|
||||
@@ -937,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "日期格式",
|
||||
"description": "日期应如何显示",
|
||||
"regional": "区域格式",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "自定义"
|
||||
"description": "电子邮件列表中日期的显示方式",
|
||||
"smart": "智能(按地区)",
|
||||
"relative": "相对时间(1 小时前、2 天前)",
|
||||
"full": "始终完整日期",
|
||||
"preview_today": "今天:",
|
||||
"preview_this_week": "本周:",
|
||||
"preview_older": "更早:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "时间格式",
|
||||
@@ -1126,6 +1166,13 @@
|
||||
"attachment_image_previews": {
|
||||
"label": "在附件中显示图片预览",
|
||||
"description": "将图片附件显示为缩略图卡片,而不是通用文件图标"
|
||||
},
|
||||
"send_delay": {
|
||||
"label": "撤销发送 / 发送延迟",
|
||||
"description": "通过服务器端短暂窗口延迟普通发送。",
|
||||
"off": "关闭",
|
||||
"seconds": "{seconds} 秒",
|
||||
"unsupported": "当前账户未声明支持延迟发送。该设置仍会为其他账户保存。"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -1495,6 +1542,50 @@
|
||||
"group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题",
|
||||
"group_by_letter_label": "按首字母分组"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "下载",
|
||||
"description": "自定义下载的邮件和附件的命名方式。",
|
||||
"reset": "恢复默认",
|
||||
"preview": "预览:",
|
||||
"email_template": {
|
||||
"label": "邮件文件名 (.eml)",
|
||||
"description": "导出邮件或将其拖到文件系统时使用的模板。扩展名 .eml 会自动添加。"
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "附件文件名",
|
||||
"description": "下载或拖出附件时使用的模板。如果省略 '{filename}' 和 '{ext}',将保留原始扩展名。"
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "多邮件 .zip 文件名",
|
||||
"description": "将多个选中的邮件作为单个 .zip 拖出或下载时使用的模板。扩展名 .zip 会自动添加。"
|
||||
},
|
||||
"spaces": {
|
||||
"label": "空格",
|
||||
"description": "用其他字符替换最终文件名中的空格。",
|
||||
"keep": "保留空格",
|
||||
"underscore": "替换为 _",
|
||||
"dash": "替换为 -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "小写",
|
||||
"description": "将整个文件名强制转换为小写。"
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "去除变音符号",
|
||||
"description": "将带重音的字母转换为对应的 ASCII 字符(ä → a, é → e)。对处理 Unicode 文件名有问题的工具很有用。"
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "合并重复的分隔符",
|
||||
"description": "将连续的空格、下划线或短横线合并为单个字符。"
|
||||
},
|
||||
"after_export": {
|
||||
"label": "导出后",
|
||||
"description": "将邮件导出为 .eml 后可选择移动到其他位置。",
|
||||
"keep": "保留在邮箱中",
|
||||
"archive": "移动到归档",
|
||||
"trash": "移动到回收站"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "邮件过滤器",
|
||||
"description": "创建规则,自动整理、标记和管理收到的邮件",
|
||||
@@ -1751,7 +1842,11 @@
|
||||
"color_tag": "标签",
|
||||
"remove_color": "删除标签",
|
||||
"items_selected": "已选择 {count} 封邮件",
|
||||
"edit_draft": "编辑草稿"
|
||||
"edit_draft": "编辑草稿",
|
||||
"cancel_scheduled_send": "取消发送",
|
||||
"reschedule_send": "重新计划",
|
||||
"cancel_and_edit": "取消并编辑",
|
||||
"cancel_and_compose_again": "取消并重新撰写"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "将文件夹标记为已读",
|
||||
@@ -1760,7 +1855,7 @@
|
||||
"new_subfolder": "新建子文件夹...",
|
||||
"new_folder": "新建文件夹...",
|
||||
"rename": "重命名...",
|
||||
"import_email": "导入 .eml...",
|
||||
"import_email": "导入 .eml 或 .zip...",
|
||||
"empty_folder": "清空文件夹",
|
||||
"empty_folder_generic": "清空文件夹",
|
||||
"delete_folder": "删除文件夹",
|
||||
@@ -1829,6 +1924,8 @@
|
||||
"expand_collapse": "展开/折叠会话"
|
||||
},
|
||||
"composer": {
|
||||
"send": "发送邮件",
|
||||
"schedule_send": "定时发送",
|
||||
"template_picker": "打开模板选择器"
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+2
-14
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.1",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
@@ -5971,7 +5971,6 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"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": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.1",
|
||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
@@ -97,8 +97,8 @@ export async function proxy(request: NextRequest) {
|
||||
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
|
||||
const frameSrc =
|
||||
pluginFrameOrigins.length > 0
|
||||
? `frame-src 'self' ${pluginFrameOrigins.join(" ")}`
|
||||
: `frame-src 'self'`;
|
||||
? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
|
||||
: `frame-src 'self' blob:`;
|
||||
|
||||
const csp = [
|
||||
`default-src 'self'`,
|
||||
@@ -108,7 +108,7 @@ export async function proxy(request: NextRequest) {
|
||||
`font-src 'self'`,
|
||||
`connect-src ${connectSrc}`,
|
||||
frameSrc,
|
||||
`object-src 'none'`,
|
||||
`object-src 'self' blob:`,
|
||||
`base-uri 'self'`,
|
||||
`form-action 'self'`,
|
||||
`frame-ancestors ${frameAncestors}`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||
import { parseDuration } from '@/components/calendar/event-card';
|
||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
||||
@@ -302,8 +303,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
after: start,
|
||||
before: end,
|
||||
});
|
||||
// Filter out malformed events missing required 'start' field
|
||||
const validEvents = rawEvents.filter(e => typeof e.start === 'string' && e.start);
|
||||
// Filter out malformed events missing required 'start' field, or
|
||||
// whose start string fails to parse (would otherwise crash format()
|
||||
// calls in the rendering path - #316).
|
||||
const validEvents = rawEvents.filter(e =>
|
||||
typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())
|
||||
);
|
||||
const droppedEvents = rawEvents.length - validEvents.length;
|
||||
// Expand recurring events client-side (Stalwart doesn't support
|
||||
// mutations on synthetic IDs from server-side expandRecurrences)
|
||||
@@ -366,7 +371,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
accounts.map(async ({ client, localAccountId }) => {
|
||||
try {
|
||||
const raw = await client.queryAllCalendarEvents({ after: start, before: end });
|
||||
const valid = raw.filter(e => typeof e.start === 'string' && e.start);
|
||||
const valid = raw.filter(e =>
|
||||
typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())
|
||||
);
|
||||
const expanded = expandRecurringEvents(valid, start, end);
|
||||
return prefixEventsWithLocalAccount(
|
||||
expanded,
|
||||
|
||||
+414
-44
@@ -1,5 +1,5 @@
|
||||
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 { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -11,6 +11,17 @@ import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, adv
|
||||
import { useAuthStore } from "@/stores/auth-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 {
|
||||
emails: Email[];
|
||||
mailboxes: Mailbox[];
|
||||
@@ -67,6 +78,17 @@ interface EmailStore {
|
||||
unifiedErrors: Map<string, string>; // accountId -> error message
|
||||
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;
|
||||
setMailboxes: (mailboxes: Mailbox[]) => void;
|
||||
/** Cache or update the mailbox list for a specific account. */
|
||||
@@ -111,8 +133,8 @@ interface EmailStore {
|
||||
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
|
||||
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
||||
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], envelopeMailFrom?: string) => Promise<void>;
|
||||
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: 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, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
|
||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||
@@ -126,11 +148,16 @@ interface EmailStore {
|
||||
* pass the active account's id explicitly (no `__default__` sentinel).
|
||||
* `destMailboxId` is the raw JMAP id on the destination server (not the
|
||||
* `accountId:mailboxId` namespace used for shared folders).
|
||||
* `destJmapAccountId` overrides the destination client's primary account
|
||||
* for the import — used when dropping into a delegated/shared mailbox that
|
||||
* is owned by a different JMAP account but accessed through the same
|
||||
* client (i.e. there is no separate connected client for the owner).
|
||||
*/
|
||||
crossAccountMoveEmails: (
|
||||
emailIdsBySource: Map<string, string[]>,
|
||||
destAccountId: string,
|
||||
destMailboxId: string,
|
||||
destJmapAccountId?: string,
|
||||
) => Promise<void>;
|
||||
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
|
||||
advancedSearch: (client: IJMAPClient) => Promise<void>;
|
||||
@@ -180,6 +207,16 @@ interface EmailStore {
|
||||
refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<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
|
||||
loadMockData: () => void;
|
||||
}
|
||||
@@ -214,6 +251,38 @@ function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | n
|
||||
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
|
||||
* Thunderbird-style sidebar), redirect read/write operations to that
|
||||
@@ -239,20 +308,22 @@ function resolveActionMailboxes(): Mailbox[] {
|
||||
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
|
||||
* action (browse, load-more, search). Each entry has a JMAP client plus a
|
||||
* fresh mailbox list so the helpers can resolve the role mailbox per account.
|
||||
* Accounts whose mailbox fetch fails are skipped - the unified result will
|
||||
* surface that in its per-account error map.
|
||||
*
|
||||
* When `includeGroup` is true, also emits one synthetic entry per shared
|
||||
* owner account reachable through each logged-in client. The shared entries
|
||||
* are flagged with `isShared: true` so `lib/unified-mailbox.ts` routes JMAP
|
||||
* requests via `originalId` + owner accountId.
|
||||
*/
|
||||
async function buildUnifiedAccountClients(): Promise<UnifiedAccountClient[]> {
|
||||
export async function buildUnifiedAccountClients(
|
||||
opts: { includeGroup?: boolean } = {},
|
||||
): Promise<UnifiedAccountClient[]> {
|
||||
const { includeGroup = false } = opts;
|
||||
const authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected);
|
||||
const allClients = useAuthStore.getState().getAllConnectedClients();
|
||||
const built: UnifiedAccountClient[] = [];
|
||||
@@ -260,8 +331,31 @@ async function buildUnifiedAccountClients(): Promise<UnifiedAccountClient[]> {
|
||||
const c = allClients.get(a.id);
|
||||
if (!c) continue;
|
||||
try {
|
||||
const mailboxes = await c.getMailboxes();
|
||||
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes });
|
||||
const mailboxes = includeGroup ? await c.getAllMailboxes() : await c.getMailboxes();
|
||||
const ownMailboxes = includeGroup
|
||||
? mailboxes.filter((m) => !m.isShared)
|
||||
: mailboxes;
|
||||
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, isShared: false });
|
||||
|
||||
if (includeGroup) {
|
||||
const sharedByOwner = new Map<string, Mailbox[]>();
|
||||
for (const m of mailboxes) {
|
||||
if (!m.isShared || !m.accountId || m.accountId === a.id) continue;
|
||||
const list = sharedByOwner.get(m.accountId) ?? [];
|
||||
list.push(m);
|
||||
sharedByOwner.set(m.accountId, list);
|
||||
}
|
||||
for (const [ownerId, ownerMailboxes] of sharedByOwner) {
|
||||
const label = ownerMailboxes.find((m) => m.accountName)?.accountName || ownerId;
|
||||
built.push({
|
||||
accountId: ownerId,
|
||||
accountLabel: label,
|
||||
client: c,
|
||||
mailboxes: ownerMailboxes,
|
||||
isShared: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* skip account on mailbox fetch failure */
|
||||
}
|
||||
@@ -269,6 +363,12 @@ async function buildUnifiedAccountClients(): Promise<UnifiedAccountClient[]> {
|
||||
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> {
|
||||
const viewingId = useEmailStore.getState().viewingAccountId;
|
||||
const client = resolveActionClient(fallbackClient);
|
||||
@@ -351,6 +451,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
unifiedErrors: new Map(),
|
||||
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
|
||||
spamUndoCache: new Map(),
|
||||
|
||||
@@ -480,7 +591,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// 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)
|
||||
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 } : {};
|
||||
if (!selectionValid) {
|
||||
// Find inbox from PRIMARY account (not shared accounts)
|
||||
@@ -532,6 +644,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
|
||||
try {
|
||||
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);
|
||||
|
||||
// Find the mailbox to get its accountId (for shared folder support)
|
||||
@@ -553,7 +670,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// all folders that carry the tag are returned.
|
||||
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
|
||||
set({
|
||||
emails: result.emails,
|
||||
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
isLoading: false
|
||||
@@ -583,8 +700,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
set({ isLoadingMore: true, error: null });
|
||||
try {
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||
const position = emails.length;
|
||||
const built = await buildUnifiedAccountClients();
|
||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||
const { searchFilters } = get();
|
||||
const hasFilters = !isFilterEmpty(searchFilters);
|
||||
const result = hasFilters
|
||||
@@ -620,6 +738,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
set({ isLoadingMore: true, error: null });
|
||||
try {
|
||||
if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
|
||||
set({ isLoadingMore: false });
|
||||
await get().loadMoreScheduledEmails(client);
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveClient = resolveActionClient(client);
|
||||
// Get emails per page from settings
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
@@ -665,7 +789,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Deduplicate: the server may return overlapping results if new emails
|
||||
// arrived between paginated requests and shifted positions.
|
||||
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({
|
||||
emails: [...currentEmails, ...newEmails],
|
||||
@@ -695,7 +819,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const email = await resolveActionClient(client).getEmail(emailId, accountId);
|
||||
|
||||
if (email) {
|
||||
set({ selectedEmail: email });
|
||||
const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId);
|
||||
set({ selectedEmail: annotatedEmail });
|
||||
return annotatedEmail;
|
||||
}
|
||||
return email;
|
||||
} catch (error) {
|
||||
@@ -715,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 });
|
||||
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
|
||||
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) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to send email",
|
||||
@@ -730,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 });
|
||||
try {
|
||||
const mailboxes = await client.getMailboxes();
|
||||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||
if (!sentMailbox) throw new Error('No sent mailbox found');
|
||||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||||
await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id);
|
||||
set({ isLoading: false });
|
||||
const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil, envelopeRecipients);
|
||||
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) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to send email",
|
||||
@@ -773,14 +911,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
forceDelete = true;
|
||||
}
|
||||
|
||||
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox
|
||||
if (deleteAction === 'trash' && !forceDelete) {
|
||||
// If deleteAction is 'trash' or 'trash-and-read' and not forced permanent delete, try to move to trash mailbox
|
||||
if ((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete) {
|
||||
const trashMailbox = findTrashMailbox(mailboxes, { accountId });
|
||||
const alsoMarkRead = deleteAction === 'trash-and-read' && isUnread;
|
||||
|
||||
if (trashMailbox) {
|
||||
// Use originalId for shared mailboxes if available
|
||||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||||
await effectiveClient.moveToTrash(emailId, trashId, accountId);
|
||||
await effectiveClient.moveToTrash(emailId, trashId, accountId, alsoMarkRead);
|
||||
|
||||
// After marking read in the same request, the email arrives in trash as read.
|
||||
const arrivesUnread = isUnread && !alsoMarkRead;
|
||||
|
||||
// Remove from local state (email moved to trash, not in current view)
|
||||
set((state) => {
|
||||
@@ -803,9 +945,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: mailbox.totalEmails + 1,
|
||||
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
|
||||
unreadEmails: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
|
||||
totalThreads: mailbox.totalThreads + 1,
|
||||
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
|
||||
unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
|
||||
};
|
||||
}
|
||||
return mailbox;
|
||||
@@ -1091,7 +1233,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId) => {
|
||||
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId) => {
|
||||
if (emailIdsBySource.size === 0) return;
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
@@ -1124,7 +1266,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
const blob = await sourceClient.fetchBlob(full.blobId);
|
||||
const keywords: Record<string, boolean> = { ...(full.keywords ?? {}) };
|
||||
await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords);
|
||||
await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId);
|
||||
await sourceClient.deleteEmail(emailId);
|
||||
return emailId;
|
||||
}),
|
||||
@@ -1257,7 +1399,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
if (isUnifiedView && unifiedRole) {
|
||||
const built = await buildUnifiedAccountClients();
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||
const result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0);
|
||||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
|
||||
set({
|
||||
@@ -1283,7 +1426,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
|
||||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
|
||||
set({
|
||||
emails: result.emails,
|
||||
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||||
externalSearchResults: externals,
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
@@ -1323,7 +1466,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
if (isUnifiedView && unifiedRole) {
|
||||
const built = await buildUnifiedAccountClients();
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||
const result = await advancedSearchUnifiedEmails(
|
||||
built,
|
||||
unifiedRole,
|
||||
@@ -1357,7 +1501,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
|
||||
|
||||
set({
|
||||
emails: result.emails,
|
||||
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||||
externalSearchResults: externals,
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
@@ -1514,6 +1658,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
|
||||
const isInJunk = currentMailbox?.role === 'junk';
|
||||
const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk);
|
||||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
|
||||
|
||||
// Group emails by accountId (handles unified view and search results spanning accounts).
|
||||
const emailsByAccount = new Map<string, string[]>();
|
||||
@@ -1554,7 +1699,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||||
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId);
|
||||
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId, alsoMarkRead);
|
||||
ids.forEach(id => movedEmailIds.add(id));
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
@@ -1745,7 +1890,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
});
|
||||
|
||||
try {
|
||||
await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread;
|
||||
await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId, alsoMarkRead);
|
||||
|
||||
set(state => ({
|
||||
emails: state.emails.filter(e => e.id !== emailId),
|
||||
@@ -1800,16 +1947,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
|
||||
batchMarkAsSpam: async (client, emailIds) => {
|
||||
const { selectedMailbox } = get();
|
||||
const { selectedMailbox, emails } = get();
|
||||
const mailboxes = resolveActionMailboxes();
|
||||
const effectiveClient = resolveActionClient(client);
|
||||
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
if (!currentMailbox) return;
|
||||
|
||||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
|
||||
|
||||
try {
|
||||
for (const emailId of emailIds) {
|
||||
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId);
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
const markRead = alsoMarkRead && !!email && !email.keywords?.$seen;
|
||||
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId, markRead);
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
@@ -1880,6 +2031,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
get().fetchTagCounts(client);
|
||||
}
|
||||
|
||||
if (accountChanges.EmailSubmission) {
|
||||
await get().refreshScheduledMetadata(client);
|
||||
if (get().isScheduledView) {
|
||||
await get().fetchScheduledEmails(client);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Mailbox state changes - refresh mailbox list
|
||||
if (accountChanges.Mailbox) {
|
||||
await get().fetchMailboxes(client);
|
||||
@@ -1927,6 +2085,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Only refresh if a mailbox is currently selected
|
||||
if (!selectedMailbox) return;
|
||||
|
||||
if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
|
||||
await get().fetchScheduledEmails(client);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch emails for the current mailbox without clearing the list first
|
||||
// This provides a smoother update experience
|
||||
@@ -1953,6 +2116,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
|
||||
const currentEmails = get().emails;
|
||||
const previousTotal = get().totalEmails;
|
||||
|
||||
// Only notify for genuinely new incoming mail in the Inbox.
|
||||
// Without these guards the toast/sound also fires when sending,
|
||||
@@ -1970,15 +2134,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Merge the refreshed first page with the existing loaded emails.
|
||||
// This avoids discarding already-loaded pages which would cause the
|
||||
// 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
|
||||
// existing emails beyond that page (if any), skipping duplicates and
|
||||
// emails removed from the first page (e.g. deleted or moved).
|
||||
const merged: Email[] = [...result.emails];
|
||||
const mergedIds = new Set(result.emails.map((e: Email) => e.id));
|
||||
// existing emails beyond that page (if any), skipping duplicates. Do not
|
||||
// append the whole previous list: drafts are saved as destroy+create, so
|
||||
// the old draft can disappear from the refreshed first page and must not
|
||||
// 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)) {
|
||||
merged.push(email);
|
||||
mergedIds.add(email.id);
|
||||
@@ -2334,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: () => {
|
||||
const mockEmails: Email[] = [
|
||||
{
|
||||
|
||||
+13
-2
@@ -333,7 +333,12 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
|
||||
try {
|
||||
if (abortController.signal.aborted) return;
|
||||
const { blobId, type } = await client.uploadBlob(file);
|
||||
const { blobId, type } = await client.uploadBlob(file, {
|
||||
signal: abortController.signal,
|
||||
onProgress: (loaded, total) => {
|
||||
set({ uploadProgress: { name: file.name, loaded, total, current: 1, totalFiles: 1 } });
|
||||
},
|
||||
});
|
||||
if (abortController.signal.aborted) return;
|
||||
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } });
|
||||
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
|
||||
@@ -361,7 +366,13 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } });
|
||||
|
||||
try {
|
||||
const { blobId, type } = await client.uploadBlob(file);
|
||||
const idx = i;
|
||||
const { blobId, type } = await client.uploadBlob(file, {
|
||||
signal: abortController.signal,
|
||||
onProgress: (loaded, total) => {
|
||||
set({ uploadProgress: { name: file.name, loaded, total, current: idx + 1, totalFiles } });
|
||||
},
|
||||
});
|
||||
if (abortController.signal.aborted) break;
|
||||
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
|
||||
await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
|
||||
|
||||
@@ -353,6 +353,14 @@ export const useProTabStore = create<ProTabState>()(
|
||||
const target = state.tabs.find((t) => t.id === targetTabId);
|
||||
if (!dragged || !target) return;
|
||||
|
||||
// Mirror the moveTabToPane guard: never let a cross-pane reorder
|
||||
// empty the main pane, which would otherwise leave the layout with
|
||||
// a blank main pane next to a populated split pane.
|
||||
if (dragged.paneId === 'main' && target.paneId !== 'main') {
|
||||
const otherMainTabs = state.tabs.filter((t) => t.paneId === 'main' && t.id !== draggedId);
|
||||
if (otherMainTabs.length === 0) return;
|
||||
}
|
||||
|
||||
const next = state.tabs.filter((t) => t.id !== draggedId);
|
||||
const insertAt = next.findIndex((t) => t.id === targetTabId) + (edge === 'after' ? 1 : 0);
|
||||
const reassigned: ProTab = dragged.paneId === target.paneId
|
||||
@@ -504,6 +512,15 @@ export const useProTabStore = create<ProTabState>()(
|
||||
state.loadedTabIds = [HOME_TAB.id];
|
||||
return;
|
||||
}
|
||||
// Heal broken state where every tab ended up on the split pane:
|
||||
// collapse the split so the surviving tabs return to main, otherwise
|
||||
// the layout would render an empty main pane next to the split.
|
||||
if (!state.tabs.some((t) => t.paneId === 'main')) {
|
||||
state.tabs = state.tabs.map((t) => ({ ...t, paneId: 'main' as const }));
|
||||
state.activeSplitTabId = null;
|
||||
state.splitOrientation = null;
|
||||
state.focusedPaneId = 'main';
|
||||
}
|
||||
if (!state.tabs.some((t) => t.id === state.activeTabId && t.paneId === 'main')) {
|
||||
state.activeTabId = state.tabs.find((t) => t.paneId === 'main')?.id ?? HOME_TAB.id;
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@ export type FontSize = 'small' | 'medium' | 'large';
|
||||
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
||||
/** @deprecated Use Density instead */
|
||||
export type ListDensity = Density;
|
||||
export type DeleteAction = 'trash' | 'permanent';
|
||||
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
|
||||
export type ReplyMode = 'reply' | 'replyAll';
|
||||
export type SignaturePosition = 'above_quote' | 'below_quote';
|
||||
export type DateFormat = 'regional' | 'iso' | 'custom';
|
||||
export type DateFormat = 'smart' | 'relative' | 'full';
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
|
||||
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
|
||||
@@ -41,6 +41,7 @@ export type ToolbarPosition = 'top' | 'below-subject';
|
||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||
export type MailLayout = 'split' | 'focus' | 'horizontal';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -153,6 +154,7 @@ interface SettingsState {
|
||||
autoSelectReplyIdentity: boolean;
|
||||
plainTextMode: boolean; // Send plain text only (no rich text editor)
|
||||
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
|
||||
signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter
|
||||
|
||||
@@ -202,6 +204,7 @@ interface SettingsState {
|
||||
|
||||
// Unified Mailbox
|
||||
enableUnifiedMailbox: boolean;
|
||||
includeGroupInUnified: boolean;
|
||||
|
||||
// Email Display
|
||||
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
|
||||
@@ -239,6 +242,16 @@ interface SettingsState {
|
||||
tourCompleted: boolean; // Interactive tour completed
|
||||
showOnboardingOnNewDevices: boolean; // When true, onboarding shows again on each new device
|
||||
|
||||
// Downloads
|
||||
emailDownloadTemplate: string;
|
||||
attachmentDownloadTemplate: string;
|
||||
bundleDownloadTemplate: string;
|
||||
filenameSpaceReplacement: 'keep' | 'underscore' | 'dash';
|
||||
filenameLowercase: boolean;
|
||||
filenameStripDiacritics: boolean;
|
||||
filenameCollapseSeparators: boolean;
|
||||
postExportAction: 'keep' | 'archive' | 'trash';
|
||||
|
||||
// Advanced
|
||||
debugMode: boolean;
|
||||
debugCategories: Record<DebugCategory, boolean>;
|
||||
@@ -289,7 +302,7 @@ const DEFAULT_SETTINGS = {
|
||||
animationsEnabled: true,
|
||||
|
||||
// Language & Region
|
||||
dateFormat: 'regional' as DateFormat,
|
||||
dateFormat: 'smart' as DateFormat,
|
||||
timeFormat: '24h' as TimeFormat,
|
||||
firstDayOfWeek: 1 as FirstDayOfWeek, // Monday
|
||||
|
||||
@@ -316,6 +329,7 @@ const DEFAULT_SETTINGS = {
|
||||
autoSelectReplyIdentity: false,
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
sendDelaySeconds: 0 as SendDelaySeconds,
|
||||
signaturePosition: 'below_quote' as SignaturePosition,
|
||||
signatureSeparatorEnabled: true,
|
||||
|
||||
@@ -365,6 +379,7 @@ const DEFAULT_SETTINGS = {
|
||||
|
||||
// Unified Mailbox
|
||||
enableUnifiedMailbox: false,
|
||||
includeGroupInUnified: false,
|
||||
|
||||
// Email Display
|
||||
disableThreading: false,
|
||||
@@ -424,6 +439,16 @@ const DEFAULT_SETTINGS = {
|
||||
tourCompleted: false,
|
||||
showOnboardingOnNewDevices: false,
|
||||
|
||||
// Downloads
|
||||
emailDownloadTemplate: '{date} ({from}-{to}) {subject}',
|
||||
attachmentDownloadTemplate: '{filename}',
|
||||
bundleDownloadTemplate: 'emails-{count}',
|
||||
filenameSpaceReplacement: 'keep' as 'keep' | 'underscore' | 'dash',
|
||||
filenameLowercase: false,
|
||||
filenameStripDiacritics: false,
|
||||
filenameCollapseSeparators: true,
|
||||
postExportAction: 'keep' as 'keep' | 'archive' | 'trash',
|
||||
|
||||
// Advanced
|
||||
debugMode: false,
|
||||
debugCategories: {
|
||||
@@ -498,6 +523,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
|
||||
plainTextMode: state.plainTextMode,
|
||||
subAddressDelimiter: state.subAddressDelimiter,
|
||||
sendDelaySeconds: state.sendDelaySeconds,
|
||||
signaturePosition: state.signaturePosition,
|
||||
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
|
||||
sessionTimeout: state.sessionTimeout,
|
||||
@@ -523,6 +549,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
// proInterface is intentionally omitted - it's a per-device choice
|
||||
// (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced.
|
||||
enableUnifiedMailbox: state.enableUnifiedMailbox,
|
||||
includeGroupInUnified: state.includeGroupInUnified,
|
||||
senderFavicons: state.senderFavicons,
|
||||
showAvatarsInJunk: state.showAvatarsInJunk,
|
||||
colorfulSidebarIcons: state.colorfulSidebarIcons,
|
||||
@@ -566,6 +593,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) {
|
||||
return;
|
||||
}
|
||||
if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(settings[key])) {
|
||||
set({ sendDelaySeconds: 0 });
|
||||
return;
|
||||
}
|
||||
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
|
||||
return;
|
||||
}
|
||||
@@ -737,17 +768,26 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
}),
|
||||
{
|
||||
name: 'settings-storage',
|
||||
version: 3,
|
||||
version: 4,
|
||||
migrate: (persisted, version) => {
|
||||
const state = persisted as Record<string, unknown>;
|
||||
if (version < 2 && state.listDensity) {
|
||||
state.density = 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') {
|
||||
state.protocolOpenMode = state.protocolMailtoOpenMode;
|
||||
}
|
||||
delete state.protocolMailtoOpenMode;
|
||||
// v4: `dateFormat` was repurposed from 'regional'|'iso'|'custom' to
|
||||
// 'smart'|'relative'|'full'. The old setting was never read anywhere,
|
||||
// so every persisted value maps to the new default.
|
||||
if (version < 4) {
|
||||
state.dateFormat = 'smart';
|
||||
}
|
||||
return state as unknown as SettingsState;
|
||||
},
|
||||
onRehydrateStorage: () => {
|
||||
|
||||
Reference in New Issue
Block a user