Compare commits

...
62 Commits
Author SHA1 Message Date
Linus Rath d0c7e991ff Merge branch 'main' into feature/scheduled-send 2026-05-28 18:43:13 +02:00
Shuki VakninandLinus Rath 82be047708 fix(email-viewer): stop shattering table cells with word-break: break-word
The global rule

    td, th { word-break: break-word; }

was breaking HTML-email tables one glyph per row whenever a column was
narrow, especially for Hebrew/Arabic/CJK headers and long English
strings. The non-standard `word-break: break-word` keyword behaves
like `break-all` in some engines, splitting words at arbitrary
character boundaries even when the word would fit if the column auto-
expanded.

`overflow-wrap: break-word` is already set on body/table, so the rule
only needs to add min-content relaxation for cells. `overflow-wrap:
anywhere` does exactly that without re-introducing break-all
behaviour.

Repro: any transactional Hebrew/RTL order-summary email — each header
(`מוצר`, `כמות`, `מחיר`) collapses to one glyph per row. After the fix
they render on a single line.

Closes #341.
2026-05-28 18:32:06 +02:00
Linus Rath 7d1fb73290 fix: scope Ctrl/Cmd+Enter send to focused composer 2026-05-28 18:28:07 +02:00
Shuki VakninandLinus Rath 2818a16f06 feat(composer): Ctrl+Enter / Cmd+Enter sends the open draft
Adds the universal "send with the platform modifier" shortcut every
mainstream mail client (Gmail, Outlook, Apple Mail, Proton, Tutanota,
Fastmail, Thunderbird) supports. Closes #343.

Behaviour:

* Window-level keydown listener registered while the composer is
  mounted. Fires when focus is anywhere inside the composer — chip
  inputs, subject, body textarea, or the rich-text contentEditable.
* Plain Enter is untouched; only Enter + Ctrl (Win/Linux) or Cmd
  (macOS) triggers send. Shift/Alt modifiers are ignored so existing
  autocomplete-confirm / chip-commit Enters are not hijacked.
* Routes through a ref so handleSend's per-render rebind doesn't
  re-register the listener every render.
* All existing send-time validation, attachment-warning, draft-save
  and undo-send flows still apply — the shortcut just calls the
  same handleSend() as the toolbar button.
* Listed in the Keyboard Shortcuts dialog under the existing
  Composer section.

Tested:

* Compose -> type body -> Ctrl+Enter -> Outbox.
* Cc/Bcc autocomplete suggestion + Enter still selects (alt-free
  Enter without Ctrl, so the new listener bails).
* Subject input -> Ctrl+Enter -> sends.
* Body Enter without modifier -> newline.
2026-05-28 18:23:37 +02:00
Shuki VakninandGitHub e93dd44111 fix: report real upload progress; XHR with progress events #333
The Files page UI sat at 0% throughout an upload because uploadBlob()
uses fetch(), which does not surface upload progress events. The store
set loaded=0 before the call and loaded=file.size after it, so users
saw the progress bar jump from 0% straight to 100% on completion --
and on slow connections (or large files) it appeared frozen.

Switch uploadBlob() to XHR when the caller passes onProgress or an
AbortSignal, so progress events from xhr.upload.onprogress can drive
the UI. Callers that don't pass either keep the fetch path so we
preserve the existing 401-retry behaviour in authenticatedFetch().

Wire the file store to pass both onProgress (updates uploadProgress
in real time) and the existing AbortController's signal (so cancel
now actually aborts the network request, not just the post-upload
createFileNode step).

uploadBlob() is part of IJMAPClient so the signature change is also
applied to the demo client (synthesises 0% then 100%).
2026-05-28 18:20:22 +02:00
Lucas Gaitzsch d1b2206aa0 fixes 2026-05-26 18:43:01 +02:00
Lucas Gaitzsch 0137a1a593 Merge branch 'main' into feature/scheduled-send
# Conflicts:
#	app/(main)/[locale]/page.tsx
#	components/email/email-list.tsx
#	components/email/email-viewer.tsx
2026-05-26 18:30:45 +02:00
Chuyen NguyenandLinus Rath e86183b44a Fix bug where editing any field closed the form 2026-05-26 08:39:24 +02:00
Linus Rath ad80aa23ca fix: keep empty viewer pane visible in Pro split layout 2026-05-25 19:07:27 +02:00
Linus Rath 3a8daf8bff feat: allow drag-and-drop into shared mailboxes 2026-05-25 18:53:54 +02:00
Linus Rath f8e7cce85a fix: prevent empty main pane when reordering tabs across panes 2026-05-25 18:25:55 +02:00
Linus Rath 62ebe443f9 fix: align continued multi-week events with week's left edge 2026-05-25 18:13:57 +02:00
Linus Rath 3c0faba837 fix: collapse focus mail layout to multi-line on mobile 2026-05-25 18:05:01 +02:00
Linus Rath 956acb69ce feat: add NEXT_PUBLIC_DEFAULT_LOCALE for fallback UI locale #243 2026-05-25 17:01:52 +02:00
Linus Rath e2abc8dee9 fix: prefix remaining <img>, favicon, and WebDAV URLs with basePath #319 2026-05-25 16:45:13 +02:00
Linus Rath 42ec34be21 fix: show end date in event popover for multi-day events #318 2026-05-25 16:28:03 +02:00
Linus Rath 534894c38d feat: locale-aware date format in email list with preset picker #331 2026-05-25 16:17:32 +02:00
Linus Rath 537707d9ed feat: include group inboxes in unified mailbox view #328 2026-05-23 16:01:22 +02:00
Linus Rath afe1e5a67c fix: restore blob: in object-src and frame-src CSP for PDF/HTML previews 2026-05-23 15:53:58 +02:00
Linus Rath d13934c2a6 fix: match user-avatar treatment on quick reply 2026-05-23 15:35:10 +02:00
Lucas GaitzschandGitHub c4aa0c1772 Merge branch 'bulwarkmail:main' into feature/scheduled-send 2026-05-23 06:39:35 +02:00
Linus Rath acc90eb455 feat: add "Move to Trash and mark as read" delete action #323 2026-05-22 19:08:02 +02:00
Lucas Gaitzsch 9a302e5183 fix styling 2026-05-22 18:14:57 +02:00
Lucas Gaitzsch 401870c9bb add translations 2026-05-22 18:05:54 +02:00
Lucas Gaitzsch 91cd087243 fix email store lazy load 2026-05-22 18:01:48 +02:00
Lucas Gaitzsch d04a8e578b disable password managers for recipients 2026-05-22 18:01:31 +02:00
Linus Rath 5aa6d7a2f0 docs: document OAUTH_ALLOW_PRIVATE_ENDPOINTS in env/config examples 2026-05-22 17:57:41 +02:00
Linus Rath c46de636e0 fix: keep a gutter on bare-HTML emails on mobile 2026-05-22 17:47:24 +02:00
shukivandLinus Rath 8de8babba5 fix(email): keep a small gutter on plain-text emails on mobile
The <=640px rule zeroes .email-content-text horizontal padding, so
plain-text (prose) emails render flush against the viewport edge on
phones, which hurts readability. Use a reduced 0.75rem gutter instead
of 0 — still maximizes width for wide content but keeps text off the
screen edge.
2026-05-22 17:45:53 +02:00
Linus Rath 63f2169ae7 fix: add OAUTH_ALLOW_PRIVATE_ENDPOINTS for split-DNS setups 2026-05-22 17:22:14 +02:00
Linus Rath e843ef0ebb fix: convert recurrenceRules to singular in batch create 2026-05-22 17:00:53 +02:00
Linus Rath 4b463f9691 fix: prefix hand-written URLs with basePath for subpath deployments 2026-05-22 16:38:55 +02:00
Linus Rath 58e4a3d117 feat: add post-export action setting (keep/archive/trash) 2026-05-22 15:36:15 +02:00
Linus Rath e3f6ae874d feat: add settings template for multi-email .zip filename 2026-05-22 15:30:15 +02:00
Linus Rath 52f5a5b42c feat: support importing emails from .zip archives 2026-05-22 15:22:33 +02:00
Linus Rath e7bded82fb i18n: add missing translation keys across 16 locales 2026-05-22 15:09:51 +02:00
Linus Rath 3ac14ecf38 feat: add filename transform settings 2026-05-22 14:57:25 +02:00
Linus Rath 0dca019fe5 fix: stop URL-encoding drag-out filenames and preserve Unicode letters 2026-05-22 14:49:17 +02:00
Lucas Gaitzsch e6d939ba43 fixes from review 2026-05-22 14:46:57 +02:00
Linus Rath ca0d6805cf feat: add Downloads settings tab with template editor for .eml and attachment filenames 2026-05-22 14:46:25 +02:00
Linus Rath 8bcb487442 feat: name dragged/exported .eml files as "date (from-to) subject" with ASCII-only chars 2026-05-22 14:28:36 +02:00
Linus Rath 0245ec67e1 feat: enhance email filename generation and sanitization for drag-and-drop functionality 2026-05-22 14:25:30 +02:00
Linus Rath 8810a63262 feat: drag emails out to file explorer as .eml 2026-05-22 14:04:52 +02:00
Linus Rath d3778e6521 fix: handle malformed event dates in calendar route #316 2026-05-22 14:04:02 +02:00
Lucas Gaitzsch 5c1d59f38a fixes from review 2026-05-22 13:13:13 +02:00
Lucas Gaitzsch 159706a683 Merge branch 'main' into feature/scheduled-send
# Conflicts:
#	app/(main)/[locale]/page.tsx
#	components/layout/sidebar.tsx
#	stores/email-store.ts
#	stores/settings-store.ts
2026-05-22 12:31:06 +02:00
Lucas Gaitzsch 5928c6f73e Merge branch 'main' into feature/scheduled-send 2026-05-22 12:21:39 +02:00
Lucas Gaitzsch 7dccc2f432 fixes from review 2026-05-22 12:21:32 +02:00
Lucas Gaitzsch dd322d4c9d some fixes 2026-05-20 20:04:46 +02:00
Lucas Gaitzsch 7e15782fb3 fix err 2026-05-20 20:00:17 +02:00
Lucas Gaitzsch 1e8bd40b93 fix draft duplicating 2026-05-20 19:58:41 +02:00
Lucas Gaitzsch 3dc1b4ceee rework 2026-05-20 19:56:28 +02:00
Lucas Gaitzsch 1a3d359fee fix bugs 2026-05-20 08:28:31 +02:00
Lucas Gaitzsch 891c3250be Merge branch 'main' into feature/scheduled-send 2026-05-20 08:17:46 +02:00
Lucas Gaitzsch 4a3c775b40 fix 2026-05-09 00:31:34 +02:00
Lucas Gaitzsch f45b67fe19 fix 2026-05-08 22:37:16 +02:00
Lucas Gaitzsch c458091698 add new shortcuts 2026-05-08 20:41:19 +02:00
Lucas Gaitzsch 37cd5ca635 Merge remote-tracking branch 'origin/main' into feature/scheduled-send
# Conflicts:
#	app/[locale]/page.tsx
#	components/email/email-composer.tsx
#	components/email/email-viewer.tsx
2026-05-07 18:01:41 +02:00
Lucas Gaitzsch 154ae84247 Scheduld Send 2026-05-05 20:11:11 +02:00
Lucas Gaitzsch 16f719066f Merge branch 'main' into feature/scheduled-send 2026-05-04 15:46:50 +02:00
Lucas Gaitzsch 17577e222c Merge branch 'main' into feature/scheduled-send 2026-05-04 15:45:15 +02:00
Lucas Gaitzsch 48821338c3 ADD DOC 2026-05-03 22:44:16 +02:00
85 changed files with 5716 additions and 878 deletions
+21
View File
@@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OpenID Connect issuer URL for discovery # OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com # 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 # Session & Security
# ============================================================================= # =============================================================================
@@ -229,6 +234,22 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# your own directory (e.g. http://localhost:3001 for local development). # your own directory (e.g. http://localhost:3001 for local development).
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org # 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) # Legacy Build-time Variables (still supported as fallback)
# ============================================================================= # =============================================================================
+5
View File
@@ -8,6 +8,11 @@ ENV NEXT_TELEMETRY_DISABLED=1
# at build time, so it cannot be changed without rebuilding. # at build time, so it cannot be changed without rebuilding.
ARG NEXT_PUBLIC_BASE_PATH= ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$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 # 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. # `git rev-parse` inside the build can't find it - CI must pass it in.
ARG GIT_COMMIT=unknown ARG GIT_COMMIT=unknown
+2 -2
View File
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store"; 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 { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
@@ -96,7 +96,7 @@ function OAuthCallbackInner() {
// the refresh-token cookie write for the same reason. // the refresh-token cookie write for the same reason.
(async () => { (async () => {
try { try {
const res = await fetch("/api/auth/sso/complete", { const res = await apiFetch("/api/auth/sso/complete", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
+3 -3
View File
@@ -11,7 +11,7 @@ import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config"; 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 { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; 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"; 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="px-8 pt-12 pb-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 mb-6"> <div className="inline-flex items-center justify-center w-20 h-20 mb-6">
<img <img
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl} src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
alt={appName} alt={appName}
className="max-w-20 max-h-20 object-contain" 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="px-8 pt-10 pb-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 mb-5"> <div className="inline-flex items-center justify-center w-16 h-16 mb-5">
<img <img
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl} src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
alt={appName} alt={appName}
className="max-w-16 max-h-16 object-contain" className="max-w-16 max-h-16 object-contain"
/> />
+344 -92
View File
@@ -15,7 +15,7 @@ import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/li
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; 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 { toast } from "@/stores/toast-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-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 { isFilePreviewable } from "@/lib/file-preview";
import { appendPlainTextSignature } from "@/lib/signature-utils"; import { appendPlainTextSignature } from "@/lib/signature-utils";
import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { resolveReplyFrom } from "@/lib/reply-identity"; 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 { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
@@ -74,6 +75,8 @@ import { buildQuoteHeader } from "@/lib/quote-header";
import { useLocaleStore } from "@/stores/locale-store"; import { useLocaleStore } from "@/stores/locale-store";
import type { QuoteHeader } from "@/lib/plugin-types"; import type { QuoteHeader } from "@/lib/plugin-types";
const SCHEDULED_MAILBOX_ID = '__scheduled__';
export default function Home() { export default function Home() {
const t = useTranslations(); const t = useTranslations();
@@ -107,12 +110,39 @@ export default function Home() {
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null); const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null); const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastUndoToastSubmissionRef = useRef<string | null>(null);
const initialMailLoadClientRef = useRef<object | null>(null);
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
useIdentitySync(); useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore(); const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('email_viewer.reschedule_prompt'));
if (!value) return null;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) {
toast.error(t('email_composer.schedule_send_invalid'));
return null;
}
if (time <= Date.now()) {
toast.error(t('email_composer.schedule_send_future'));
return null;
}
if (!client?.hasDelayedSend()) {
toast.error(t('email_composer.schedule_send_unsupported'));
return null;
}
const maxDelayedSend = client.getMaxDelayedSend();
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
toast.error(t('email_composer.schedule_send_too_late'));
return null;
}
return new Date(time).toISOString();
}, [client, t]);
// Load trusted senders address book when feature is enabled // Load trusted senders address book when feature is enabled
useEffect(() => { useEffect(() => {
if (trustedSendersAddressBook && client && !trustedSendersLoaded) { if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
@@ -269,6 +299,21 @@ export default function Home() {
fetchTagCounts, fetchTagCounts,
fetchEmailContent, fetchEmailContent,
isUnifiedView, isUnifiedView,
scheduledEmails,
scheduledTotal,
scheduledHasMore,
isLoadingScheduled,
isScheduledView,
setScheduledView,
fetchScheduledEmails,
loadMoreScheduledEmails,
cancelScheduledEmail,
cancelScheduledEmailForEdit,
rescheduleScheduledEmail,
refreshScheduledMetadata,
cancelUndoSend,
clearPendingUndoSend,
pendingUndoSend,
fetchUnifiedEmails: fetchUnifiedEmailsAction, fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts, refreshUnifiedCounts,
exitUnifiedView, exitUnifiedView,
@@ -293,47 +338,26 @@ export default function Home() {
useProMultiAccountMailboxes(); useProMultiAccountMailboxes();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const delayedSendSupported = client?.hasDelayedSend() ?? true;
const activeEmails = isScheduledView ? scheduledEmails : emails;
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const accounts = useAccountStore((s) => s.accounts); const accounts = useAccountStore((s) => s.accounts);
const connectedAccountsSignature = useMemo( const connectedAccountsSignature = useMemo(
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","), () => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
[accounts], [accounts],
); );
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => { // Builds the populated UnifiedAccountClient[] used by the unified-view
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected); // effects and one-shot actions in this page. Reads the includeGroup
const clients = useAuthStore.getState().getAllConnectedClients(); // setting at call time so the latest toggle value is always honored.
const result: UnifiedAccountClient[] = []; const buildPopulatedUnifiedAccounts = useCallback(async (): Promise<UnifiedAccountClient[]> => {
for (const account of connected) { return buildUnifiedAccountClients({
const accountClient = clients.get(account.id); includeGroup: useSettingsStore.getState().includeGroupInUnified,
if (!accountClient) continue;
result.push({
accountId: account.id,
accountLabel: account.label || account.email,
client: accountClient,
mailboxes: [],
}); });
}
return result;
}, []); }, []);
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 getMailtoProtocolAccounts = useCallback(() => {
const connectedClients = useAuthStore.getState().getAllConnectedClients(); const connectedClients = useAuthStore.getState().getAllConnectedClients();
return useAccountStore.getState().accounts.filter((account) => return useAccountStore.getState().accounts.filter((account) =>
@@ -353,7 +377,7 @@ export default function Home() {
conversationThreadId: null as string | null, conversationThreadId: null as string | null,
}); });
navRestoreStateRef.current.client = client; navRestoreStateRef.current.client = client;
navRestoreStateRef.current.emails = emails; navRestoreStateRef.current.emails = activeEmails;
navRestoreStateRef.current.mailboxes = mailboxes; navRestoreStateRef.current.mailboxes = mailboxes;
navRestoreStateRef.current.selectedMailbox = selectedMailbox; navRestoreStateRef.current.selectedMailbox = selectedMailbox;
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null; navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
@@ -379,7 +403,24 @@ export default function Home() {
// Restore mailbox selection. selectMailbox clears the current email, // Restore mailbox selection. selectMailbox clears the current email,
// which is fine because we re-apply the saved email below. // which is fine because we re-apply the saved email below.
if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) { if (state.mailboxId === SCHEDULED_MAILBOX_ID) {
if (!ctx.client?.hasDelayedSend()) {
setScheduledView(false);
selectEmail(null);
return;
}
setScheduledView(true);
selectMailbox(SCHEDULED_MAILBOX_ID);
selectEmail(null);
if (ctx.client) {
try {
await fetchScheduledEmails(ctx.client);
} catch (error) {
debug.error('Failed to fetch scheduled emails on history restore:', error);
}
}
} else if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
setScheduledView(false);
selectMailbox(state.mailboxId); selectMailbox(state.mailboxId);
if (ctx.client) { if (ctx.client) {
try { try {
@@ -440,19 +481,19 @@ export default function Home() {
// Keyboard shortcuts handlers // Keyboard shortcuts handlers
const keyboardHandlers = useMemo(() => ({ const keyboardHandlers = useMemo(() => ({
onNextEmail: () => { onNextEmail: () => {
if (emails.length === 0) return; if (activeEmails.length === 0) return;
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1; const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1;
const nextIndex = currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex; const nextIndex = currentIndex < activeEmails.length - 1 ? currentIndex + 1 : currentIndex;
if (nextIndex >= 0 && nextIndex < emails.length) { if (nextIndex >= 0 && nextIndex < activeEmails.length) {
handleEmailSelect(emails[nextIndex]); handleEmailSelect(activeEmails[nextIndex]);
} }
}, },
onPreviousEmail: () => { onPreviousEmail: () => {
if (emails.length === 0) return; if (activeEmails.length === 0) return;
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : emails.length; const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : activeEmails.length;
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0; const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
if (prevIndex >= 0 && prevIndex < emails.length) { if (prevIndex >= 0 && prevIndex < activeEmails.length) {
handleEmailSelect(emails[prevIndex]); handleEmailSelect(activeEmails[prevIndex]);
} }
}, },
onOpenEmail: () => { onOpenEmail: () => {
@@ -468,18 +509,23 @@ export default function Home() {
} }
}, },
onReply: () => { onReply: () => {
if (isScheduledView) return;
if (selectedEmail) handleReply(); if (selectedEmail) handleReply();
}, },
onReplyAll: () => { onReplyAll: () => {
if (isScheduledView) return;
if (selectedEmail) handleReplyAll(); if (selectedEmail) handleReplyAll();
}, },
onForward: () => { onForward: () => {
if (isScheduledView) return;
if (selectedEmail) handleForward(); if (selectedEmail) handleForward();
}, },
onToggleStar: () => { onToggleStar: () => {
if (isScheduledView) return;
if (selectedEmail) handleToggleStar(); if (selectedEmail) handleToggleStar();
}, },
onArchive: async () => { onArchive: async () => {
if (isScheduledView) return;
if (selectedEmailIds.size > 0 && client) { if (selectedEmailIds.size > 0 && client) {
try { try {
await batchArchive(client); await batchArchive(client);
@@ -491,6 +537,7 @@ export default function Home() {
} }
}, },
onDelete: async () => { onDelete: async () => {
if (isScheduledView) return;
if (selectedEmailIds.size > 0 && client) { if (selectedEmailIds.size > 0 && client) {
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash'; const isInTrash = currentMailbox?.role === 'trash';
@@ -520,6 +567,7 @@ export default function Home() {
} }
}, },
onMarkAsUnread: async () => { onMarkAsUnread: async () => {
if (isScheduledView) return;
if (!client) return; if (!client) return;
if (selectedEmailIds.size > 0) { if (selectedEmailIds.size > 0) {
await batchMarkAsRead(client, false); await batchMarkAsRead(client, false);
@@ -528,6 +576,7 @@ export default function Home() {
} }
}, },
onMarkAsRead: async () => { onMarkAsRead: async () => {
if (isScheduledView) return;
if (!client) return; if (!client) return;
if (selectedEmailIds.size > 0) { if (selectedEmailIds.size > 0) {
await batchMarkAsRead(client, true); await batchMarkAsRead(client, true);
@@ -536,6 +585,7 @@ export default function Home() {
} }
}, },
onToggleSpam: async () => { onToggleSpam: async () => {
if (isScheduledView) return;
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInJunk = currentMailbox?.role === 'junk'; const isInJunk = currentMailbox?.role === 'junk';
if (selectedEmailIds.size > 0 && client) { if (selectedEmailIds.size > 0 && client) {
@@ -563,6 +613,7 @@ export default function Home() {
if (isMobile) setActiveView('viewer'); if (isMobile) setActiveView('viewer');
}, },
onFocusSearch: () => { onFocusSearch: () => {
if (isScheduledView) return;
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement; const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
if (searchInput) { if (searchInput) {
searchInput.focus(); searchInput.focus();
@@ -574,22 +625,27 @@ export default function Home() {
}, },
onRefresh: async () => { onRefresh: async () => {
if (client && selectedMailbox) { if (client && selectedMailbox) {
if (selectedMailbox === SCHEDULED_MAILBOX_ID) {
await fetchScheduledEmails(client);
} else {
await fetchEmails(client, selectedMailbox); await fetchEmails(client, selectedMailbox);
} }
}
}, },
onSelectAll: () => { onSelectAll: () => {
if (isScheduledView) return;
selectAllEmails(); selectAllEmails();
}, },
onDeselectAll: () => { onDeselectAll: () => {
clearSelection(); clearSelection();
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]); }), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes, isScheduledView]);
// Initialize keyboard shortcuts // Initialize keyboard shortcuts
useKeyboardShortcuts({ useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer, enabled: isAuthenticated && !showComposer,
emails, emails: activeEmails,
selectedEmailId: selectedEmail?.id, selectedEmailId: selectedEmail?.id,
selectionCount: selectedEmailIds.size, selectionCount: selectedEmailIds.size,
handlers: keyboardHandlers, handlers: keyboardHandlers,
@@ -602,13 +658,36 @@ export default function Home() {
onRefresh: async () => { onRefresh: async () => {
if (!client) return; if (!client) return;
const state = useEmailStore.getState(); const state = useEmailStore.getState();
if (state.isScheduledView || state.selectedMailbox === SCHEDULED_MAILBOX_ID) {
await Promise.all([ await Promise.all([
state.fetchMailboxes(client), state.fetchMailboxes(client),
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client), state.fetchScheduledEmails(client),
]); ]);
} else {
await state.fetchMailboxes(client);
await state.refreshScheduledMetadata(client);
await (state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client));
}
}, },
}); });
useEffect(() => {
if (!delayedSendSupported && isScheduledView) {
setScheduledView(false);
}
}, [delayedSendSupported, isScheduledView, setScheduledView]);
useEffect(() => {
if (!pendingUndoSend) return;
const pendingSendTime = new Date(pendingUndoSend.sendAt).getTime();
if (!Number.isFinite(pendingSendTime) || pendingSendTime <= Date.now()) {
clearPendingUndoSend();
return;
}
const timer = setTimeout(clearPendingUndoSend, pendingSendTime - Date.now());
return () => clearTimeout(timer);
}, [clearPendingUndoSend, pendingUndoSend]);
// Update page title based on context // Update page title based on context
useEffect(() => { useEffect(() => {
let title = appName; let title = appName;
@@ -812,18 +891,26 @@ export default function Home() {
}, [isAuthenticated, client, handleMailtoProtocolRequest]); }, [isAuthenticated, client, handleMailtoProtocolRequest]);
// Fallback fetch for paths that didn't go through login()'s prefetch // Fallback fetch for paths that didn't go through login()'s prefetch
// (notably checkAuth on page refresh). The prefetch in auth-store/login() // (notably checkAuth on page refresh). Settings pages can also prefill
// populates mailboxes before this effect first runs, so on the post-login // mailboxes without emails, so bootstrap emails once per client when the
// path this block is a no-op. // mail route mounts even if mailbox data is already present.
useEffect(() => { useEffect(() => {
if (isAuthenticated && client && mailboxes.length === 0) { if (!isAuthenticated || !client) {
initialMailLoadClientRef.current = null;
return;
}
if (initialMailLoadClientRef.current === client) return;
initialMailLoadClientRef.current = client;
let retryTimer: ReturnType<typeof setTimeout> | null = null; let retryTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false; let cancelled = false;
const loadData = async (attempt = 1) => { const loadData = async (attempt = 1) => {
try { try {
const needsMailboxes = useEmailStore.getState().mailboxes.length === 0;
await Promise.all([ await Promise.all([
fetchMailboxes(client), needsMailboxes ? fetchMailboxes(client) : Promise.resolve(),
fetchQuota(client) fetchQuota(client)
]); ]);
@@ -837,6 +924,9 @@ export default function Home() {
return; return;
} }
await refreshScheduledMetadata(client);
// Fetch emails for the selected mailbox after scheduled metadata is available.
if (selectedMailboxId) { if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId); await fetchEmails(client, selectedMailboxId);
} else { } else {
@@ -846,6 +936,7 @@ export default function Home() {
fetchTagCounts(client); fetchTagCounts(client);
} catch (error) { } catch (error) {
console.error('Error loading email data:', error); console.error('Error loading email data:', error);
initialMailLoadClientRef.current = null;
} }
}; };
loadData(); loadData();
@@ -854,8 +945,7 @@ export default function Home() {
cancelled = true; cancelled = true;
if (retryTimer) clearTimeout(retryTimer); if (retryTimer) clearTimeout(retryTimer);
}; };
} }, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]);
// Push notifications: set up once per client and tear down when the client // Push notifications: set up once per client and tear down when the client
// goes away (logout or account switch). Kept separate from the fetch effect // goes away (logout or account switch). Kept separate from the fetch effect
@@ -889,12 +979,12 @@ export default function Home() {
useEffect(() => { useEffect(() => {
if (!enableUnifiedMailbox && !isEmbedded) return; if (!enableUnifiedMailbox && !isEmbedded) return;
if (!isAuthenticated || !client) return; if (!isAuthenticated || !client) return;
const built = buildUnifiedAccounts(); buildPopulatedUnifiedAccounts().then((built) => {
if (built.length < 2) return; const hasGroupEntry = built.some((b) => b.isShared);
populateUnifiedAccountMailboxes(built).then((populated) => { if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
refreshUnifiedCounts(populated); 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 // System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or // 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 // Only set timeout if there's a selected email, it's unread, and we have a client
if (!selectedEmail || !client || selectedEmail.keywords?.$seen) { if (!selectedEmail || !client || selectedEmail.keywords?.$seen || isScheduledView) {
return; return;
} }
@@ -996,7 +1086,7 @@ export default function Home() {
} }
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEmail?.id]); }, [selectedEmail?.id, isScheduledView]);
// Handle new email notifications - play sound // Handle new email notifications - play sound
useEffect(() => { useEffect(() => {
@@ -1037,6 +1127,7 @@ export default function Home() {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[]; inReplyTo?: string[];
references?: string[]; references?: string[];
delayedUntil?: string;
}) => { }) => {
if (!client) return; if (!client) return;
@@ -1044,8 +1135,13 @@ export default function Home() {
const effectiveMode = pendingDraft?.mode ?? composerMode; const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = selectedEmail?.id; const originalEmailId = selectedEmail?.id;
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.envelopeMailFrom); const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom);
setShowComposer(false); setShowComposer(false);
if (result.scheduled) {
await refreshScheduledMetadata(client);
if (isScheduledView) await fetchScheduledEmails(client);
return;
}
// Mark the original email with $answered or $forwarded keyword // Mark the original email with $answered or $forwarded keyword
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) { if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
@@ -1063,7 +1159,7 @@ export default function Home() {
} }
// Refresh the current mailbox to update the UI // Refresh the current mailbox to update the UI
await fetchEmails(client, selectedMailbox); if (!isScheduledView) await fetchEmails(client, selectedMailbox);
} catch (error) { } catch (error) {
console.error("Failed to send email:", error); console.error("Failed to send email:", error);
} }
@@ -1193,6 +1289,45 @@ export default function Home() {
if (isMobile) setActiveView('viewer'); if (isMobile) setActiveView('viewer');
}; };
useEffect(() => {
if (!pendingUndoSend || !client) return;
if (lastUndoToastSubmissionRef.current === pendingUndoSend.submissionId) return;
lastUndoToastSubmissionRef.current = pendingUndoSend.submissionId;
const pending = pendingUndoSend;
const undoDurationMs = Math.max(sendDelaySeconds, 8) * 1000;
toast.success(t('email_viewer.scheduled_send_created'), {
duration: undoDurationMs,
action: {
label: t('email_viewer.undo_send'),
onClick: () => {
void (async () => {
try {
const restored = await cancelUndoSend(client, pending);
if (restored && !pending.isSmime) {
await handleEditDraft(restored);
}
if (isScheduledView) await fetchScheduledEmails(client);
} catch (error) {
console.error('Failed to undo scheduled send:', error);
}
})();
},
},
});
const timer = setTimeout(() => {
const current = useEmailStore.getState().pendingUndoSend;
if (current?.submissionId === pending.submissionId) {
clearPendingUndoSend();
}
}, undoDurationMs);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t]);
const handleReplyAll = async () => { const handleReplyAll = async () => {
if (selectedEmail) { if (selectedEmail) {
const ok = await emailHooks.onBeforeReplyAll.intercept({ const ok = await emailHooks.onBeforeReplyAll.intercept({
@@ -1459,7 +1594,29 @@ export default function Home() {
}; };
const handleMailboxSelect = async (mailboxId: string) => { const handleMailboxSelect = async (mailboxId: string) => {
if (mailboxId === SCHEDULED_MAILBOX_ID) {
if (!delayedSendSupported) {
setScheduledView(false);
return;
}
if (isUnifiedView) exitUnifiedView();
setScheduledView(true);
selectMailbox(mailboxId);
selectEmail(null);
clearSelection();
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
if (client) await fetchScheduledEmails(client);
return;
}
if (isUnifiedMailboxId(mailboxId)) { if (isUnifiedMailboxId(mailboxId)) {
setScheduledView(false);
const role = UNIFIED_ROLE_BY_ID[mailboxId]; const role = UNIFIED_ROLE_BY_ID[mailboxId];
if (!role) return; if (!role) return;
@@ -1474,8 +1631,7 @@ export default function Home() {
setTabletListVisible(true); setTabletListVisible(true);
} }
const built = buildUnifiedAccounts(); const populated = await buildPopulatedUnifiedAccounts();
const populated = await populateUnifiedAccountMailboxes(built);
await fetchUnifiedEmailsAction(populated, role); await fetchUnifiedEmailsAction(populated, role);
refreshUnifiedCounts(populated); refreshUnifiedCounts(populated);
return; return;
@@ -1484,6 +1640,7 @@ export default function Home() {
if (isUnifiedView) { if (isUnifiedView) {
exitUnifiedView(); exitUnifiedView();
} }
setScheduledView(false);
selectMailbox(mailboxId); selectMailbox(mailboxId);
selectEmail(null); // Clear selected email when switching mailboxes selectEmail(null); // Clear selected email when switching mailboxes
@@ -1510,6 +1667,7 @@ export default function Home() {
}; };
const handleTagSelect = async (keywordId: string | null) => { const handleTagSelect = async (keywordId: string | null) => {
setScheduledView(false);
selectKeyword(keywordId); selectKeyword(keywordId);
// On mobile, close sidebar and go to list view // On mobile, close sidebar and go to list view
@@ -1753,17 +1911,24 @@ export default function Home() {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = '.eml,message/rfc822'; input.accept = EML_IMPORT_ACCEPT;
input.multiple = true; input.multiple = true;
input.onchange = async (e) => { input.onchange = async (e) => {
const files = Array.from((e.target as HTMLInputElement).files ?? []); const files = Array.from((e.target as HTMLInputElement).files ?? []);
if (files.length === 0) return; 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 imported = 0;
let failed = 0; let failed = 0;
for (const file of files) { for (const { blob } of emails) {
try { try {
const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' });
await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true }); await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true });
imported++; imported++;
} catch { } catch {
@@ -1775,7 +1940,7 @@ export default function Home() {
toast.success(t('notifications.import_email_success')); toast.success(t('notifications.import_email_success'));
if (selectedMailbox) await fetchEmails(client, selectedMailbox); if (selectedMailbox) await fetchEmails(client, selectedMailbox);
} }
if (failed > 0) { if (failed > 0 || (imported === 0 && emails.length === 0)) {
toast.error(t('notifications.import_email_error')); toast.error(t('notifications.import_email_error'));
} }
}; };
@@ -1813,8 +1978,7 @@ export default function Home() {
if (isUnifiedView) { if (isUnifiedView) {
const role = useEmailStore.getState().unifiedRole; const role = useEmailStore.getState().unifiedRole;
if (role) { if (role) {
const built = buildUnifiedAccounts(); const populated = await buildPopulatedUnifiedAccounts();
const populated = await populateUnifiedAccountMailboxes(built);
await fetchUnifiedEmailsAction(populated, role); await fetchUnifiedEmailsAction(populated, role);
} }
return; return;
@@ -1918,6 +2082,16 @@ export default function Home() {
}); });
const originalEmailId = selectedEmail.id; const originalEmailId = selectedEmail.id;
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
let delayedUntil: string | undefined;
if (sendDelaySeconds > 0) {
if (!client.hasDelayedSend()) {
const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm'));
if (!confirmed) return;
} else {
delayedUntil = new Date(Date.now() + sendDelaySeconds * 1000).toISOString();
}
}
// RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234). // RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234).
const threading = computeReplyThreadingHeaders({ const threading = computeReplyThreadingHeaders({
@@ -1926,7 +2100,7 @@ export default function Home() {
}); });
// Send reply with just the body text // Send reply with just the body text
await sendEmail( const result = await sendEmail(
client, client,
[sender.email], [sender.email],
`Re: ${selectedEmail.subject || "(no subject)"}`, `Re: ${selectedEmail.subject || "(no subject)"}`,
@@ -1941,9 +2115,15 @@ export default function Home() {
undefined, undefined,
threading?.inReplyTo, threading?.inReplyTo,
threading?.references, threading?.references,
delayedUntil,
envelopeMailFrom, envelopeMailFrom,
); );
if (result.scheduled) {
await refreshScheduledMetadata(client);
return;
}
// Mark the original email as answered // Mark the original email as answered
try { try {
await client.setKeyword(originalEmailId, '$answered'); await client.setKeyword(originalEmailId, '$answered');
@@ -1968,12 +2148,12 @@ export default function Home() {
} }
// Get current mailbox name for mobile header // Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox"; const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet; const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent); const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
const shouldHideViewerPane = !isMobile && !hasViewerContent && (isEmbedded || isFocusedMailLayout); const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent; const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
// Handle email selection with mobile view switching // Handle email selection with mobile view switching
@@ -1985,9 +2165,8 @@ export default function Home() {
setShowComposer(false); setShowComposer(false);
} }
// Show the list stub immediately so subject/sender render without
// waiting for the body fetch - avoids the loading flicker. // waiting for the body fetch - avoids the loading flicker.
const listEmail = emails.find(e => e.id === email.id); const listEmail = activeEmails.find(e => e.id === email.id);
if (listEmail) { if (listEmail) {
selectEmail(listEmail); selectEmail(listEmail);
} }
@@ -2024,6 +2203,14 @@ export default function Home() {
const fullEmail = await fetchClient.getEmail(email.id, accountId); const fullEmail = await fetchClient.getEmail(email.id, accountId);
if (fullEmail) { if (fullEmail) {
if (listEmail?.isScheduled) {
fullEmail.scheduledSendAt = listEmail.scheduledSendAt;
fullEmail.emailSubmissionId = listEmail.emailSubmissionId;
fullEmail.scheduledIdentityId = listEmail.scheduledIdentityId;
fullEmail.scheduledUndoStatus = listEmail.scheduledUndoStatus;
fullEmail.isScheduled = true;
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
}
if (emailAccountId) { if (emailAccountId) {
fullEmail.accountId = emailAccountId; fullEmail.accountId = emailAccountId;
fullEmail.accountLabel = listEmail?.accountLabel; fullEmail.accountLabel = listEmail?.accountLabel;
@@ -2056,14 +2243,14 @@ export default function Home() {
}; };
// Navigate to next/previous email in the list // Navigate to next/previous email in the list
const selectedEmailIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1; const selectedEmailIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1;
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < emails.length - 1 const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < activeEmails.length - 1
? () => handleEmailSelect(emails[selectedEmailIndex + 1]) ? () => handleEmailSelect(activeEmails[selectedEmailIndex + 1])
: undefined; : undefined;
const handleNavigatePrev = selectedEmailIndex > 0 const handleNavigatePrev = selectedEmailIndex > 0
? () => handleEmailSelect(emails[selectedEmailIndex - 1]) ? () => handleEmailSelect(activeEmails[selectedEmailIndex - 1])
: undefined; : undefined;
// Handle opening conversation view on mobile // Handle opening conversation view on mobile
@@ -2218,6 +2405,8 @@ export default function Home() {
mailboxes={mailboxes} mailboxes={mailboxes}
selectedMailbox={selectedMailbox} selectedMailbox={selectedMailbox}
selectedKeyword={selectedKeyword} selectedKeyword={selectedKeyword}
scheduledTotal={scheduledTotal}
showScheduledMailbox={delayedSendSupported}
onMailboxSelect={handleMailboxSelect} onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect} onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick} onUnreadFilterClick={handleUnreadFilterClick}
@@ -2299,26 +2488,27 @@ export default function Home() {
type="button" type="button"
onClick={() => { onClick={() => {
if (selectedEmailIds.size > 0) { if (selectedEmailIds.size > 0) {
if (selectedEmailIds.size === emails.length) { if (selectedEmailIds.size === activeEmails.length) {
clearSelection(); clearSelection();
} else { } else {
selectAllEmails(); selectAllEmails();
} }
} else if (emails.length > 0) { } else if (activeEmails.length > 0) {
const currentId = selectedEmail?.id; const currentId = selectedEmail?.id;
const target = currentId && emails.some((e) => e.id === currentId) const target = currentId && activeEmails.some((e) => e.id === currentId)
? currentId ? currentId
: emails[0].id; : activeEmails[0].id;
toggleEmailSelection(target); toggleEmailSelection(target);
} }
}} }}
disabled={isScheduledView}
className={cn( className={cn(
"flex-shrink-0 p-2 rounded-md transition-colors", "flex-shrink-0 p-2 rounded-md transition-colors",
selectedEmailIds.size > 0 selectedEmailIds.size > 0
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)} )}
title={selectedEmailIds.size > 0 ? (selectedEmailIds.size === emails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')} title={isScheduledView ? t('email_viewer.scheduled_actions_only') : selectedEmailIds.size > 0 ? (selectedEmailIds.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
> >
{selectedEmailIds.size > 0 ? ( {selectedEmailIds.size > 0 ? (
<CheckSquare className="w-4 h-4" /> <CheckSquare className="w-4 h-4" />
@@ -2336,6 +2526,8 @@ export default function Home() {
className={cn("pl-9 h-9", searchQuery && "pr-8")} className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input data-search-input
data-tour="search-input" data-tour="search-input"
disabled={isUnifiedView || isScheduledView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
/> />
{searchQuery && ( {searchQuery && (
<button <button
@@ -2351,13 +2543,15 @@ export default function Home() {
<button <button
type="button" type="button"
onClick={toggleAdvancedSearch} onClick={toggleAdvancedSearch}
disabled={isUnifiedView || isScheduledView}
className={cn( className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors", "relative flex-shrink-0 p-2 rounded-md transition-colors",
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0 isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)} )}
title={t("advanced_search.toggle_filters")} title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
> >
<Filter className="w-4 h-4" /> <Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && ( {!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
@@ -2499,11 +2693,17 @@ export default function Home() {
)} )}
</div> </div>
{(searchQuery || !isFilterEmpty(searchFilters)) && !isLoading && ( {(searchQuery || !isFilterEmpty(searchFilters)) && !activeIsLoading && !isScheduledView && (
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20"> <div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
{hasMoreEmails {activeHasMore
? t("advanced_search.results_found_more", { count: emails.length }) ? t("advanced_search.results_found_more", { count: activeEmails.length })
: t("advanced_search.results_found", { count: emails.length })} : t("advanced_search.results_found", { count: activeEmails.length })}
</div>
)}
{isScheduledView && !activeIsLoading && (
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
{t('email_list.scheduled_count', { count: scheduledTotal })}
</div> </div>
)} )}
@@ -2512,9 +2712,35 @@ export default function Home() {
<ErrorBoundary fallback={EmailListErrorFallback}> <ErrorBoundary fallback={EmailListErrorFallback}>
<EmailList <EmailList
emails={emails} emails={activeEmails}
selectedEmailId={selectedEmail?.id} selectedEmailId={selectedEmail?.id}
isLoading={isLoading} isLoading={activeIsLoading}
hasMore={activeHasMore}
isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined}
isScheduledView={isScheduledView}
onLoadMoreScheduled={() => client && loadMoreScheduledEmails(client)}
onCancelScheduled={async (email) => {
if (client && email.emailSubmissionId) await cancelScheduledEmail(client, email.emailSubmissionId, email.id);
}}
onCancelScheduledForEdit={async (email) => {
if (!client) return;
const restored = await cancelScheduledEmailForEdit(client, email);
if (email.isSmimeScheduled) {
setComposerMode('compose');
setPendingDraft(null);
} else if (restored) {
await handleEditDraft(restored);
return;
}
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
onRescheduleScheduled={async (email) => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil && client && email.emailSubmissionId && email.scheduledIdentityId) {
await rescheduleScheduledEmail(client, email.emailSubmissionId, email.id, email.scheduledIdentityId, delayedUntil);
}
}}
onEmailSelect={handleEmailSelect} onEmailSelect={handleEmailSelect}
onEmailDoubleClick={isEmbedded ? ((email) => { onEmailDoubleClick={isEmbedded ? ((email) => {
useProTabStore.getState().openEmailTab({ useProTabStore.getState().openEmailTab({
@@ -2675,6 +2901,14 @@ export default function Home() {
await handleEmailSend(data); await handleEmailSend(data);
setPendingDraft(null); setPendingDraft(null);
}} }}
onScheduledSendCreated={async () => {
if (client) {
await refreshScheduledMetadata(client);
if (isScheduledView) await fetchScheduledEmails(client);
}
setShowComposer(false);
setPendingDraft(null);
}}
onClose={() => { onClose={() => {
setShowComposer(false); setShowComposer(false);
setComposerMode('compose'); setComposerMode('compose');
@@ -2770,6 +3004,24 @@ export default function Home() {
onNavigatePrev={handleNavigatePrev} onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)} onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft} onEditDraft={handleEditDraft}
onCancelScheduled={async () => {
if (client && selectedEmail?.emailSubmissionId) await cancelScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id);
}}
onCancelScheduledForEdit={async () => {
if (!client || !selectedEmail) return;
const restored = await cancelScheduledEmailForEdit(client, selectedEmail);
if (selectedEmail.isSmimeScheduled) {
setComposerMode('compose');
setShowComposer(true);
return;
}
if (restored) await handleEditDraft(restored);
}}
onRescheduleScheduled={async (delayedUntil) => {
if (client && selectedEmail?.emailSubmissionId && selectedEmail.scheduledIdentityId) {
await rescheduleScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id, selectedEmail.scheduledIdentityId, delayedUntil);
}
}}
onCompose={() => { onCompose={() => {
setComposerMode('compose'); setComposerMode('compose');
setShowComposer(true); setShowComposer(true);
+2 -1
View File
@@ -16,6 +16,7 @@ import { PaneSizeContext } from "@/hooks/use-pane-size";
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar"; 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 { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getPathPrefix } from "@/lib/browser-navigation";
import MailPage from "@/app/(main)/[locale]/page"; import MailPage from "@/app/(main)/[locale]/page";
import CalendarPage from "@/app/(main)/[locale]/calendar/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 // enabled it. If either precondition stops holding, hand the user back
// to the standard shell. // to the standard shell.
if (isMobile || isTablet || !proInterface) { if (isMobile || isTablet || !proInterface) {
window.location.replace("/"); window.location.replace(`${getPathPrefix()}/`);
} }
}, [initialCheckDone, isMobile, isTablet, proInterface]); }, [initialCheckDone, isMobile, isTablet, proInterface]);
+8
View File
@@ -33,6 +33,7 @@ import {
Languages, Languages,
Info, Info,
Bug, Bug,
Download,
X, X,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
@@ -59,6 +60,7 @@ import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings'; import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings'; import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { FilesSettingsComponent } from '@/components/settings/files-settings'; import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { DownloadsSettings } from '@/components/settings/downloads-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings'; import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
@@ -90,6 +92,7 @@ type Tab =
| 'layout' | 'layout'
| 'reading' | 'reading'
| 'composing' | 'composing'
| 'downloads'
| 'identities' | 'identities'
| 'vacation' | 'vacation'
| 'filters' | 'filters'
@@ -126,6 +129,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
layout: LayoutGrid, layout: LayoutGrid,
reading: BookOpen, reading: BookOpen,
composing: PenLine, composing: PenLine,
downloads: Download,
identities: UserPen, identities: UserPen,
vacation: PalmtreeIcon, vacation: PalmtreeIcon,
filters: Filter, filters: Filter,
@@ -202,6 +206,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.email_behavior.signature_position', 'settings.email_behavior.signature_position',
'settings.email_behavior.sub_address_delimiter', 'settings.email_behavior.sub_address_delimiter',
], ],
downloads: ['settings.downloads'],
identities: ['settings.identities'], identities: ['settings.identities'],
vacation: ['settings.vacation'], vacation: ['settings.vacation'],
filters: ['settings.filters'], filters: ['settings.filters'],
@@ -236,6 +241,7 @@ const tabKeywords: Record<Tab, string> = {
layout: 'toolbar sidebar account switcher unified mailbox icons rail', layout: 'toolbar sidebar account switcher unified mailbox icons rail',
reading: 'mark read preview thread conversation archive delete attachment open', reading: 'mark read preview thread conversation archive delete attachment open',
composing: 'editor signature plain text reply forward draft compose', composing: 'editor signature plain text reply forward draft compose',
downloads: 'download filename template eml attachment save export',
identities: 'from address signature email', identities: 'from address signature email',
vacation: 'auto reply away out of office holiday responder', vacation: 'auto reply away out of office holiday responder',
filters: 'sieve rules block junk forward', filters: 'sieve rules block junk forward',
@@ -580,6 +586,7 @@ export default function SettingsPage() {
// Mail // Mail
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' }, { id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, 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' }, { 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 }] : []), ...(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 }] : []), ...(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 === 'layout' && <LayoutSettings />}
{effectiveActiveTab === 'reading' && <ReadingSettings />} {effectiveActiveTab === 'reading' && <ReadingSettings />}
{effectiveActiveTab === 'composing' && <ComposingSettings />} {effectiveActiveTab === 'composing' && <ComposingSettings />}
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
{effectiveActiveTab === 'identities' && <IdentitySettings />} {effectiveActiveTab === 'identities' && <IdentitySettings />}
{effectiveActiveTab === 'vacation' && <VacationSettings />} {effectiveActiveTab === 'vacation' && <VacationSettings />}
{effectiveActiveTab === 'filters' && <FilterSettings />} {effectiveActiveTab === 'filters' && <FilterSettings />}
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react'; import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers'; import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
@@ -77,30 +77,17 @@ function emptyDraft(): RowDraft {
export function JmapServersSection({ value, source, onChange, onRevert }: Props) { export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft)); const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
const lastEmittedRef = useRef(value);
useEffect(() => { useEffect(() => {
// Re-sync from props when the underlying config value changes (e.g. revert, if (value === lastEmittedRef.current) return;
// initial load). Skip when drafts already represent the same array to avoid setDrafts(value.map(entryToDraft))
// 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);
});
}, [value]); }, [value]);
function commit(next: RowDraft[]) { function commit(next: RowDraft[]) {
setDrafts(next); setDrafts(next);
const entries: JmapServerEntry[] = []; const entries = next.map(draftToEntry).filter((e): e is JmapServerEntry => e !== null);
for (const d of next) { lastEmittedRef.current = entries;
const e = draftToEntry(d);
if (e) entries.push(e);
}
onChange(entries); onChange(entries);
} }
+1
View File
@@ -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 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 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" /> <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 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:..." /> <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> </Section>
+1 -1
View File
@@ -28,7 +28,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] }, { key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' }, { key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' }, { 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: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] }, { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' }, { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
+3 -3
View File
@@ -32,7 +32,7 @@ import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store'; 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 // 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 - // 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 { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark' const logoUrl = withBasePath(resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl) ? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl); : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
// Match the navigation rail: red for security/deprecated, amber for normal. // Match the navigation rail: red for security/deprecated, amber for normal.
const hasUpdate = useUpdateStore(selectHasUpdate); const hasUpdate = useUpdateStore(selectHasUpdate);
+2 -2
View File
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import { Shield } from 'lucide-react'; import { Shield } from 'lucide-react';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store'; import { useThemeStore } from '@/stores/theme-store';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch, withBasePath } from '@/lib/browser-navigation';
export default function AdminLoginPage() { export default function AdminLoginPage() {
const router = useRouter(); const router = useRouter();
@@ -14,7 +14,7 @@ export default function AdminLoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl; const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
+2 -1
View File
@@ -5,6 +5,7 @@ import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { configManager } from "@/lib/admin/config-manager"; import { configManager } from "@/lib/admin/config-manager";
import { withBasePath } from "@/lib/browser-navigation";
import "../globals.css"; import "../globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -38,7 +39,7 @@ export async function generateMetadata(): Promise<Metadata> {
formatDetection: { formatDetection: {
telephone: false, telephone: false,
}, },
icons: { icon: faviconUrl }, icons: { icon: withBasePath(faviconUrl) },
}; };
} }
+7 -7
View File
@@ -3,7 +3,7 @@
import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react'; 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'; type State = 'bootstrap' | 'configured' | 'env-managed';
@@ -258,7 +258,7 @@ export default function SetupWizardPage() {
// edge cases that swallow client-side replaces after the // edge cases that swallow client-side replaces after the
// setupComplete flag flips. // setupComplete flag flips.
setTimeout(() => { setTimeout(() => {
window.location.assign('/admin/login'); window.location.assign(`${getPathPrefix()}/admin/login`);
}, 1500); }, 1500);
}} }}
/> />
@@ -340,13 +340,13 @@ function CompletedScreen() {
</div> </div>
<div className="mt-6 space-y-2"> <div className="mt-6 space-y-2">
<a <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" 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 Sign in to admin dashboard
</a> </a>
<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" 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 Open webmail login
@@ -422,13 +422,13 @@ function AlreadyConfiguredScreen() {
</div> </div>
<div className="mt-6 space-y-2"> <div className="mt-6 space-y-2">
<a <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" 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 Sign in to admin dashboard
</a> </a>
<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" 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 Open webmail login
@@ -1329,7 +1329,7 @@ function BrandingAsset({
}} }}
/> />
{value ? ( {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> <span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
)} )}
+2 -3
View File
@@ -3,9 +3,8 @@ import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { encryptPayload } from '@/lib/auth/crypto'; import { encryptPayload } from '@/lib/auth/crypto';
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server'; 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 { discoverOAuth } from '@/lib/oauth/discovery';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { getOauthScopes } from '@/lib/oauth/tokens'; import { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret'; import { hasSessionSecret } from '@/lib/auth/session-secret';
@@ -62,7 +61,7 @@ export async function POST(request: NextRequest) {
} }
const { clientId, discoveryUrl } = getRequiredConfig(serverId); const { clientId, discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl }); const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
if (!metadata?.authorization_endpoint) { if (!metadata?.authorization_endpoint) {
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 }); return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
+11 -3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery'; import { discoverOAuth } from '@/lib/oauth/discovery';
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env'; 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 // 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; if (metadata?.token_endpoint) return metadata.token_endpoint;
// 2. Try common Stalwart token endpoint paths directly // 2. Try common Stalwart token endpoint paths directly
@@ -105,14 +110,17 @@ export async function POST(request: NextRequest) {
let upstreamUrl: string; let upstreamUrl: string;
let resolvedServerId: string | null = null; let resolvedServerId: string | null = null;
let adminTrusted = false;
const requestedEntry = findServerById(serverList, requestedServerId); const requestedEntry = findServerById(serverList, requestedServerId);
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl); const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
if (matchedEntry) { if (matchedEntry) {
upstreamUrl = matchedEntry.url; upstreamUrl = matchedEntry.url;
resolvedServerId = matchedEntry.id; resolvedServerId = matchedEntry.id;
adminTrusted = true;
} else if (configuredServerUrl) { } else if (configuredServerUrl) {
upstreamUrl = configuredServerUrl; upstreamUrl = configuredServerUrl;
adminTrusted = true;
} else if (allowCustomEndpoint) { } else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) { if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP token exchange: rejected non-public server URL'); 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 }); return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
} }
const tokenEndpoint = await findTokenEndpoint(upstreamUrl); const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
if (!tokenEndpoint) { if (!tokenEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found'); 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 }); return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
+51 -3
View File
@@ -10,6 +10,8 @@ import { NextRequest, NextResponse } from 'next/server';
*/ */
const ACCOUNT_ID = 'dev-account-001'; const ACCOUNT_ID = 'dev-account-001';
const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = [];
const emailCreationIds = new Map<string, string>();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mailboxes // Mailboxes
@@ -1533,6 +1535,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
bodyValues: {}, bodyValues: {},
}; };
emails.unshift(newEmail); emails.unshift(newEmail);
emailCreationIds.set(key, newId);
created[key] = { id: newId }; created[key] = { id: newId };
} }
} }
@@ -1575,8 +1578,51 @@ function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId]; return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
} }
function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult { function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult {
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: { 'sub-1': { id: 'sub-mock-1' } }, notCreated: null }, callId]; const created: Record<string, { id: string; sendAt?: string }> = {};
const updated: Record<string, null> = {};
const create = args.create as Record<string, { emailId?: string; identityId?: string; envelope?: { mailFrom?: { parameters?: { HOLDFOR?: string; HOLDUNTIL?: string } } } }> | undefined;
if (create) {
for (const [key, value] of Object.entries(create)) {
const id = `submission-${Date.now()}-${key}`;
const holdFor = value.envelope?.mailFrom?.parameters?.HOLDFOR;
const holdUntil = value.envelope?.mailFrom?.parameters?.HOLDUNTIL;
const holdForSeconds = holdFor ? Number(holdFor) : Number.NaN;
const holdUntilTime = Number.isFinite(holdForSeconds) && holdForSeconds > 0
? Date.now() + holdForSeconds * 1000
: holdUntil ? new Date(holdUntil).getTime() : Number.NaN;
const delayedUntil = Number.isFinite(holdUntilTime) ? new Date(holdUntilTime).toISOString() : undefined;
created[key] = { id, ...(delayedUntil ? { sendAt: delayedUntil } : {}) };
if (delayedUntil && value.emailId && value.identityId) {
const emailId = value.emailId.startsWith('#') ? emailCreationIds.get(value.emailId.slice(1)) || value.emailId : value.emailId;
scheduledSubmissions.push({ id, emailId, identityId: value.identityId, sendAt: delayedUntil, undoStatus: 'pending' });
}
}
}
const update = args.update as Record<string, { undoStatus?: 'pending' | 'final' | 'canceled' }> | undefined;
if (update) {
for (const [id, patch] of Object.entries(update)) {
const submission = scheduledSubmissions.find(s => s.id === id);
if (submission && patch.undoStatus) {
submission.undoStatus = patch.undoStatus;
updated[id] = null;
}
}
}
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, notCreated: null, notUpdated: null }, callId];
}
function handleEmailSubmissionQuery(args: MethodArgs, callId: string): MethodResult {
const position = Number(args.position || 0);
const limit = Number(args.limit || 50);
const submissions = [...scheduledSubmissions].sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
return ['EmailSubmission/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: submissions.slice(position, position + limit).map(s => s.id), total: submissions.length, position, canCalculateChanges: false }, callId];
}
function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResult {
const ids = args.ids as string[] | undefined;
const list = ids ? scheduledSubmissions.filter(s => ids.includes(s.id)) : scheduledSubmissions;
return ['EmailSubmission/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
} }
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult { function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
@@ -1639,6 +1685,8 @@ const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => Meth
'Identity/get': handleIdentityGet, 'Identity/get': handleIdentityGet,
'Identity/set': handleIdentitySet, 'Identity/set': handleIdentitySet,
'EmailSubmission/set': handleEmailSubmissionSet, 'EmailSubmission/set': handleEmailSubmissionSet,
'EmailSubmission/query': handleEmailSubmissionQuery,
'EmailSubmission/get': handleEmailSubmissionGet,
'Quota/get': handleQuotaGet, 'Quota/get': handleQuotaGet,
'VacationResponse/get': handleVacationResponseGet, 'VacationResponse/get': handleVacationResponseGet,
'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId], 'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId],
@@ -1776,7 +1824,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
isReadOnly: false, isReadOnly: false,
accountCapabilities: { accountCapabilities: {
'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {}, 'urn:ietf:params:jmap:submission': { maxDelayedSend: 2592000, submissionExtensions: { FUTURERELEASE: true } },
'urn:ietf:params:jmap:quota': {}, 'urn:ietf:params:jmap:quota': {},
'urn:ietf:params:jmap:vacationresponse': {}, 'urn:ietf:params:jmap:vacationresponse': {},
'urn:ietf:params:jmap:contacts': {}, 'urn:ietf:params:jmap:contacts': {},
+2 -2
View File
@@ -242,8 +242,8 @@ body {
@media (max-width: 640px) { @media (max-width: 640px) {
.email-content-text { .email-content-text {
padding-left: 0; padding-left: 0.75rem;
padding-right: 0; padding-right: 0.75rem;
} }
} }
+5 -2
View File
@@ -78,7 +78,11 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
const calendarName = calendar?.name || ""; const calendarName = calendar?.name || "";
const durationMinutes = parseDuration(event.duration); const durationMinutes = parseDuration(event.duration);
const endTime = getEventEndDate(event); 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 ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
const handleDragStart = useCallback((e: DragEvent) => { 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", "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", "hover:opacity-90 transition-opacity cursor-pointer",
continuesAfter && "rounded-r-sm", continuesAfter && "rounded-r-sm",
continuesBefore && "-ml-0.5",
continuesAfter && "pr-2", continuesAfter && "pr-2",
isSelected && "ring-2 ring-primary", isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50", isBeingDragged && "opacity-50",
+38 -2
View File
@@ -8,11 +8,11 @@ import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft, X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check, Pencil, Trash2, Copy, Send, Check,
} from "lucide-react"; } from "lucide-react";
import { format, parseISO } from "date-fns"; import { format, isSameDay, parseISO } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card"; import { parseDuration, getEventColor } from "./event-card";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { import {
isOrganizer, isOrganizer,
getUserParticipantId, getUserParticipantId,
@@ -143,6 +143,8 @@ export function EventDetailPopover({
const startDate = getEventStartDate(event); const startDate = getEventStartDate(event);
const durationMinutes = parseDuration(event.duration); const durationMinutes = parseDuration(event.duration);
const endDate = getEventEndDate(event); const endDate = getEventEndDate(event);
const displayEndDate = getEventDisplayEndDate(event);
const isMultiDay = !isSameDay(startDate, displayEndDate);
const locationName = useMemo(() => { const locationName = useMemo(() => {
if (!event.locations) return null; if (!event.locations) return null;
@@ -331,6 +333,38 @@ export function EventDetailPopover({
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> <Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm"> <div className="text-sm">
{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>
</>
)
) : (
<>
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{formatEventDate(startDate)} {formatEventDate(startDate)}
</span> </span>
@@ -342,6 +376,8 @@ export function EventDetailPopover({
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span> <span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
</div> </div>
)} )}
</>
)}
</div> </div>
</div> </div>
+67 -1
View File
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react"; 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 type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card"; import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
@@ -621,6 +621,32 @@ export function EventModal({
</div> </div>
</div> </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"> <div className="text-sm">
<span className="font-medium">{formatEventDate(startD)}</span> <span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && ( {!event.showWithoutTime && (
@@ -629,6 +655,8 @@ export function EventModal({
</span> </span>
)} )}
</div> </div>
);
})()}
{event.description && ( {event.description && (
<p className="text-sm text-muted-foreground">{event.description}</p> <p className="text-sm text-muted-foreground">{event.description}</p>
@@ -742,6 +770,41 @@ export function EventModal({
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> <Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm"> <div className="text-sm">
{(() => {
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"> <span className="font-medium text-foreground">
{formatEventDate(startD)} {formatEventDate(startD)}
</span> </span>
@@ -753,6 +816,9 @@ export function EventModal({
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span> <span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
</div> </div>
)} )}
</>
);
})()}
</div> </div>
</div> </div>
+236 -7
View File
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react"; import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
@@ -88,7 +88,9 @@ interface EmailComposerProps {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[]; inReplyTo?: string[];
references?: string[]; references?: string[];
delayedUntil?: string;
}) => void | Promise<void>; }) => void | Promise<void>;
onScheduledSendCreated?: () => void | Promise<void>;
onClose?: () => void; onClose?: () => void;
onDiscardDraft?: (draftId: string) => void; onDiscardDraft?: (draftId: string) => void;
onSaveState?: (data: ComposerDraftData) => void; onSaveState?: (data: ComposerDraftData) => void;
@@ -168,8 +170,21 @@ function buildEmbeddedSignatureHtml(
return ''; return '';
} }
function formatLocalDateTimeInput(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function getDefaultScheduleValue(): string {
const tomorrowAtEight = new Date();
tomorrowAtEight.setDate(tomorrowAtEight.getDate() + 1);
tomorrowAtEight.setHours(8, 0, 0, 0);
return formatLocalDateTimeInput(tomorrowAtEight);
}
export function EmailComposer({ export function EmailComposer({
onSend, onSend,
onScheduledSendCreated,
onClose, onClose,
onDiscardDraft, onDiscardDraft,
onSaveState, onSaveState,
@@ -187,6 +202,7 @@ export function EmailComposer({
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const signaturePosition = useSettingsStore((state) => state.signaturePosition); const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const activeIdentities = useIdentityStore((s) => s.identities); const activeIdentities = useIdentityStore((s) => s.identities);
@@ -390,6 +406,12 @@ export function EmailComposer({
const [smimePassphraseError, setSmimePassphraseError] = useState(''); const [smimePassphraseError, setSmimePassphraseError] = useState('');
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false); const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState(''); const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>();
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
const [scheduleValue, setScheduleValue] = useState('');
const [scheduleError, setScheduleError] = useState('');
const [showSendMenu, setShowSendMenu] = useState(false);
const sendMenuRef = useRef<HTMLDivElement>(null);
const saveTemplateModalRef = useFocusTrap({ const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate, isActive: showSaveAsTemplate,
@@ -495,6 +517,23 @@ export function EmailComposer({
} }
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]); }, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
useEffect(() => {
const handleClickOutsideSendMenu = (event: MouseEvent) => {
if (!sendMenuRef.current?.contains(event.target as Node)) {
setShowSendMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutsideSendMenu);
return () => document.removeEventListener('mousedown', handleClickOutsideSendMenu);
}, []);
const openScheduleDialog = useCallback(() => {
setScheduleError('');
setScheduleValue(getDefaultScheduleValue());
setShowScheduleDialog(true);
setShowSendMenu(false);
}, []);
useEffect(() => { useEffect(() => {
if (!autoSelectReplyIdentity) return; if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return; if (selectedIdentityId || initialData?.selectedIdentityId) return;
@@ -1184,6 +1223,33 @@ export function EmailComposer({
return undefined; return undefined;
}; };
const validateScheduleValue = (value: string): string | null => {
if (!value) return t('schedule_send_required');
const time = new Date(value).getTime();
if (!Number.isFinite(time)) return t('schedule_send_invalid');
if (time <= Date.now()) return t('schedule_send_future');
if (composerClient) {
const maxDelayedSend = composerClient.getMaxDelayedSend();
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
return t('schedule_send_too_late');
}
}
return null;
};
const resolveDelayedUntil = async (requestedDelayedUntil?: string): Promise<string | undefined> => {
if (requestedDelayedUntil) return requestedDelayedUntil;
if (sendDelaySeconds === 0) return undefined;
if (composerClient?.hasDelayedSend()) {
return new Date(Date.now() + sendDelaySeconds * 1000).toISOString();
}
const confirmed = window.confirm(t('send_delay_unsupported_confirm'));
if (!confirmed) {
throw new Error(t('send_delay_unsupported'));
}
return undefined;
};
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid: // Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
// references so recipient clients that strip data URIs can still render them. // references so recipient clients that strip data URIs can still render them.
const rewriteInlineImages = (html: string): { const rewriteInlineImages = (html: string): {
@@ -1227,7 +1293,7 @@ export function EmailComposer({
}; };
}; };
const handleSend = async (skipAttachmentCheck = false) => { const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
@@ -1255,6 +1321,7 @@ export function EmailComposer({
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase())); const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) { if (matched) {
setAttachmentWarningKeyword(matched); setAttachmentWarningKeyword(matched);
setAttachmentWarningDelayedUntil(delayedUntil);
setShowAttachmentWarning(true); setShowAttachmentWarning(true);
return; return;
} }
@@ -1343,6 +1410,7 @@ export function EmailComposer({
const inlineAttachments = rewritten?.attachments ?? []; const inlineAttachments = rewritten?.attachments ?? [];
try { try {
const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil);
// Let plugins veto the send (external-mail warning, mistyped-domain // Let plugins veto the send (external-mail warning, mistyped-domain
// guards, etc.). Returning false from any handler aborts before either // guards, etc.). Returning false from any handler aborts before either
// the S/MIME or standard JMAP path runs. // the S/MIME or standard JMAP path runs.
@@ -1492,7 +1560,16 @@ export function EmailComposer({
} }
// 7. Send via raw email path // 7. Send via raw email path
await sendRawEmail(client, payload, currentIdentity.id); const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses]);
if (effectiveDelayedUntil && finalDraftId) {
client.deleteEmail(finalDraftId).catch(err => {
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
toast.warning(t('schedule_send_cleanup_warning'));
});
}
if (result.scheduled) {
await onScheduledSendCreated?.();
}
} else { } else {
// Standard JMAP send path // Standard JMAP send path
// Collect uploaded attachment blobIds for the send request // Collect uploaded attachment blobIds for the send request
@@ -1542,6 +1619,7 @@ export function EmailComposer({
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo, inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references, references: threadingHeaders?.references,
delayedUntil: effectiveDelayedUntil,
}); });
if (mode === 'reply' || mode === 'replyAll') { if (mode === 'reply' || mode === 'replyAll') {
@@ -1566,14 +1644,54 @@ export function EmailComposer({
setDraftId(null); setDraftId(null);
setSubAddressTag(""); setSubAddressTag("");
setValidationErrors({}); setValidationErrors({});
setShowScheduleDialog(false);
setScheduleValue('');
setScheduleError('');
// Clear ref so unmount effect doesn't re-save // Clear ref so unmount effect doesn't re-save
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' }; stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
} catch (err) { } catch (err) {
debug.error('Failed to send email:', err); debug.error('Failed to send email:', err);
toast.error(t('send_failed')); toast.error(err instanceof Error ? err.message : t('send_failed'));
} }
}; };
const handleScheduleSend = () => {
if (!composerClient?.hasDelayedSend()) {
setScheduleError(t('schedule_send_unsupported'));
return;
}
const error = validateScheduleValue(scheduleValue);
if (error) {
setScheduleError(error);
return;
}
handleSend(false, new Date(scheduleValue).toISOString());
};
// Ctrl+Enter (Win/Linux) / Cmd+Enter (macOS) sends the open compose
// 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 = () => { const cleanClose = () => {
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
@@ -1612,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 ( return (
<div className={cn("flex h-full bg-background", className)}> <div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
<PluginSlot <PluginSlot
name="composer-sidebar" name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border" className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
@@ -1626,6 +1786,7 @@ export function EmailComposer({
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
onKeyDown={handleComposerKeyDown}
> >
{/* Drag overlay */} {/* Drag overlay */}
{isDraggingOver && ( {isDraggingOver && (
@@ -2053,7 +2214,6 @@ export function EmailComposer({
> >
<BookmarkPlus className="w-4 h-4" /> <BookmarkPlus className="w-4 h-4" />
</Button> </Button>
{/* S/MIME toggles */} {/* S/MIME toggles */}
{canSmimeSign && ( {canSmimeSign && (
<> <>
@@ -2091,6 +2251,46 @@ export function EmailComposer({
> >
{t('discard')} {t('discard')}
</button> </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 <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend}
@@ -2100,6 +2300,7 @@ export function EmailComposer({
<Send className="w-4 h-4 mr-2" /> <Send className="w-4 h-4 mr-2" />
{t('send')} {t('send')}
</Button> </Button>
)}
</div> </div>
</div> </div>
@@ -2138,6 +2339,29 @@ export function EmailComposer({
</div> </div>
)} )}
{showScheduleDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md p-6 animate-in zoom-in-95 duration-200">
<h3 className="text-lg font-semibold text-foreground mb-2">{t('schedule_send')}</h3>
<p className="text-sm text-muted-foreground mb-4">{t('schedule_send_description')}</p>
<Input
type="datetime-local"
value={scheduleValue}
onChange={(e) => {
setScheduleValue(e.target.value);
setScheduleError('');
}}
className={cn(scheduleError && "border-destructive focus-visible:ring-destructive")}
/>
{scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowScheduleDialog(false)}>{tCommon('cancel')}</Button>
<Button onClick={handleScheduleSend} disabled={!canSend}>{t('schedule_send')}</Button>
</div>
</div>
</div>
)}
{/* S/MIME passphrase prompt */} {/* S/MIME passphrase prompt */}
{smimePassphrasePrompt && ( {smimePassphrasePrompt && (
<div <div
@@ -2214,7 +2438,7 @@ export function EmailComposer({
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}> <Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
{t('forgot_attachment.back')} {t('forgot_attachment.back')}
</Button> </Button>
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}> <Button onClick={() => { setShowAttachmentWarning(false); handleSend(true, attachmentWarningDelayedUntil); setAttachmentWarningDelayedUntil(undefined); }}>
{t('forgot_attachment.send_anyway')} {t('forgot_attachment.send_anyway')}
</Button> </Button>
</div> </div>
@@ -2461,6 +2685,11 @@ function RecipientChipInput({
aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined} aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined}
aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
aria-invalid={validationError || undefined} aria-invalid={validationError || undefined}
data-bwignore="true"
data-1p-ignore
data-op-ignore
data-lpignore="true"
data-form-type="other"
/> />
</div> </div>
{validationError && validationMessage && ( {validationError && validationMessage && (
+42 -2
View File
@@ -30,6 +30,8 @@ import {
ShieldAlert, ShieldAlert,
ShieldCheck, ShieldCheck,
EditIcon, EditIcon,
CalendarClock,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
@@ -63,6 +65,9 @@ interface EmailContextMenuProps {
onMarkAsSpam?: () => void; onMarkAsSpam?: () => void;
onUndoSpam?: () => void; onUndoSpam?: () => void;
onEditDraft?: () => void; onEditDraft?: () => void;
onCancelScheduled?: () => void;
onCancelScheduledForEdit?: () => void;
onRescheduleScheduled?: () => void;
// Batch actions // Batch actions
onBatchMarkAsRead?: (read: boolean) => void; onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void; onBatchDelete?: () => void;
@@ -133,6 +138,9 @@ export function EmailContextMenu({
onBatchMarkAsSpam, onBatchMarkAsSpam,
onBatchUndoSpam, onBatchUndoSpam,
onEditDraft, onEditDraft,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
}: EmailContextMenuProps) { }: EmailContextMenuProps) {
const t = useTranslations("context_menu"); const t = useTranslations("context_menu");
const _tColor = useTranslations("email_viewer.color_tag"); const _tColor = useTranslations("email_viewer.color_tag");
@@ -143,6 +151,8 @@ export function EmailContextMenu({
const currentColors = getCurrentColors(email.keywords); const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1; const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk'; const isInJunkFolder = currentMailboxRole === 'junk';
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
// Build color options from keyword definitions in settings // Build color options from keyword definitions in settings
const colorOptions = emailKeywords.map((kw) => ({ const colorOptions = emailKeywords.map((kw) => ({
@@ -196,8 +206,36 @@ export function EmailContextMenu({
</ContextMenuHeader> </ContextMenuHeader>
)} )}
{isScheduled && !showBatchActions && canCancelScheduled && (
<>
<ContextMenuItem
icon={CalendarClock}
label={t("reschedule_send")}
onClick={() => handleAction(onRescheduleScheduled!)}
disabled={!onRescheduleScheduled}
/>
<ContextMenuItem
icon={XCircle}
label={t("cancel_scheduled_send")}
onClick={() => handleAction(onCancelScheduled!)}
disabled={!onCancelScheduled}
/>
<ContextMenuItem
icon={EditIcon}
label={email.isSmimeScheduled ? t("cancel_and_compose_again") : t("cancel_and_edit")}
onClick={() => handleAction(onCancelScheduledForEdit!)}
disabled={!onCancelScheduledForEdit}
/>
</>
)}
{canCancelScheduled && <ContextMenuSeparator />}
{!isScheduled && (
<>
{/* Edit Draft - only for single draft emails */} {/* Edit Draft - only for single draft emails */}
{!showBatchActions && isDraft && onEditDraft && ( {!isScheduled && !showBatchActions && isDraft && onEditDraft && (
<> <>
<ContextMenuItem <ContextMenuItem
icon={EditIcon} icon={EditIcon}
@@ -209,7 +247,7 @@ export function EmailContextMenu({
)} )}
{/* Single email actions - Reply, Reply All, Forward */} {/* Single email actions - Reply, Reply All, Forward */}
{!showBatchActions && ( {!isScheduled && !showBatchActions && (
<> <>
<ContextMenuItem <ContextMenuItem
icon={Reply} icon={Reply}
@@ -375,6 +413,8 @@ export function EmailContextMenu({
) )
} }
/> />
</>
)}
<PluginSlot name="context-menu-email" /> <PluginSlot name="context-menu-email" />
</ContextMenu> </ContextMenu>
+3 -3
View File
@@ -50,7 +50,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role; const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; 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 hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const trimmedPreview = stripInvisibleLeading(email.preview ?? ''); const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
@@ -68,8 +70,6 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
sourceMailboxId: selectedMailbox, sourceMailboxId: selectedMailbox,
}); });
const isMobile = useUIStore((state) => state.isMobile);
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress( const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => { useCallback((pos) => {
onContextMenu?.( onContextMenu?.(
+39 -11
View File
@@ -4,13 +4,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item"; import { ThreadListItem } from "./thread-list-item";
import { EmailContextMenu } from "./email-context-menu"; import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle } from "lucide-react"; import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils"; import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -26,6 +27,8 @@ interface EmailListProps {
onEmailDoubleClick?: (email: Email) => void; onEmailDoubleClick?: (email: Email) => void;
className?: string; className?: string;
isLoading?: boolean; isLoading?: boolean;
hasMore?: boolean;
isLoadingMoreItems?: boolean;
onOpenConversation?: (thread: ThreadGroup) => void; onOpenConversation?: (thread: ThreadGroup) => void;
onReply?: (email: Email) => void; onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void; onReplyAll?: (email: Email) => void;
@@ -39,6 +42,11 @@ interface EmailListProps {
onMarkAsSpam?: (email: Email) => void; onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void;
onEditDraft?: (email: Email) => void; onEditDraft?: (email: Email) => void;
isScheduledView?: boolean;
onLoadMoreScheduled?: () => void;
onCancelScheduled?: (email: Email) => void | Promise<void>;
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
onRescheduleScheduled?: (email: Email) => void | Promise<void>;
} }
export function EmailList({ export function EmailList({
@@ -48,6 +56,8 @@ export function EmailList({
onEmailDoubleClick, onEmailDoubleClick,
className, className,
isLoading = false, isLoading = false,
hasMore,
isLoadingMoreItems,
onOpenConversation, onOpenConversation,
onReply, onReply,
onReplyAll, onReplyAll,
@@ -61,6 +71,11 @@ export function EmailList({
onUndoSpam, onUndoSpam,
onMoveToMailbox, onMoveToMailbox,
onEditDraft, onEditDraft,
isScheduledView = false,
onLoadMoreScheduled,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
}: EmailListProps) { }: EmailListProps) {
const t = useTranslations('email_list'); const t = useTranslations('email_list');
const { client } = useAuthStore(); const { client } = useAuthStore();
@@ -95,9 +110,9 @@ export function EmailList({
const disableThreading = useSettingsStore((state) => state.disableThreading); const disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => { const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails, disableThreading); const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
return sortThreadGroups(groups); return sortThreadGroups(groups);
}, [emails, disableThreading]); }, [emails, disableThreading, isScheduledView]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>(); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -107,7 +122,11 @@ export function EmailList({
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const 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(() => { const estimateSize = useCallback(() => {
if (isFocusedMailLayout) { if (isFocusedMailLayout) {
@@ -216,10 +235,14 @@ export function EmailList({
}; };
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
if (isScheduledView) {
onLoadMoreScheduled?.();
return;
}
if (client && hasMoreEmails && !isLoadingMore && !isLoading) { if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
loadMoreEmails(client); loadMoreEmails(client);
} }
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]); }, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
const handleToggleThreadExpansion = useCallback(async (threadId: string) => { const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
const isExpanded = expandedThreadIds.has(threadId); const isExpanded = expandedThreadIds.has(threadId);
@@ -279,7 +302,7 @@ export function EmailList({
<div <div
className={cn( className={cn(
"transition-all duration-300 ease-in-out overflow-hidden", "transition-all duration-300 ease-in-out overflow-hidden",
hasSelection ? "max-h-16 opacity-100" : "max-h-0 opacity-0" hasSelection && !isScheduledView ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
)} )}
> >
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between"> <div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
@@ -403,17 +426,19 @@ export function EmailList({
) : emails.length === 0 && !isLoading ? ( ) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full py-12"> <div className="flex flex-col items-center justify-center h-full py-12">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted shadow-lg flex items-center justify-center"> <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted shadow-lg flex items-center justify-center">
{searchQuery || !isFilterEmpty(searchFilters) ? ( {isScheduledView ? (
<CalendarClock className="w-10 h-10 text-muted-foreground" />
) : searchQuery || !isFilterEmpty(searchFilters) ? (
<SearchX className="w-10 h-10 text-muted-foreground" /> <SearchX className="w-10 h-10 text-muted-foreground" />
) : ( ) : (
<MailX className="w-10 h-10 text-muted-foreground" /> <MailX className="w-10 h-10 text-muted-foreground" />
)} )}
</div> </div>
<p className="text-base font-medium text-foreground"> <p className="text-base font-medium text-foreground">
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')} {isScheduledView ? t('no_scheduled_emails') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
</p> </p>
<p className="text-sm mt-1 text-muted-foreground"> <p className="text-sm mt-1 text-muted-foreground">
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')} {isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
</p> </p>
</div> </div>
) : ( ) : (
@@ -465,13 +490,13 @@ export function EmailList({
</div> </div>
<div className="py-4 flex justify-center"> <div className="py-4 flex justify-center">
{isLoadingMore && hasMoreEmails && ( {footerIsLoadingMore && footerHasMore && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
<span>{t('loading_more')}</span> <span>{t('loading_more')}</span>
</div> </div>
)} )}
{!hasMoreEmails && emails.length > 0 && ( {!footerHasMore && emails.length > 0 && (
<div className="text-sm text-muted-foreground border-t border-border pt-6"> <div className="text-sm text-muted-foreground border-t border-border pt-6">
{t('no_more_emails')} {t('no_more_emails')}
</div> </div>
@@ -506,6 +531,9 @@ export function EmailList({
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
onEditDraft={() => onEditDraft?.(contextMenu.data!)} onEditDraft={() => onEditDraft?.(contextMenu.data!)}
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)} onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => { onBatchArchive={async () => {
+198 -39
View File
@@ -3,8 +3,11 @@
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react"; import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; 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 { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
@@ -63,6 +66,7 @@ import {
EditIcon, EditIcon,
PlayCircle, PlayCircle,
PenSquare, PenSquare,
CalendarClock,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
@@ -73,6 +77,7 @@ import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { useDeviceDetection } from "@/hooks/use-media-query"; import { useDeviceDetection } from "@/hooks/use-media-query";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge"; import { EmailIdentityBadge } from "./email-identity-badge";
@@ -122,6 +127,9 @@ interface EmailViewerProps {
onNavigatePrev?: () => void; onNavigatePrev?: () => void;
onShowShortcuts?: () => void; onShowShortcuts?: () => void;
onEditDraft?: () => void; onEditDraft?: () => void;
onCancelScheduled?: () => void;
onCancelScheduledForEdit?: () => void;
onRescheduleScheduled?: (delayedUntil: string) => void;
onCompose?: () => void; onCompose?: () => void;
currentUserEmail?: string; currentUserEmail?: string;
currentUserName?: string; currentUserName?: string;
@@ -799,6 +807,7 @@ interface DraggableAttachmentChipProps {
attachment: EffectiveAttachment; attachment: EffectiveAttachment;
client: IJMAPClient | null; client: IJMAPClient | null;
enabled: boolean; enabled: boolean;
downloadName?: string;
children: (dragProps: { children: (dragProps: {
draggable: boolean; draggable: boolean;
onPointerEnter: () => void; onPointerEnter: () => void;
@@ -807,9 +816,9 @@ interface DraggableAttachmentChipProps {
}) => React.ReactNode; }) => React.ReactNode;
} }
function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) { function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
const source = useMemo<AttachmentDragSource>(() => ({ const source = useMemo<AttachmentDragSource>(() => ({
name: attachment.name || 'download', name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream', type: attachment.type || 'application/octet-stream',
getBlobUrl: async () => { getBlobUrl: async () => {
if (attachment.blobId && client) { if (attachment.blobId && client) {
@@ -832,7 +841,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, children }: Drag
} }
return null; return null;
}, },
}), [attachment, client]); }), [attachment, client, downloadName]);
const drag = useAttachmentDrag(source, enabled); const drag = useAttachmentDrag(source, enabled);
return <>{children(drag)}</>; return <>{children(drag)}</>;
} }
@@ -870,6 +879,9 @@ export function EmailViewer({
onNavigatePrev, onNavigatePrev,
onShowShortcuts, onShowShortcuts,
onEditDraft, onEditDraft,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
onCompose, onCompose,
currentUserEmail, currentUserEmail,
currentUserName, currentUserName,
@@ -879,6 +891,7 @@ export function EmailViewer({
className, className,
}: EmailViewerProps) { }: EmailViewerProps) {
const t = useTranslations('email_viewer'); const t = useTranslations('email_viewer');
const tComposer = useTranslations('email_composer');
const tNotifications = useTranslations('notifications'); const tNotifications = useTranslations('notifications');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tSmime = useTranslations('smime'); const tSmime = useTranslations('smime');
@@ -901,6 +914,26 @@ export function EmailViewer({
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled); const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
const dragOutActive = useMemo(() => isDragOutSupported(), []); 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 timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
@@ -909,6 +942,8 @@ export function EmailViewer({
// Detect if the email is a draft // Detect if the email is a draft
const isDraft = email?.keywords?.['$draft'] === true; const isDraft = email?.keywords?.['$draft'] === true;
const isScheduled = email?.isScheduled === true;
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
// Color options for email tags (from user-defined keyword settings) // Color options for email tags (from user-defined keyword settings)
const colorOptions = emailKeywords.map((kw) => ({ const colorOptions = emailKeywords.map((kw) => ({
@@ -921,6 +956,30 @@ export function EmailViewer({
const { isTablet, isMobile } = useDeviceDetection(); const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore(); const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore(); const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('reschedule_prompt'));
if (!value) return null;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) {
toast.error(tComposer('schedule_send_invalid'));
return null;
}
if (time <= Date.now()) {
toast.error(tComposer('schedule_send_future'));
return null;
}
if (!client?.hasDelayedSend()) {
toast.error(tComposer('schedule_send_unsupported'));
return null;
}
const maxDelayedSend = client.getMaxDelayedSend();
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
toast.error(tComposer('schedule_send_too_late'));
return null;
}
return new Date(time).toISOString();
}, [client, t, tComposer]);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour(); const { startTour } = useTour();
const isEmbedded = useIsEmbedded(); const isEmbedded = useIsEmbedded();
@@ -2561,6 +2620,15 @@ export function EmailViewer({
return emailContent; return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); }, [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 handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
// Blob URLs inherit our origin; script-bearing MIME types (text/html, // Blob URLs inherit our origin; script-bearing MIME types (text/html,
@@ -2570,6 +2638,8 @@ export function EmailViewer({
&& mailAttachmentAction === 'preview' && mailAttachmentAction === 'preview'
&& isMimeTypeSafeForInlinePreview(attachment.type); && isMimeTypeSafeForInlinePreview(attachment.type);
const downloadName = resolveAttachmentName(attachment);
const info: AttachmentInfo = { const info: AttachmentInfo = {
name: attachment.name || '', name: attachment.name || '',
type: attachment.type, type: attachment.type,
@@ -2580,7 +2650,7 @@ export function EmailViewer({
if (attachment.blobId && onDownloadAttachment) { if (attachment.blobId && onDownloadAttachment) {
emailHooks.onAttachmentDownload.emit(info); emailHooks.onAttachmentDownload.emit(info);
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type); onDownloadAttachment(attachment.blobId, downloadName, attachment.type);
return; return;
} }
@@ -2600,7 +2670,7 @@ export function EmailViewer({
emailHooks.onAttachmentDownload.emit(info); emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = objectUrl; anchor.href = objectUrl;
anchor.download = attachment.name || 'download'; anchor.download = downloadName;
document.body.appendChild(anchor); document.body.appendChild(anchor);
anchor.click(); anchor.click();
anchor.remove(); anchor.remove();
@@ -2630,16 +2700,17 @@ export function EmailViewer({
emailHooks.onAttachmentDownload.emit(info); emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = objectUrl; anchor.href = objectUrl;
anchor.download = attachment.name || 'download'; anchor.download = downloadName;
document.body.appendChild(anchor); document.body.appendChild(anchor);
anchor.click(); anchor.click();
anchor.remove(); anchor.remove();
} }
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [mailAttachmentAction, onDownloadAttachment, email?.id]); }, [mailAttachmentAction, onDownloadAttachment, email, resolveAttachmentName]);
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => { const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
const downloadName = resolveAttachmentName(attachment);
const info: AttachmentInfo = { const info: AttachmentInfo = {
name: attachment.name || '', name: attachment.name || '',
type: attachment.type, type: attachment.type,
@@ -2649,7 +2720,7 @@ export function EmailViewer({
}; };
emailHooks.onAttachmentDownload.emit(info); emailHooks.onAttachmentDownload.emit(info);
if (attachment.blobId && onDownloadAttachment) { if (attachment.blobId && onDownloadAttachment) {
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true); onDownloadAttachment(attachment.blobId, downloadName, attachment.type, true);
return; return;
} }
@@ -2662,7 +2733,7 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = objectUrl; anchor.href = objectUrl;
anchor.download = attachment.name || 'download'; anchor.download = downloadName;
document.body.appendChild(anchor); document.body.appendChild(anchor);
anchor.click(); anchor.click();
anchor.remove(); anchor.remove();
@@ -2678,12 +2749,12 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = objectUrl; anchor.href = objectUrl;
anchor.download = attachment.name || 'download'; anchor.download = downloadName;
document.body.appendChild(anchor); document.body.appendChild(anchor);
anchor.click(); anchor.click();
anchor.remove(); anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); 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 // 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. // rendered as thumbnails inside the chip. Skips images larger than 10 MB.
@@ -2799,7 +2870,9 @@ export function EmailViewer({
// Word/Outlook HTML emails ship a <style> block but put their gutter in // 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. // @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 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>&nbsp;</p> spacers for vertical // Word emails rely on empty <p class=MsoNormal>&nbsp;</p> spacers for vertical
// rhythm. With our default line-height: 1.6 these stack into oversized gaps; // rhythm. With our default line-height: 1.6 these stack into oversized gaps;
@@ -2822,11 +2895,11 @@ export function EmailViewer({
<style> <style>
html, body { overflow: hidden; } 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; } 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; } img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; } a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; } 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; } pre { white-space: pre-wrap; word-wrap: break-word; }
${wordHtmlCSS} ${wordHtmlCSS}
${darkModeCSS} ${darkModeCSS}
@@ -3056,23 +3129,26 @@ export function EmailViewer({
const handleExportEmail = async () => { const handleExportEmail = async () => {
if (!email?.blobId || !client) return; if (!email?.blobId || !client) return;
try { try {
const subject = (email.subject || 'email').replace(/[<>:"/\\|?*]+/g, '_').slice(0, 100); await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822');
await client.downloadBlob(email.blobId, `${subject}.eml`, 'message/rfc822');
} catch { } catch {
toast.error(tNotifications('export_email_error')); 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 = () => { const handleImportEmail = () => {
if (!client) return; if (!client) return;
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = '.eml,message/rfc822'; input.accept = EML_IMPORT_ACCEPT;
input.multiple = true;
input.onchange = async (e) => { input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]; const files = Array.from((e.target as HTMLInputElement).files ?? []);
if (!file) return; if (files.length === 0) return;
try {
const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState(); const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const mailboxId = mailbox?.originalId || selectedMailbox; const mailboxId = mailbox?.originalId || selectedMailbox;
@@ -3080,11 +3156,31 @@ export function EmailViewer({
toast.error(tNotifications('import_email_error')); toast.error(tNotifications('import_email_error'));
return; return;
} }
const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' });
let emails;
try {
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 }); await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true });
imported++;
} catch {
failed++;
}
}
if (imported > 0) {
toast.success(tNotifications('import_email_success')); toast.success(tNotifications('import_email_success'));
await fetchEmails(client); await fetchEmails(client);
} catch { }
if (failed > 0 || emails.length === 0) {
toast.error(tNotifications('import_email_error')); toast.error(tNotifications('import_email_error'));
} }
}; };
@@ -3208,9 +3304,9 @@ export function EmailViewer({
if (!email) { if (!email) {
if (isDemoMode) { 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_White_and_Color.svg'
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg'; : '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
return ( 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={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"> <div className="text-center p-8 max-w-md">
@@ -3295,7 +3391,32 @@ export function EmailViewer({
<ChevronLeft className="w-5 h-5" /> <ChevronLeft className="w-5 h-5" />
</Button> </Button>
)} )}
{isDraft && onEditDraft && ( {isScheduled && canCancelScheduled && (
<>
<Button variant="default" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
<X className="w-4 h-4" />
<span className="hidden sm:inline text-sm">{t('cancel_scheduled_send')}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
}}
className="hidden sm:flex sm:h-8"
title={t('reschedule_send')}
>
<CalendarClock className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reschedule_send')}</span>}
</Button>
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
<EditIcon className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
</Button>
</>
)}
{!isScheduled && isDraft && onEditDraft && (
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
@@ -3307,7 +3428,7 @@ export function EmailViewer({
<span className="text-sm">{t('edit_draft')}</span> <span className="text-sm">{t('edit_draft')}</span>
</Button> </Button>
)} )}
{!isDraft && (<> {!isScheduled && !isDraft && (<>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -3349,6 +3470,7 @@ export function EmailViewer({
</div> </div>
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */} {/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
{!isScheduled && (
<div className="flex items-center gap-0 sm:gap-0.5"> <div className="flex items-center gap-0 sm:gap-0.5">
{/* Archive */} {/* Archive */}
<Button <Button
@@ -3806,6 +3928,7 @@ export function EmailViewer({
)} )}
</div> </div>
</div> </div>
)}
</> </>
); );
@@ -3816,13 +3939,13 @@ export function EmailViewer({
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)} className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
> >
{/* Mobile More menu sidebar overlay */} {/* Mobile More menu sidebar overlay */}
{isMobile && moreMenuOpen && ( {!isScheduled && isMobile && moreMenuOpen && (
<div <div
className="fixed inset-0 bg-black/50 z-[60] sm:hidden" className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
onClick={() => setMoreMenuOpen(false)} onClick={() => setMoreMenuOpen(false)}
/> />
)} )}
{isMobile && ( {!isScheduled && isMobile && (
<div className={cn( <div className={cn(
"fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden", "fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden",
"transform transition-transform duration-300 ease-in-out", "transform transition-transform duration-300 ease-in-out",
@@ -4209,7 +4332,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id]; const thumbUrl = imageThumbUrls[attachment.id];
return ( 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) => ( {(dragProps) => (
<div <div
className={cn( className={cn(
@@ -4289,7 +4412,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return ( 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) => ( {(dragProps) => (
<div <div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full" className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -4754,10 +4877,44 @@ export function EmailViewer({
</div> </div>
)} )}
{/* Scheduled Banner */}
{isScheduled && (
<div className="border-b border-border bg-primary/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-primary">
<CalendarClock className="w-4 h-4" />
<span className="text-sm font-medium">
{t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })}
</span>
</div>
<div className="flex items-center gap-2">
{canCancelScheduled && (
<>
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
}}
>
{t('reschedule_send')}
</Button>
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit}>
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
</>
)}
</div>
</div>
</div>
)}
{/* Draft Banner */} {/* Draft Banner */}
{isDraft && ( {isDraft && (
<div className="border-b border-border bg-warning/10"> <div className="border-b border-border bg-warning/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex items-center justify-between"> <div className="px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-warning"> <div className="flex items-center gap-2 text-warning">
<File className="w-4 h-4" /> <File className="w-4 h-4" />
<span className="text-sm font-medium">{t('draft_banner')}</span> <span className="text-sm font-medium">{t('draft_banner')}</span>
@@ -4908,7 +5065,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id]; const thumbUrl = imageThumbUrls[attachment.id];
return ( 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) => ( {(dragProps) => (
<div <div
className={cn( className={cn(
@@ -4993,7 +5150,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return ( 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) => ( {(dragProps) => (
<div <div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full" className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -5051,7 +5208,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id]; const thumbUrl = imageThumbUrls[attachment.id];
return ( 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) => ( {(dragProps) => (
<div <div
className={cn( className={cn(
@@ -5130,7 +5287,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return ( 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) => ( {(dragProps) => (
<div <div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full" className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -5226,14 +5383,16 @@ export function EmailViewer({
<PluginSlot name="email-footer" /> <PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts and while loading a new email */} {/* Quick Reply Section - hidden for drafts and while loading a new email */}
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}> {!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}> <div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Avatar <Avatar
name={currentUserName || "You"} name={activeAccount?.displayName || currentUserName || "You"}
email={currentUserEmail || ""} email={activeAccount?.email || activeAccount?.username || currentUserEmail || ""}
size="lg" size="lg"
className="shadow-sm w-10 h-10" className="shadow-sm w-10 h-10"
disableFavicon
fallbackColor={activeAccount?.avatarColor}
/> />
</div> </div>
<div className="flex-1 min-w-0 space-y-3"> <div className="flex-1 min-w-0 space-y-3">
+59 -6
View File
@@ -1,11 +1,11 @@
"use client"; "use client";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { formatDate, stripInvisibleLeading } from "@/lib/utils"; import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types"; import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward } from "lucide-react"; import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock } from "lucide-react";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
@@ -67,15 +67,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const isUnifiedView = useEmailStore((state) => state.isUnifiedView); const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
const getAccountById = useAccountStore((state) => state.getAccountById); const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id); 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 trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
const scheduledSendLabel = email.isScheduled && email.scheduledSendAt
? formatDateTime(email.scheduledSendAt, timeFormat)
: null;
// Resolve color tags using keyword definitions; unknown tags fall back to gray // Resolve color tags using keyword definitions; unknown tags fall back to gray
const tagIds = getEmailColorTags(email.keywords); const tagIds = getEmailColorTags(email.keywords);
@@ -91,8 +97,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
sourceMailboxId: selectedMailbox, sourceMailboxId: selectedMailbox,
}); });
const isMobile = useUIStore((state) => state.isMobile);
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress( const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => { useCallback((pos) => {
onContextMenu?.( onContextMenu?.(
@@ -241,12 +245,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{resolvedKeywordDefs.map((kd) => ( {resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} /> <span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))} ))}
{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( <span className={cn(
'text-xs tabular-nums', 'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground' isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}> )}>
{formatDate(email.receivedAt)} {formatDate(email.receivedAt)}
</span> </span>
)}
</div> </div>
</div> </div>
) : ( ) : (
@@ -299,6 +313,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{kd.label} {kd.label}
</span> </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( <span className={cn(
"text-xs tabular-nums", "text-xs tabular-nums",
isUnread isUnread
@@ -307,6 +330,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}> )}>
{formatDate(email.receivedAt)} {formatDate(email.receivedAt)}
</span> </span>
)}
</div> </div>
</div> </div>
@@ -335,6 +359,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div> </div>
{/* Hover Quick Actions */} {/* Hover Quick Actions */}
{!email.isScheduled && (
<EmailHoverActions <EmailHoverActions
email={email} email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")} backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
@@ -345,6 +370,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onSetColorTag={onSetColorTag} onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam} onMarkAsSpam={onMarkAsSpam}
/> />
)}
</div> </div>
); );
} }
@@ -374,12 +400,17 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
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 trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
const scheduledSendLabel = latestEmail.isScheduled && latestEmail.scheduledSendAt
? formatDateTime(latestEmail.scheduledSendAt, timeFormat)
: null;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore(); const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
const getAccountById = useAccountStore((state) => state.getAccountById); const getAccountById = useAccountStore((state) => state.getAccountById);
@@ -645,12 +676,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{keywordDef && ( {keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} /> <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)} )}
{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( <span className={cn(
'text-xs tabular-nums', 'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground' hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}> )}>
{formatDate(latestEmail.receivedAt)} {formatDate(latestEmail.receivedAt)}
</span> </span>
)}
</div> </div>
</div> </div>
) : ( ) : (
@@ -715,6 +756,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{keywordDef.label} {keywordDef.label}
</span> </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( <span className={cn(
"text-xs tabular-nums", "text-xs tabular-nums",
hasUnread hasUnread
@@ -723,6 +773,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}> )}>
{formatDate(latestEmail.receivedAt)} {formatDate(latestEmail.receivedAt)}
</span> </span>
)}
</div> </div>
</div> </div>
@@ -751,6 +802,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div> </div>
{/* Hover Quick Actions for thread header */} {/* Hover Quick Actions for thread header */}
{!latestEmail.isScheduled && (
<EmailHoverActions <EmailHoverActions
email={latestEmail} email={latestEmail}
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")} backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
@@ -761,6 +813,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
/> />
)}
</div> </div>
{isExpanded && !isMobile && !isFocusedMailLayout && ( {isExpanded && !isMobile && !isFocusedMailLayout && (
+4 -4
View File
@@ -21,7 +21,7 @@ import { getMaxAccounts } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; 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"; import { Avatar } from "@/components/ui/avatar";
interface NavItem { interface NavItem {
@@ -385,7 +385,7 @@ export function NavigationRail({
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */} {/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
{isStalwartAdmin && ( {isStalwartAdmin && (
<a <a
href="/admin" href={`${getPathPrefix()}/admin`}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]", "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", "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 ? ( return logoUrl ? (
<div className="flex items-center justify-center py-3 px-1"> <div className="flex items-center justify-center py-3 px-1">
<img <img
@@ -572,7 +572,7 @@ export function NavigationRail({
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1"> <div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
{isStalwartAdmin && ( {isStalwartAdmin && (
<a <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" 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"} title={t("admin") || "Admin"}
> >
+44 -9
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, ReactNode } from "react"; import { useState, useEffect, useMemo, ReactNode } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
@@ -74,6 +74,8 @@ interface SidebarProps {
onDeleteFolder?: (mailboxId: string) => void; onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void; onImportEmail?: (mailboxId: string) => void;
onRefreshMailboxes?: () => void; onRefreshMailboxes?: () => void;
scheduledTotal?: number;
showScheduledMailbox?: boolean;
className?: string; className?: string;
/** /**
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the * Multi-account (Pro) mode props. When `multiAccountMode` is true, the
@@ -674,6 +676,8 @@ export function Sidebar({
onDeleteFolder, onDeleteFolder,
onImportEmail, onImportEmail,
onRefreshMailboxes, onRefreshMailboxes,
scheduledTotal = 0,
showScheduledMailbox = false,
className, className,
multiAccountMode = false, multiAccountMode = false,
accountMailboxes, accountMailboxes,
@@ -732,15 +736,20 @@ export function Sidebar({
// pane. // pane.
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded; const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded;
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox); const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified);
const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons); const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons);
const tagCounts = useEmailStore(s => s.tagCounts); const tagCounts = useEmailStore(s => s.tagCounts);
const accounts = useAccountStore(s => s.accounts); const accounts = useAccountStore(s => s.accounts);
const connectedAccounts = accounts.filter(a => a.isConnected); 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 // Pro shell treats the unified mailbox as a core part of the multi-account
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The // UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. With a
// 2+ account requirement still applies - with a single account the // single account we still surface unified when the user has opted into
// unified counts would just duplicate that account's inbox. // merging group/shared inboxes — otherwise the counts would just duplicate
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1; // the one inbox.
const showUnified =
(multiAccountMode || enableUnifiedMailbox) &&
(connectedAccounts.length > 1 || (includeGroupInUnified && hasGroupInboxes));
const { unifiedCounts } = useEmailStore(); const { unifiedCounts } = useEmailStore();
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
@@ -1022,7 +1031,8 @@ export function Sidebar({
{!isCollapsed && t("loading_mailboxes")} {!isCollapsed && t("loading_mailboxes")}
</div> </div>
) : ( ) : (
tree.map((node) => ( <>
{tree.map((node) => (
<MailboxTreeItem <MailboxTreeItem
key={node.id} key={node.id}
node={node} node={node}
@@ -1037,7 +1047,19 @@ export function Sidebar({
colorful={colorfulSidebarIcons} colorful={colorfulSidebarIcons}
onContextMenu={isActive ? handleMailboxContextMenu : undefined} 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,7 +1084,8 @@ export function Sidebar({
{!isCollapsed && t("loading_mailboxes")} {!isCollapsed && t("loading_mailboxes")}
</div> </div>
) : ( ) : (
ownTree.map((node) => ( <>
{ownTree.map((node) => (
<MailboxTreeItem <MailboxTreeItem
key={node.id} key={node.id}
node={node} node={node}
@@ -1075,7 +1098,19 @@ export function Sidebar({
colorful={colorfulSidebarIcons} colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu} 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}
/>
)}
</>
)} )}
</> </>
)} )}
+20 -2
View File
@@ -26,7 +26,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const client = useAuthStore((s) => s.client); const client = useAuthStore((s) => s.client);
const sendEmail = useEmailStore((s) => s.sendEmail); const sendEmail = useEmailStore((s) => s.sendEmail);
const fetchEmails = useEmailStore((s) => s.fetchEmails); const fetchEmails = useEmailStore((s) => s.fetchEmails);
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox); const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
const isScheduledView = useEmailStore((s) => s.isScheduledView);
const closeTab = useProTabStore((s) => s.closeTab); const closeTab = useProTabStore((s) => s.closeTab);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle); const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft); const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
@@ -36,10 +39,18 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const tabIdRef = useRef(tabId); const tabIdRef = useRef(tabId);
tabIdRef.current = tabId; tabIdRef.current = tabId;
const handleScheduledSendCreated = useCallback(async () => {
if (client) {
await refreshScheduledMetadata(client);
if (isScheduledView) await fetchScheduledEmails(client);
}
closeTab(tabIdRef.current);
}, [client, refreshScheduledMetadata, isScheduledView, fetchScheduledEmails, closeTab]);
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => { const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
if (!client) return; if (!client) return;
try { try {
await sendEmail( const result = await sendEmail(
client, client,
sendData.to, sendData.to,
sendData.subject, sendData.subject,
@@ -54,9 +65,15 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
sendData.attachments, sendData.attachments,
sendData.inReplyTo, sendData.inReplyTo,
sendData.references, sendData.references,
sendData.delayedUntil,
sendData.envelopeMailFrom, sendData.envelopeMailFrom,
); );
if (result.scheduled) {
await handleScheduledSendCreated();
return;
}
// Mark the original message as $answered / $forwarded so the standard // Mark the original message as $answered / $forwarded so the standard
// viewer and list reflect the action (same behaviour as inline compose). // viewer and list reflect the action (same behaviour as inline compose).
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) { if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
@@ -81,7 +98,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
console.error('Failed to send email:', error); console.error('Failed to send email:', error);
toast.error(t('notifications.error_sending')); toast.error(t('notifications.error_sending'));
} }
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t]); }, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
closeTab(tabIdRef.current); closeTab(tabIdRef.current);
@@ -125,6 +142,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
initialDraftText={data.initialDraftText} initialDraftText={data.initialDraftText}
initialData={data.initialData} initialData={data.initialData}
onSend={handleSend} onSend={handleSend}
onScheduledSendCreated={handleScheduledSendCreated}
onClose={handleClose} onClose={handleClose}
onDiscardDraft={handleDiscardDraft} onDiscardDraft={handleDiscardDraft}
onSaveState={handleSaveState} onSaveState={handleSaveState}
+4 -2
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { X, Download } from "lucide-react"; import { X, Download } from "lucide-react";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { withBasePath } from "@/lib/browser-navigation";
interface BeforeInstallPromptEvent extends Event { interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>; prompt: () => Promise<void>;
@@ -58,7 +59,8 @@ export function PWAInstallPrompt() {
return null; return null;
} }
const logoSrc = appLogoLightUrl || faviconUrl; const logoSrc = withBasePath(appLogoLightUrl || faviconUrl);
const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl);
return ( 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"> <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 && ( {logoSrc && (
<img <img
src={appLogoDarkUrl || faviconUrl} src={darkLogoSrc}
alt={appName} alt={appName}
className="w-8 h-8 shrink-0 object-contain hidden dark:block" className="w-8 h-8 shrink-0 object-contain hidden dark:block"
/> />
+3 -2
View File
@@ -10,6 +10,7 @@ import { usePolicyStore } from '@/stores/policy-store';
import { useUpdateStore } from '@/stores/update-store'; import { useUpdateStore } from '@/stores/update-store';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getPathPrefix } from '@/lib/browser-navigation';
import { SpamSiegeGame } from './spam-siege-game'; import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; 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"> <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"> <div className="shrink-0">
<img <img
src="/branding/Bulwark_Logo_Color.svg" src={`${getPathPrefix()}/branding/Bulwark_Logo_Color.svg`}
alt="Bulwark" alt="Bulwark"
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform" className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/> />
<img <img
src="/branding/Bulwark_Logo_White.svg" src={`${getPathPrefix()}/branding/Bulwark_Logo_White.svg`}
alt="Bulwark" alt="Bulwark"
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform" className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/> />
+1 -26
View File
@@ -4,17 +4,14 @@ import { useTranslations } from 'next-intl';
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store'; import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { usePolicyStore } from '@/stores/policy-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() { export function CalendarSettings() {
const t = useTranslations('calendar.settings'); const t = useTranslations('calendar.settings');
const tViews = useTranslations('calendar.views'); const tViews = useTranslations('calendar.views');
const tDays = useTranslations('calendar.days');
const { viewMode, setViewMode } = useCalendarStore(); const { viewMode, setViewMode } = useCalendarStore();
const { const {
timeFormat,
firstDayOfWeek,
showTimeInMonthView, showTimeInMonthView,
showWeekNumbers, showWeekNumbers,
enableCalendarTasks, enableCalendarTasks,
@@ -40,28 +37,6 @@ export function CalendarSettings() {
/> />
</SettingItem> </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 <SettingItem
label={t('show_time_in_month_view')} label={t('show_time_in_month_view')}
description={t('show_time_in_month_view_desc')} description={t('show_time_in_month_view_desc')}
@@ -3,6 +3,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import type { SendDelaySeconds } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { import {
@@ -22,11 +24,14 @@ export function ComposingSettings() {
autoSelectReplyIdentity, autoSelectReplyIdentity,
attachmentReminderEnabled, attachmentReminderEnabled,
attachmentReminderKeywords, attachmentReminderKeywords,
sendDelaySeconds,
subAddressDelimiter, subAddressDelimiter,
signaturePosition, signaturePosition,
signatureSeparatorEnabled, signatureSeparatorEnabled,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { client } = useAuthStore();
const delayedSendSupported = client?.hasDelayedSend() ?? false;
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
@@ -37,6 +42,24 @@ export function ComposingSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
<div className="flex flex-col items-end gap-1">
<Select
value={String(sendDelaySeconds)}
onChange={(value) => updateSetting('sendDelaySeconds', Number(value) as SendDelaySeconds)}
options={[
{ value: '0', label: t('send_delay.off') },
{ value: '10', label: t('send_delay.seconds', { seconds: 10 }) },
{ value: '30', label: t('send_delay.seconds', { seconds: 30 }) },
{ value: '60', label: t('send_delay.seconds', { seconds: 60 }) },
]}
/>
{sendDelaySeconds > 0 && !delayedSendSupported && (
<p className="max-w-64 text-right text-xs text-amber-600 dark:text-amber-400">{t('send_delay.unsupported')}</p>
)}
</div>
</SettingItem>
<SettingItem label={t('signature_position.label')} description={t('signature_position.description')}> <SettingItem label={t('signature_position.label')} description={t('signature_position.description')}>
<Select <Select
value={signaturePosition} value={signaturePosition}
+266
View File
@@ -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>
);
}
+3 -2
View File
@@ -6,6 +6,7 @@ import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Hom
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section"; import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog"; import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { withBasePath } from "@/lib/browser-navigation";
interface SampleFile { interface SampleFile {
name: string; name: string;
@@ -95,7 +96,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
)} )}
> >
{settings.showThumbnails && file.thumbnailUrl ? ( {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 ? ( ) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "sm") getPreviewIcon(file, settings.coloredIcons, "sm")
) : null} ) : null}
@@ -125,7 +126,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
)} )}
> >
{settings.showThumbnails && file.thumbnailUrl ? ( {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 ? ( ) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "lg") getPreviewIcon(file, settings.coloredIcons, "lg")
) : ( ) : (
+86 -3
View File
@@ -1,17 +1,100 @@
"use client"; "use client";
import { useMemo } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { LanguageSwitcher } from '@/components/ui/language-switcher'; 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() { 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 ( 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')}> <SettingItem label={t('language.label')} description={t('language.description')}>
<LanguageSwitcher /> <LanguageSwitcher />
</SettingItem> </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> </SettingsSection>
); );
} }
+22 -2
View File
@@ -1,11 +1,13 @@
"use client"; "use client";
import { useMemo } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store'; import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store'; import { useAccountStore } from '@/stores/account-store';
import { useEmailStore } from '@/stores/email-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [ const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false }, { sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
@@ -115,9 +117,11 @@ function MailLayoutPreview({
export function LayoutSettings() { export function LayoutSettings() {
const t = useTranslations('settings.appearance'); const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior'); 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 { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts); const accounts = useAccountStore(s => s.accounts);
const mailboxes = useEmailStore(s => s.mailboxes);
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
@@ -177,10 +181,11 @@ export function LayoutSettings() {
/> />
</SettingItem> </SettingItem>
{accounts.length > 1 && ( {(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
<SettingItem <SettingItem
label={t('unified_mailbox.label')} label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')} description={t('unified_mailbox.description')}
locked={isSettingLocked('enableUnifiedMailbox')}
> >
<ToggleSwitch <ToggleSwitch
checked={enableUnifiedMailbox} checked={enableUnifiedMailbox}
@@ -189,6 +194,21 @@ export function LayoutSettings() {
</SettingItem> </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')}> <SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
<ToggleSwitch <ToggleSwitch
checked={proInterface} checked={proInterface}
+2 -1
View File
@@ -120,9 +120,10 @@ export function ReadingSettings() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Select <Select
value={deleteAction} value={deleteAction}
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')} onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'trash-and-read' | 'permanent')}
options={[ options={[
{ value: 'trash', label: t('delete_action.trash') }, { 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') }, { value: 'permanent', label: t('delete_action.permanent') },
]} ]}
/> />
+2 -1
View File
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store"; import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { avatarHooks } from "@/lib/plugin-hooks"; import { avatarHooks } from "@/lib/plugin-hooks";
import { withBasePath } from "@/lib/browser-navigation";
const IS_DEV = process.env.NODE_ENV !== "production"; 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 customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl; const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null; 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 imgSrc = disableImages ? null : (photoSrc || faviconSrc);
const isFavicon = imgSrc !== null && imgSrc === faviconSrc; const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
+5 -3
View File
@@ -92,9 +92,11 @@ export function useAttachmentDrag(
return; return;
} }
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium reads this on // `DownloadURL` format: <mime>:<filename>:<url>. The filename must be
// drop and writes a real file at the destination. // raw - URL-encoding it lands literally on disk (`%20` instead of a
e.dataTransfer.setData("DownloadURL", `${type}:${encodeURIComponent(name)}:${url}`); // space). Callers are expected to sanitise reserved chars (`:` etc.)
// beforehand.
e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`);
e.dataTransfer.effectAllowed = "copyMove"; e.dataTransfer.effectAllowed = "copyMove";
}, },
[source.name, source.type, prefetch], [source.name, source.type, prefetch],
+202 -5
View File
@@ -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 { Email } from "@/lib/jmap/types";
import { IJMAPClient } from "@/lib/jmap/client-interface";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useUIStore } from "@/stores/ui-store"; 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 { interface UseEmailDragOptions {
email: Email; email: Email;
@@ -15,6 +26,7 @@ interface UseEmailDragOptions {
interface UseEmailDragReturn { interface UseEmailDragReturn {
dragHandlers: { dragHandlers: {
draggable: boolean; draggable: boolean;
onPointerEnter?: () => void;
onDragStart: (e: DragEvent<HTMLDivElement>) => void; onDragStart: (e: DragEvent<HTMLDivElement>) => void;
onDragEnd: (e: DragEvent<HTMLDivElement>) => void; onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
}; };
@@ -44,10 +56,153 @@ function createDragPreview(count: number): HTMLElement {
return preview; 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 { export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
const { selectedEmailIds, emails } = useEmailStore(); const { selectedEmailIds, emails } = useEmailStore();
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext(); const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
const isMobile = useUIStore((state) => state.isMobile); 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>) => { const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
// Determine which emails to drag: // Determine which emails to drag:
@@ -59,8 +214,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
? emails.filter(em => selectedEmailIds.has(em.id)) ? emails.filter(em => selectedEmailIds.has(em.id))
: threadEmails || [email]; : threadEmails || [email];
// Set data transfer e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData( e.dataTransfer.setData(
"application/x-email-ids", "application/x-email-ids",
JSON.stringify(emailsToDrag.map(em => em.id)) 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(", ") 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 // Create custom drag image
const dragPreview = createDragPreview(emailsToDrag.length); const dragPreview = createDragPreview(emailsToDrag.length);
e.dataTransfer.setDragImage(dragPreview, 0, 0); e.dataTransfer.setDragImage(dragPreview, 0, 0);
@@ -80,10 +268,18 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
}); });
startDrag(emailsToDrag, sourceMailboxId); startDrag(emailsToDrag, sourceMailboxId);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails]); }, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
endDrag(); 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]); }, [endDrag]);
// Check if this specific email is being dragged // Check if this specific email is being dragged
@@ -94,6 +290,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} } ? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} }
: { : {
draggable: true, draggable: true,
onPointerEnter: dragOutEnabled ? handlePointerEnter : undefined,
onDragStart: handleDragStart, onDragStart: handleDragStart,
onDragEnd: handleDragEnd, onDragEnd: handleDragEnd,
}, },
+3
View File
@@ -289,6 +289,9 @@ export const KEYBOARD_SHORTCUTS = {
{ key: "x", description: "shortcuts.threads.expand_collapse" }, { key: "x", description: "shortcuts.threads.expand_collapse" },
], ],
composer: [ composer: [
{ key: "Ctrl + Enter", description: "shortcuts.composer.send" },
{ key: "Ctrl + Shift + Enter", description: "shortcuts.composer.schedule_send" },
{ key: "t", description: "shortcuts.composer.template_picker" }, { key: "t", description: "shortcuts.composer.template_picker" },
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
], ],
} as const; } as const;
+27 -19
View File
@@ -81,20 +81,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
// Virtual nodes (shared folder headers) cannot be drop targets // Virtual nodes (shared folder headers) cannot be drop targets
if (mailbox.id.startsWith("shared-")) return false; 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; return true;
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]); }, [isDragging, mailbox, sourceMailboxId]);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => { const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
@@ -154,18 +142,38 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
bySource.get(srcAccountId)!.push(id); 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 sourceAccountIds = Array.from(bySource.keys());
const isCrossAccount =
!!destAccountId && const isJmapCrossAccount =
!!sourceJmapAccountId &&
!!destJmapAccountId &&
sourceJmapAccountId !== destJmapAccountId;
const isLocalCrossAccount =
!mailbox.isShared && !mailbox.isShared &&
!!destAccountId &&
sourceAccountIds.some((src) => src !== destAccountId); sourceAccountIds.some((src) => src !== destAccountId);
const isCrossAccount = isJmapCrossAccount || isLocalCrossAccount;
if (isCrossAccount) { if (isCrossAccount) {
// JMAP can't natively move an email between primary accounts, so the if (!destAccountId) {
// store reuploads each source blob into the destination account and throw new Error('Could not resolve destination account');
// then deletes the original. }
// 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; 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 { } else {
// Single-account or same-account-shared move: bulk JMAP request. // Single-account or same-account-shared move: bulk JMAP request.
await moveEmailsToMailbox(client, emailIds, mailbox.id); await moveEmailsToMailbox(client, emailIds, mailbox.id);
+14 -2
View File
@@ -12,9 +12,21 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'always' | 'always'
| 'as-needed'; | '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({ export const routing = defineRouting({
locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'], locales: SUPPORTED_LOCALES,
defaultLocale: 'en', defaultLocale: resolvedDefaultLocale,
localePrefix localePrefix
}); });
+97 -1
View File
@@ -11,6 +11,34 @@ function createClient(): JMAPClient {
return client; return client;
} }
function enableDelayedSend(client: JMAPClient) {
Object.assign(client, {
capabilities: {
'urn:ietf:params:jmap:core': {},
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {},
},
session: {
primaryAccounts: {
'urn:ietf:params:jmap:mail': 'account-1',
'urn:ietf:params:jmap:submission': 'submission-account-1',
},
accounts: {
'account-1': {
accountCapabilities: {
'urn:ietf:params:jmap:mail': {},
},
},
'submission-account-1': {
accountCapabilities: {
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: true } },
},
},
},
},
});
}
interface JMAPMethodCall { interface JMAPMethodCall {
0: string; 0: string;
1: Record<string, unknown>; 1: Record<string, unknown>;
@@ -62,7 +90,7 @@ function mockSendEmailFlow() {
payload = { payload = {
methodResponses: [ methodResponses: [
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'], ['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'],
['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'], ['EmailSubmission/set', { created: { '1': { id: 'sub-1', sendAt: '2026-05-08T18:00:00Z' } } }, '1'],
], ],
}; };
} }
@@ -167,4 +195,72 @@ describe('JMAPClient.sendEmail threading headers', () => {
expect(draft.inReplyTo).toEqual(['real@example.com']); expect(draft.inReplyTo).toEqual(['real@example.com']);
expect(draft.references).toBeUndefined(); expect(draft.references).toBeUndefined();
}); });
it('uses FUTURERELEASE envelope and submission capability for scheduled sends', async () => {
const client = createClient();
enableDelayedSend(client);
const captured = mockSendEmailFlow();
const delayedUntil = new Date(Date.now() + 60_000).toISOString();
const result = await client.sendEmail(
['recipient@example.com'],
'Scheduled test',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
undefined,
undefined,
delayedUntil,
);
const identityRequest = captured[1];
expect(identityRequest.using).toContain('urn:ietf:params:jmap:submission');
const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set');
expect(submissionCall?.[1].accountId).toBe('submission-account-1');
expect(submissionCall?.[1].create).toEqual({
'1': {
emailId: expect.stringMatching(/^#send-/),
identityId: 'identity-1',
envelope: {
mailFrom: {
email: 'user@example.com',
parameters: { HOLDFOR: expect.stringMatching(/^\d+$/) },
},
rcptTo: [{ email: 'recipient@example.com' }],
},
},
});
expect(JSON.stringify(submissionCall?.[1].create)).not.toContain('sendAt');
expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: '2026-05-08T18:00:00Z' });
});
it('cleans up replacement submission if canceling the original fails during reschedule', async () => {
const client = createClient();
enableDelayedSend(client);
vi.spyOn(client, 'getMailboxes').mockResolvedValue([
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
] as never);
vi.spyOn(client, 'getIdentities').mockResolvedValue([
{ id: 'identity-1', name: 'User', email: 'user@example.com', mayDelete: false },
]);
const requestSpy = vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
.mockImplementation(async (methodCalls) => {
const args = methodCalls[0][1] as { create?: unknown; update?: Record<string, unknown> };
if (args.create) {
return { methodResponses: [['EmailSubmission/set', { created: { replacement: { id: 'sub-new' } } }, '0']] };
}
if (args.update?.['sub-old']) {
return { methodResponses: [['EmailSubmission/set', { notUpdated: { 'sub-old': { type: 'cannotUnsend' } } }, '0']] };
}
return { methodResponses: [['EmailSubmission/set', { updated: { 'sub-new': null } }, '0']] };
});
await expect(client.rescheduleEmailSubmission('sub-old', 'email-1', 'identity-1', new Date(Date.now() + 60_000).toISOString()))
.rejects.toThrow('could not cancel the original');
expect(requestSpy).toHaveBeenCalledWith(expect.arrayContaining([
expect.arrayContaining(['EmailSubmission/set', expect.objectContaining({ update: { 'sub-new': { undoStatus: 'canceled' } } })]),
]));
});
}); });
+17
View File
@@ -146,6 +146,23 @@ describe('oauth/discovery', () => {
expect(consoleSpy).toHaveBeenCalled(); 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 () => { it('caches results - second call for same server URL does not re-fetch', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true, ok: true,
+1
View File
@@ -153,6 +153,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' }, oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' }, oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_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 }, allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] }, jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false }, jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
+20
View File
@@ -75,6 +75,26 @@ export function apiFetch(input: string, init?: RequestInit): Promise<Response> {
return fetch(input, init); 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. * Extracts the locale from the current URL, skipping any mount prefix.
+10 -3
View File
@@ -24,8 +24,14 @@ export interface TimedEventLayout {
export function getEventStartDate( export function getEventStartDate(
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>, event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
): Date { ): Date {
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start; // Prefer utcStart for timed events but fall back to start if utcStart is
return parseISO(source); // 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[] { export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
@@ -56,7 +62,8 @@ export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWe
export function getEventEndDate(event: CalendarEvent): Date { export function getEventEndDate(event: CalendarEvent): Date {
if (!event.showWithoutTime && event.utcEnd) { if (!event.showWithoutTime && event.utcEnd) {
return parseISO(event.utcEnd); const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return utc;
} }
const start = getEventStartDate(event); const start = getEventStartDate(event);
+119 -14
View File
@@ -1,5 +1,5 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from '@/lib/jmap/types'; import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult } from '@/lib/jmap/types';
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types'; import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { getDemoData, type DemoData } from './demo-data'; import { getDemoData, type DemoData } from './demo-data';
import { generateDemoId } from './demo-utils'; import { generateDemoId } from './demo-utils';
@@ -11,6 +11,7 @@ import { generateDemoId } from './demo-utils';
export class DemoJMAPClient implements IJMAPClient { export class DemoJMAPClient implements IJMAPClient {
private data: DemoData; private data: DemoData;
private blobStore = new Map<string, Blob>(); private blobStore = new Map<string, Blob>();
private scheduledSubmissions = new Map<string, { id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled'; isSmime: boolean }>();
private connectionCallback: ((connected: boolean) => void) | null = null; private connectionCallback: ((connected: boolean) => void) | null = null;
private stateChangeCallback: ((change: StateChange) => void) | null = null; private stateChangeCallback: ((change: StateChange) => void) | null = null;
private lastStates: AccountStates = {}; private lastStates: AccountStates = {};
@@ -49,15 +50,15 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Capabilities ────────────────────────────────────────────── // ── Capabilities ──────────────────────────────────────────────
hasAccountCapability(_capability: string, _accountId?: string): boolean { hasAccountCapability(capability: string, _accountId?: string): boolean {
return false; return capability === 'urn:ietf:params:jmap:submission';
} }
getCapabilities(): Record<string, unknown> { getCapabilities(): Record<string, unknown> {
return { return {
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 }, 'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {}, 'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } },
'urn:ietf:params:jmap:vacationresponse': {}, 'urn:ietf:params:jmap:vacationresponse': {},
'urn:ietf:params:jmap:contacts': {}, 'urn:ietf:params:jmap:contacts': {},
'urn:ietf:params:jmap:calendars': {}, 'urn:ietf:params:jmap:calendars': {},
@@ -70,6 +71,8 @@ export class DemoJMAPClient implements IJMAPClient {
getMaxSizeUpload(): number { return 50_000_000; } getMaxSizeUpload(): number { return 50_000_000; }
getMaxCallsInRequest(): number { return 16; } getMaxCallsInRequest(): number { return 16; }
getMaxObjectsInGet(): number { return 500; } getMaxObjectsInGet(): number { return 500; }
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
hasDelayedSend(): boolean { return true; }
getEventSourceUrl(): string | null { return null; } getEventSourceUrl(): string | null { return null; }
supportsEmailSubmission(): boolean { return true; } supportsEmailSubmission(): boolean { return true; }
supportsQuota(): boolean { return true; } supportsQuota(): boolean { return true; }
@@ -264,10 +267,11 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts(); 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); const email = this.data.emails.find(e => e.id === emailId);
if (!email) return; if (!email) return;
email.mailboxIds = { [trashMailboxId]: true }; email.mailboxIds = { [trashMailboxId]: true };
if (markAsRead) email.keywords.$seen = true;
this.recalcMailboxCounts(); this.recalcMailboxCounts();
} }
@@ -277,10 +281,13 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts(); 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) { for (const id of emailIds) {
const email = this.data.emails.find(e => e.id === id); 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(); this.recalcMailboxCounts();
} }
@@ -289,6 +296,8 @@ export class DemoJMAPClient implements IJMAPClient {
emails: Array<{ id: string; receivedAt: string }>, emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string, archiveMailboxId: string,
mode: 'single' | 'year' | 'month', mode: 'single' | 'year' | 'month',
_existingMailboxes: Mailbox[],
_accountId?: string,
): Promise<void> { ): Promise<void> {
if (emails.length === 0) return; if (emails.length === 0) return;
if (mode === 'single') { if (mode === 'single') {
@@ -355,10 +364,13 @@ export class DemoJMAPClient implements IJMAPClient {
return count; 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 email = this.data.emails.find(e => e.id === emailId);
const junkMb = this.data.mailboxes.find(m => m.role === 'junk'); 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(); 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 }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[], inReplyTo?: string[],
references?: string[], references?: string[],
): Promise<void> { delayedUntil?: string,
_envelopeMailFrom?: string,
): Promise<SendEmailResult> {
// Remove draft if updating // Remove draft if updating
if (draftId) { if (draftId) {
this.data.emails = this.data.emails.filter(e => e.id !== draftId); this.data.emails = this.data.emails.filter(e => e.id !== draftId);
@@ -457,8 +471,8 @@ export class DemoJMAPClient implements IJMAPClient {
const sentMb = this.data.mailboxes.find(m => m.role === 'sent'); const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = { const email: Email = {
id: generateDemoId('email'), threadId: generateDemoId('thread'), id: generateDemoId('email'), threadId: generateDemoId('thread'),
mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true }, mailboxIds: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: true },
keywords: { $seen: true }, keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
size: body.length + (htmlBody?.length || 0), size: body.length + (htmlBody?.length || 0),
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }], from: [{ name: 'Demo User', email: 'demo@example.com' }],
@@ -478,7 +492,22 @@ export class DemoJMAPClient implements IJMAPClient {
references: references?.length ? references : undefined, references: references?.length ? references : undefined,
}; };
this.data.emails.push(email); this.data.emails.push(email);
let emailSubmissionId: string | undefined;
if (delayedUntil) {
emailSubmissionId = generateDemoId('submission');
this.scheduledSubmissions.set(emailSubmissionId, {
id: emailSubmissionId,
emailId: email.id,
identityId: _identityId || 'demo-identity',
sendAt: delayedUntil,
undoStatus: 'pending',
isSmime: false,
});
}
this.recalcMailboxCounts(); this.recalcMailboxCounts();
return delayedUntil
? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt: delayedUntil }
: { scheduled: false, emailId: email.id };
} }
async sendImipReply(): Promise<void> { /* no-op in demo */ } async sendImipReply(): Promise<void> { /* no-op in demo */ }
@@ -487,9 +516,17 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Blobs ───────────────────────────────────────────────────── // ── 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'); const blobId = generateDemoId('blob');
this.blobStore.set(blobId, file); this.blobStore.set(blobId, file);
opts?.onProgress?.(file.size, file.size);
return { blobId, size: file.size, type: file.type }; 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 importRawEmail(): Promise<string> { return generateDemoId('email'); }
async submitEmail(): Promise<void> { /* no-op */ } async submitEmail(): Promise<void> { /* no-op */ }
async sendRawEmail(): Promise<void> { /* no-op */ } async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise<SendEmailResult> {
const emailId = generateDemoId('email');
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = {
id: emailId,
threadId: generateDemoId('thread'),
mailboxIds: { [(delayedUntil ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true },
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
size: 1024,
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [],
subject: 'S/MIME message',
preview: 'Signed/encrypted demo message',
hasAttachment: false,
};
this.data.emails.push(email);
let emailSubmissionId: string | undefined;
if (delayedUntil) {
emailSubmissionId = generateDemoId('submission');
this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: true });
}
this.recalcMailboxCounts();
return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true };
}
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
const pending = Array.from(this.scheduledSubmissions.values())
.filter(s => s.undoStatus === 'pending')
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
const page = pending.slice(position, position + limit);
const emails = page.map((submission) => {
const email = this.data.emails.find(e => e.id === submission.emailId);
if (!email) return null;
return {
...email,
scheduledSendAt: submission.sendAt,
emailSubmissionId: submission.id,
scheduledIdentityId: submission.identityId,
scheduledUndoStatus: submission.undoStatus,
isScheduled: true,
isSmimeScheduled: submission.isSmime,
} satisfies ScheduledEmail;
}).filter((email): email is ScheduledEmail => email !== null);
const nextPosition = position + page.length;
return { emails, hasMore: nextPosition < pending.length, total: pending.length, nextPosition };
}
async cancelEmailSubmission(submissionId: string): Promise<void> {
const submission = this.scheduledSubmissions.get(submissionId);
if (submission) submission.undoStatus = 'canceled';
}
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
await this.cancelEmailSubmission(submissionId);
const replacement = generateDemoId('submission');
this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: false });
return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt: delayedUntil };
}
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (!email) return;
email.mailboxIds[draftMailboxId] = true;
if (sentMailboxId) delete email.mailboxIds[sentMailboxId];
email.keywords.$draft = true;
this.recalcMailboxCounts();
}
// ── Internal helpers ────────────────────────────────────────── // ── Internal helpers ──────────────────────────────────────────
+264
View File
@@ -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",
};
}
+48
View File
@@ -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";
+25 -8
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription } from "./types"; import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { SieveScript, SieveCapabilities } from "./sieve-types";
/** /**
@@ -31,6 +31,8 @@ export interface IJMAPClient {
getMaxSizeUpload(): number; getMaxSizeUpload(): number;
getMaxCallsInRequest(): number; getMaxCallsInRequest(): number;
getMaxObjectsInGet(): number; getMaxObjectsInGet(): number;
getMaxDelayedSend(accountId?: string): number;
hasDelayedSend(accountId?: string): boolean;
getEventSourceUrl(): string | null; getEventSourceUrl(): string | null;
supportsEmailSubmission(): boolean; supportsEmailSubmission(): boolean;
supportsQuota(): boolean; supportsQuota(): boolean;
@@ -96,9 +98,9 @@ export interface IJMAPClient {
setKeyword(emailId: string, keyword: string): Promise<void>; setKeyword(emailId: string, keyword: string): Promise<void>;
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>; migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
deleteEmail(emailId: string): Promise<void>; 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>; 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( batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>, emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string, archiveMailboxId: string,
@@ -110,7 +112,7 @@ export interface IJMAPClient {
emptyMailbox(mailboxId: string): Promise<number>; emptyMailbox(mailboxId: string): Promise<number>;
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>; markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
markAllAsRead(excludeMailboxIds?: 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>; undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
// ── Threads ─────────────────────────────────────────────────── // ── Threads ───────────────────────────────────────────────────
@@ -146,8 +148,15 @@ export interface IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[], inReplyTo?: string[],
references?: string[], references?: string[],
delayedUntil?: string,
envelopeMailFrom?: string, envelopeMailFrom?: string,
): Promise<void>; ): Promise<SendEmailResult>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
cancelEmailSubmission(submissionId: string): Promise<void>;
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void>;
sendImipReply(opts: { sendImipReply(opts: {
organizerEmail: string; organizerEmail: string;
@@ -169,7 +178,16 @@ export interface IJMAPClient {
sendImipCancellation(event: CalendarEvent): Promise<void>; sendImipCancellation(event: CalendarEvent): Promise<void>;
// ── Blobs ───────────────────────────────────────────────────── // ── 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; getBlobDownloadUrl(blobId: string, name?: string, type?: string): string;
fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>; fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>;
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>; 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>; copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
// ── S/MIME raw-email helpers ────────────────────────────────── // ── 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>; submitEmail(emailId: string, identityId: string): Promise<void>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
} }
+501 -41
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription } from "./types"; import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface"; import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils"; import { toWildcardQuery } from "./search-utils";
@@ -61,6 +61,12 @@ interface JMAPEmailHeader {
type JMAPMethodCall = [string, Record<string, unknown>, string]; type JMAPMethodCall = [string, Record<string, unknown>, string];
const SUBMISSION_USING = [
'urn:ietf:params:jmap:core',
'urn:ietf:params:jmap:mail',
'urn:ietf:params:jmap:submission',
] as const;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type JMAPResponseResult = Record<string, any>; type JMAPResponseResult = Record<string, any>;
@@ -93,6 +99,8 @@ const EMAIL_LIST_PROPERTIES = [
"subject", "subject",
"preview", "preview",
"hasAttachment", "hasAttachment",
// Needed so list rows can serve drag-out to the file system as .eml.
"blobId",
] as const; ] as const;
// Stalwart's default property list for Calendar/get omits shareWith, isVisible, // 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; return emailCount === limit;
} }
function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean {
return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/'));
}
function isSmimeEmail(email: Email): boolean {
const types: string[] = [];
const collect = (part: Email['bodyStructure']): void => {
if (!part) return;
if (part.type) types.push(part.type.toLowerCase());
part.subParts?.forEach(collect);
};
collect(email.bodyStructure);
email.attachments?.forEach(att => types.push((att.type || '').toLowerCase()));
return types.some(type =>
type.includes('pkcs7') ||
type.includes('x-pkcs7') ||
type === 'application/pkcs7-mime' ||
type === 'application/pkcs7-signature'
);
}
/** /**
* Fold a single iCalendar content line per RFC 5545 §3.1. * Fold a single iCalendar content line per RFC 5545 §3.1.
* Lines longer than 75 octets MUST be split with CRLF + a single linear white space character. * Lines longer than 75 octets MUST be split with CRLF + a single linear white space character.
@@ -345,6 +374,33 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string {
return name.replace(/\s*<[^>]*>\s*$/, '').trim(); return name.replace(/\s*<[^>]*>\s*$/, '').trim();
} }
function normalizeEnvelopeRecipients(recipients?: Array<string | EmailAddress>): Array<{ email: string }> {
return (recipients || [])
.map((recipient) => typeof recipient === 'string' ? recipient : recipient.email)
.map((email) => email.trim())
.filter(Boolean)
.map((email) => ({ email }));
}
function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: number, recipients?: Array<string | EmailAddress>): Record<string, unknown> | undefined {
if (!holdForSeconds) return undefined;
const rcptTo = normalizeEnvelopeRecipients(recipients);
return {
mailFrom: {
email: fromEmail,
parameters: {
HOLDFOR: String(holdForSeconds),
},
},
rcptTo,
};
}
type SubmissionCapability = {
maxDelayedSend?: number;
submissionExtensions?: unknown;
};
export class JMAPClient implements IJMAPClient { export class JMAPClient implements IJMAPClient {
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000; private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
@@ -701,7 +757,7 @@ export class JMAPClient implements IJMAPClient {
} }
const requestBody = { const requestBody = {
using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], using: using || (hasSubmissionMethod(methodCalls) ? [...SUBMISSION_USING] : ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]),
methodCalls, methodCalls,
}; };
@@ -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 targetAccountId = accountId || this.accountId;
const patch: Record<string, unknown> = { mailboxIds: { [trashMailboxId]: true } };
if (markAsRead) patch["keywords/$seen"] = true;
await this.request([ await this.request([
["Email/set", { ["Email/set", {
accountId: targetAccountId, accountId: targetAccountId,
update: { update: { [emailId]: patch },
[emailId]: {
mailboxIds: { [trashMailboxId]: true },
},
},
}, "0"], }, "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; 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([ await this.request([
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"], ["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]); ]);
@@ -1505,7 +1564,7 @@ export class JMAPClient implements IJMAPClient {
return totalMarked; 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 targetAccountId = accountId || this.accountId;
const mailboxes = await this.getMailboxes(); const mailboxes = await this.getMailboxes();
@@ -1524,14 +1583,13 @@ export class JMAPClient implements IJMAPClient {
? junkMailbox.originalId ? junkMailbox.originalId
: junkMailbox.id; : junkMailbox.id;
const patch: Record<string, unknown> = { mailboxIds: { [mailboxId]: true } };
if (markAsRead) patch["keywords/$seen"] = true;
await this.request([ await this.request([
["Email/set", { ["Email/set", {
accountId: targetAccountId, accountId: targetAccountId,
update: { update: { [emailId]: patch },
[emailId]: {
mailboxIds: { [mailboxId]: true },
},
},
}, "0"], }, "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 }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[], inReplyTo?: string[],
references?: string[], references?: string[],
delayedUntil?: string,
envelopeMailFrom?: string envelopeMailFrom?: string
): Promise<void> { ): Promise<SendEmailResult> {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
const emailId = `send-${Date.now()}`; const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes(); const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
@@ -2193,12 +2253,19 @@ export class JMAPClient implements IJMAPClient {
// e.g. sending from a domain-catch-all alias without a dedicated Identity), // e.g. sending from a domain-catch-all alias without a dedicated Identity),
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope` // set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
// is omitted the server derives mailFrom from the Identity. // is omitted the server derives mailFrom from the Identity.
const submissionCreate = (submissionId: string): Record<string, unknown> => { const buildSubmissionCreate = (submissionId: string): Record<string, unknown> => {
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId }; const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
if (envelopeMailFrom) { if (holdForSeconds || envelopeMailFrom) {
const envelopeRecipients = [...to, ...(cc || []), ...(bcc || [])]
.map((email) => email.trim())
.filter(Boolean)
.map((email) => ({ email }));
create.envelope = { create.envelope = {
mailFrom: { email: envelopeMailFrom }, mailFrom: {
rcptTo: [...to, ...(cc || []), ...(bcc || [])].map((email) => ({ email })), email: envelopeMailFrom || fromEmail || this.username,
...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}),
},
rcptTo: envelopeRecipients,
}; };
} }
return { [submissionId]: create }; return { [submissionId]: create };
@@ -2215,8 +2282,8 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate }, create: { [emailId]: emailCreate },
}, "1"]); }, "1"]);
methodCalls.push(["EmailSubmission/set", { methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId, accountId: this.getSubmissionAccountId(),
create: submissionCreate("1"), create: buildSubmissionCreate("1"),
onSuccessUpdateEmail, onSuccessUpdateEmail,
}, "2"]); }, "2"]);
} else { } else {
@@ -2225,14 +2292,18 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate }, create: { [emailId]: emailCreate },
}, "0"]); }, "0"]);
methodCalls.push(["EmailSubmission/set", { methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId, accountId: this.getSubmissionAccountId(),
create: submissionCreate("1"), create: buildSubmissionCreate("1"),
onSuccessUpdateEmail, onSuccessUpdateEmail,
}, "1"]); }, "1"]);
} }
const response = await this.request(methodCalls); const response = await this.request(methodCalls);
let createdEmailId: string | undefined;
let emailSubmissionId: string | undefined;
let serverSendAt: string | undefined;
if (response.methodResponses) { if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) { for (const [methodName, result] of response.methodResponses) {
if (methodName.endsWith('/error')) { if (methodName.endsWith('/error')) {
@@ -2264,10 +2335,26 @@ export class JMAPClient implements IJMAPClient {
`${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`, `${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`,
); );
} }
if (methodName === 'Email/set' && result.created?.[emailId]?.id) {
createdEmailId = result.created[emailId].id;
}
if (methodName === 'EmailSubmission/set' && result.created?.['1']?.id) {
emailSubmissionId = result.created['1'].id;
serverSendAt = result.created['1'].sendAt;
} }
} }
} }
if (delayedUntil && emailSubmissionId && !serverSendAt) {
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
}
return delayedUntil
? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt }
: { scheduled: false, emailId: createdEmailId, emailSubmissionId };
}
/** /**
* Send an iMIP (RFC 6047) REPLY email to the organizer after an RSVP. * Send an iMIP (RFC 6047) REPLY email to the organizer after an RSVP.
* This is needed when the server does not handle sendSchedulingMessages. * This is needed when the server does not handle sendSchedulingMessages.
@@ -2427,7 +2514,7 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate }, create: { [emailId]: emailCreate },
}, "0"], }, "0"],
["EmailSubmission/set", { ["EmailSubmission/set", {
accountId: this.accountId, accountId: this.getSubmissionAccountId(),
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
onSuccessUpdateEmail: { onSuccessUpdateEmail: {
"#sub-1": { "#sub-1": {
@@ -2605,7 +2692,7 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate }, create: { [emailId]: emailCreate },
}, "0"], }, "0"],
["EmailSubmission/set", { ["EmailSubmission/set", {
accountId: this.accountId, accountId: this.getSubmissionAccountId(),
create: { "sub-1": { emailId: `#${emailId}`, identityId } }, create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: { onSuccessUpdateEmail: {
"#sub-1": { "#sub-1": {
@@ -2757,7 +2844,7 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate }, create: { [emailId]: emailCreate },
}, "0"], }, "0"],
["EmailSubmission/set", { ["EmailSubmission/set", {
accountId: this.accountId, accountId: this.getSubmissionAccountId(),
create: { "sub-1": { emailId: `#${emailId}`, identityId } }, create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: { onSuccessUpdateEmail: {
"#sub-1": { "#sub-1": {
@@ -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) { if (!this.session) {
throw new Error('Not connected. Call connect() first.'); throw new Error('Not connected. Call connect() first.');
} }
@@ -2794,19 +2945,33 @@ export class JMAPClient implements IJMAPClient {
throw new Error('Upload URL not available'); throw new Error('Upload URL not available');
} }
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(this.accountId)); const targetAccountId = opts.accountId || this.accountId;
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId));
// 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, { const response = await this.authenticatedFetch(finalUploadUrl, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' }, headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file, body: file,
}); });
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
throw new Error(`Failed to upload file: ${response.status} - ${errorText}`); throw new Error(`Failed to upload file: ${response.status} - ${errorText}`);
} }
responseText = await response.text();
const responseText = await response.text(); }
let result; let result;
try { try {
result = JSON.parse(responseText); result = JSON.parse(responseText);
@@ -2824,7 +2989,7 @@ export class JMAPClient implements IJMAPClient {
} }
// Nested format: { [accountId]: { blobId, type, size } } // Nested format: { [accountId]: { blobId, type, size } }
const blobInfo = result[this.accountId]; const blobInfo = result[targetAccountId];
if (blobInfo?.blobId) { if (blobInfo?.blobId) {
return { return {
blobId: blobInfo.blobId, blobId: blobInfo.blobId,
@@ -2893,6 +3058,85 @@ export class JMAPClient implements IJMAPClient {
return coreCapability?.maxObjectsInGet || 500; return coreCapability?.maxObjectsInGet || 500;
} }
getMaxDelayedSend(accountId?: string): number {
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
}
hasDelayedSend(accountId?: string): boolean {
const submissionCapability = this.getSubmissionCapability(accountId);
const submissionExtensions = submissionCapability?.submissionExtensions;
const hasFutureRelease = this.hasSubmissionExtension(submissionExtensions, 'FUTURERELEASE');
return !!submissionCapability
&& hasFutureRelease
&& this.getMaxDelayedSend(accountId) > 0;
}
private validateDelayedUntil(delayedUntil: string, accountId?: string): number {
const time = new Date(delayedUntil).getTime();
if (!Number.isFinite(time)) {
throw new Error('Scheduled send time is invalid');
}
const now = Date.now();
if (time <= now) {
throw new Error('Scheduled send time must be in the future');
}
const maxDelayedSend = this.getMaxDelayedSend(accountId);
if (!this.hasDelayedSend(accountId) || maxDelayedSend <= 0) {
throw new Error('Scheduled send is not supported for this account');
}
if (time > now + maxDelayedSend * 1000) {
throw new Error('Scheduled send time is later than the server allows');
}
return Math.ceil((time - now) / 1000);
}
private async getEmailSubmissionSendAt(submissionId: string): Promise<string | undefined> {
const response = await this.request([
['EmailSubmission/get', {
accountId: this.getSubmissionAccountId(),
ids: [submissionId],
properties: ['sendAt', 'undoStatus'],
}, '0'],
]);
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { sendAt?: string } | undefined;
return submission?.sendAt;
}
private async getEmailSubmissionEnvelope(submissionId: string): Promise<{ rcptTo?: Array<{ email: string }> } | undefined> {
const response = await this.request([
['EmailSubmission/get', {
accountId: this.getSubmissionAccountId(),
ids: [submissionId],
properties: ['envelope'],
}, '0'],
]);
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { envelope?: { rcptTo?: Array<{ email: string }> } } | undefined;
return submission?.envelope;
}
private getSubmissionAccountId(accountId?: string): string {
return accountId || this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'] || this.accountId;
}
private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined {
const submissionAccountId = this.getSubmissionAccountId(accountId);
return this.session?.accounts?.[submissionAccountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission'] as SubmissionCapability | undefined;
}
private hasSubmissionExtension(submissionExtensions: unknown, extension: string): boolean {
const target = extension.toUpperCase();
if (Array.isArray(submissionExtensions)) {
return submissionExtensions.some(item => typeof item === 'string' && item.toUpperCase() === target);
}
if (submissionExtensions && typeof submissionExtensions === 'object') {
return Object.entries(submissionExtensions as Record<string, unknown>)
.some(([key, value]) => key.toUpperCase() === target && value !== false && value != null);
}
return false;
}
getEventSourceUrl(): string | null { getEventSourceUrl(): string | null {
if (!this.session) return null; if (!this.session) return null;
@@ -4117,6 +4361,7 @@ export class JMAPClient implements IJMAPClient {
const createMap: Record<string, Partial<CalendarEvent>> = {}; const createMap: Record<string, Partial<CalendarEvent>> = {};
for (let i = 0; i < events.length; i++) { for (let i = 0; i < events.length; i++) {
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent; 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; createMap[`new-${i}`] = clean;
} }
@@ -5297,20 +5542,29 @@ export class JMAPClient implements IJMAPClient {
return response.arrayBuffer(); 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( async importRawEmail(
blob: Blob, blob: Blob,
mailboxIds: Record<string, boolean>, mailboxIds: Record<string, boolean>,
keywords?: Record<string, boolean>, keywords?: Record<string, boolean>,
accountId?: string,
): Promise<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 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 // Then import via Email/import
const response = await this.request([ const response = await this.request([
['Email/import', { ['Email/import', {
accountId: this.accountId, accountId: targetAccountId,
emails: { emails: {
'smime-import': { 'smime-import': {
blobId, blobId,
@@ -5338,7 +5592,7 @@ export class JMAPClient implements IJMAPClient {
async submitEmail(emailId: string, identityId: string): Promise<void> { async submitEmail(emailId: string, identityId: string): Promise<void> {
const response = await this.request([ const response = await this.request([
['EmailSubmission/set', { ['EmailSubmission/set', {
accountId: this.accountId, accountId: this.getSubmissionAccountId(),
create: { 'smime-submit': { emailId, identityId } }, create: { 'smime-submit': { emailId, identityId } },
}, '0'], }, '0'],
]); ]);
@@ -5359,7 +5613,10 @@ export class JMAPClient implements IJMAPClient {
identityId: string, identityId: string,
sentMailboxId: string, sentMailboxId: string,
draftMailboxId?: string, draftMailboxId?: string,
): Promise<void> { delayedUntil?: string,
envelopeRecipients?: string[],
): Promise<SendEmailResult> {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
// Upload the raw message // Upload the raw message
const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
const { blobId } = await this.uploadBlob(file); const { blobId } = await this.uploadBlob(file);
@@ -5367,6 +5624,10 @@ export class JMAPClient implements IJMAPClient {
// Import into Drafts first, then move to Sent after submission succeeds. // Import into Drafts first, then move to Sent after submission succeeds.
// This avoids encrypt-on-append affecting the SMTP send. See #188. // This avoids encrypt-on-append affecting the SMTP send. See #188.
const importMailboxId = draftMailboxId || sentMailboxId; const importMailboxId = draftMailboxId || sentMailboxId;
const identities = await this.getIdentities();
const identity = identities.find(item => item.id === identityId);
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
const methodCalls: [string, Record<string, unknown>, string][] = [ const methodCalls: [string, Record<string, unknown>, string][] = [
['Email/import', { ['Email/import', {
accountId: this.accountId, accountId: this.accountId,
@@ -5379,11 +5640,12 @@ export class JMAPClient implements IJMAPClient {
}, },
}, '0'], }, '0'],
['EmailSubmission/set', { ['EmailSubmission/set', {
accountId: this.accountId, accountId: this.getSubmissionAccountId(),
create: { create: {
'raw-submit': { 'raw-submit': {
emailId: '#raw-import', emailId: '#raw-import',
identityId, identityId,
...(envelope ? { envelope } : {}),
}, },
}, },
...(draftMailboxId ? { ...(draftMailboxId ? {
@@ -5399,6 +5661,9 @@ export class JMAPClient implements IJMAPClient {
]; ];
const response = await this.request(methodCalls); const response = await this.request(methodCalls);
let emailId: string | undefined;
let emailSubmissionId: string | undefined;
let serverSendAt: string | undefined;
// Check for errors // Check for errors
for (const [methodName, result] of response.methodResponses ?? []) { for (const [methodName, result] of response.methodResponses ?? []) {
@@ -5410,6 +5675,201 @@ export class JMAPClient implements IJMAPClient {
const firstErr = Object.values(r.notCreated)[0]; const firstErr = Object.values(r.notCreated)[0];
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email'); throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
} }
if (methodName === 'Email/import') {
emailId = (result as { created?: Record<string, { id?: string }> }).created?.['raw-import']?.id;
}
if (methodName === 'EmailSubmission/set') {
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['raw-submit'];
emailSubmissionId = created?.id;
serverSendAt = created?.sendAt;
}
}
if (delayedUntil && emailSubmissionId && !serverSendAt) {
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
}
return delayedUntil
? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, isSmime: true }
: { scheduled: false, emailId, emailSubmissionId, isSmime: true };
}
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
if (!this.hasDelayedSend()) {
return { emails: [], hasMore: false, total: 0, nextPosition: position };
}
const now = Date.now();
const pageSize = Math.max(limit, 50);
const submissions: EmailSubmission[] = [];
let rawPosition = 0;
let rawTotal = 0;
do {
const queryResponse = await this.request([
['EmailSubmission/query', {
accountId: this.getSubmissionAccountId(),
limit: pageSize,
position: rawPosition,
}, '0'],
]);
const query = queryResponse.methodResponses?.[0]?.[1] as { ids?: string[]; total?: number; position?: number } | undefined;
const ids = query?.ids ?? [];
rawTotal = query?.total ?? rawPosition + ids.length;
if (ids.length === 0) break;
const submissionResponse = await this.request([
['EmailSubmission/get', {
accountId: this.getSubmissionAccountId(),
ids,
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus', 'deliveryStatus'],
}, '0'],
]);
submissions.push(...((submissionResponse.methodResponses?.[0]?.[1]?.list ?? []) as EmailSubmission[])
.filter(submission => {
if (submission.undoStatus !== 'pending' || !submission.sendAt) return false;
const sendAtTime = new Date(submission.sendAt).getTime();
return Number.isFinite(sendAtTime) && sendAtTime > now;
}));
rawPosition += ids.length;
} while (rawPosition < rawTotal);
submissions.sort((a, b) => new Date(a.sendAt || '').getTime() - new Date(b.sendAt || '').getTime());
const total = submissions.length;
const pageSubmissions = submissions.slice(position, position + limit);
const nextPosition = position + pageSubmissions.length;
if (pageSubmissions.length === 0) {
return { emails: [], hasMore: false, total, nextPosition };
}
const emailResponse = await this.request([
['Email/get', {
accountId: this.accountId,
ids: pageSubmissions.map(submission => submission.emailId),
properties: [
'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'bcc', 'replyTo',
'subject', 'preview', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'hasAttachment', 'sentAt',
'messageId', 'inReplyTo', 'references', 'headers', 'blobId', 'bodyStructure',
],
fetchTextBodyValues: true,
fetchHTMLBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 256000,
}, '0'],
]);
const emailById = new Map(((emailResponse.methodResponses?.[0]?.[1]?.list ?? []) as Email[]).map(email => [email.id, email]));
const emails = pageSubmissions
.map((submission): ScheduledEmail | null => {
const email = emailById.get(submission.emailId);
if (!email || !submission.sendAt) return null;
return {
...email,
threadId: submission.threadId || email.threadId,
scheduledSendAt: submission.sendAt,
emailSubmissionId: submission.id,
scheduledIdentityId: submission.identityId,
scheduledUndoStatus: submission.undoStatus,
scheduledDeliveryStatus: submission.deliveryStatus,
isScheduled: true,
isSmimeScheduled: isSmimeEmail(email),
};
})
.filter((email): email is ScheduledEmail => email !== null)
.sort((a, b) => new Date(a.scheduledSendAt).getTime() - new Date(b.scheduledSendAt).getTime());
return { emails, hasMore: nextPosition < total, total, nextPosition };
}
async cancelEmailSubmission(submissionId: string): Promise<void> {
const response = await this.request([
['EmailSubmission/set', {
accountId: this.getSubmissionAccountId(),
update: { [submissionId]: { undoStatus: 'canceled' } },
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
const error = result?.notUpdated?.[submissionId];
if (error) {
throw new Error(error.description || error.type || 'Failed to cancel scheduled send');
}
}
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
const holdForSeconds = this.validateDelayedUntil(delayedUntil);
const mailboxes = await this.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
const identities = await this.getIdentities();
const identity = identities.find(item => item.id === identityId);
const existingEnvelope = await this.getEmailSubmissionEnvelope(submissionId);
const email = existingEnvelope?.rcptTo?.length ? undefined : await this.getEmail(emailId);
const envelopeRecipients = existingEnvelope?.rcptTo?.length
? existingEnvelope.rcptTo
: [...(email?.to || []), ...(email?.cc || []), ...(email?.bcc || [])];
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
const response = await this.request([
['EmailSubmission/set', {
accountId: this.getSubmissionAccountId(),
create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } },
...(draftsMailbox && sentMailbox ? {
onSuccessUpdateEmail: {
'#replacement': {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
'keywords/$draft': null,
},
},
} : {}),
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
const createError = result?.notCreated?.replacement;
if (createError) {
throw new Error(createError.description || createError.type || 'Failed to reschedule email');
}
const replacementId = result?.created?.replacement?.id;
const serverSendAt = result?.created?.replacement?.sendAt;
if (!replacementId) {
throw new Error('Server did not return a replacement scheduled send ID');
}
const finalSendAt = serverSendAt || await this.getEmailSubmissionSendAt(replacementId);
try {
await this.cancelEmailSubmission(submissionId);
} catch (error) {
try {
await this.cancelEmailSubmission(replacementId);
} catch (cleanupError) {
console.error('Failed to clean up replacement scheduled send after reschedule failure:', cleanupError);
}
const message = error instanceof Error ? error.message : 'Failed to cancel original scheduled send';
throw new Error(`Reschedule created a replacement but could not cancel the original: ${message}`);
}
return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt };
}
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
const update: Record<string, unknown> = {
[`mailboxIds/${draftMailboxId}`]: true,
'keywords/$draft': true,
};
if (sentMailboxId) {
update[`mailboxIds/${sentMailboxId}`] = null;
}
const response = await this.request([
['Email/set', {
accountId: this.accountId,
update: { [emailId]: update },
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
const error = result?.notUpdated?.[emailId];
if (error) {
throw new Error(error.description || error.type || 'Failed to restore email to drafts');
} }
} }
+26
View File
@@ -42,6 +42,32 @@ export interface Email {
// Unified mailbox support - set when displaying emails from multiple accounts // Unified mailbox support - set when displaying emails from multiple accounts
accountId?: string; accountId?: string;
accountLabel?: string; accountLabel?: string;
// Client-only scheduled-send metadata, populated from EmailSubmission/query.
scheduledSendAt?: string;
emailSubmissionId?: string;
scheduledIdentityId?: string;
scheduledUndoStatus?: 'pending' | 'final' | 'canceled';
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
isScheduled?: boolean;
isSmimeScheduled?: boolean;
}
export interface SendEmailResult {
scheduled: boolean;
emailId?: string;
emailSubmissionId?: string;
sendAt?: string;
isSmime?: boolean;
}
export interface ScheduledEmail extends Email {
scheduledSendAt: string;
emailSubmissionId: string;
scheduledIdentityId: string;
scheduledUndoStatus: 'pending' | 'final' | 'canceled';
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
isScheduled: true;
isSmimeScheduled: boolean;
} }
export interface AuthenticationResults { export interface AuthenticationResults {
+13 -3
View File
@@ -1,11 +1,21 @@
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery'; 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 { isPublicHttpUrl } from '@/lib/security/url-guard';
import { readFileEnv } from '@/lib/read-file-env'; import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers'; 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 { function getGlobalClientSecret(): string {
const adminSecret = configManager.get<string>('oauthClientSecret', ''); const adminSecret = configManager.get<string>('oauthClientSecret', '');
if (adminSecret) return adminSecret; if (adminSecret) return adminSecret;
@@ -47,7 +57,7 @@ function getClientSecret(serverId?: string | null): string {
export async function getTokenEndpoint(serverId?: string | null): Promise<string> { export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
const { discoveryUrl } = getRequiredConfig(serverId); const { discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl }); const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
if (!metadata?.token_endpoint) { if (!metadata?.token_endpoint) {
throw new Error('OAuth token endpoint not found'); 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> { export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
const { discoveryUrl } = getRequiredConfig(serverId); 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 { export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
+3 -1
View File
@@ -10,6 +10,8 @@
// installing their own code. Verification kicks in for server-managed // installing their own code. Verification kicks in for server-managed
// bundles only (the `managed: true` flag on `InstalledPlugin`). // bundles only (the `managed: true` flag on `InstalledPlugin`).
import { apiFetch } from '@/lib/browser-navigation';
let cachedPubKey: CryptoKey | null = null; let cachedPubKey: CryptoKey | null = null;
let pubKeyPromise: Promise<CryptoKey | null> | 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> { async function fetchPublicKey(): Promise<CryptoKey | null> {
try { 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; if (!res.ok) return null;
const data = await res.json() as { algorithm?: string; publicKey?: string }; const data = await res.json() as { algorithm?: string; publicKey?: string };
if (data.algorithm !== 'ed25519' || typeof data.publicKey !== 'string') return null; if (data.algorithm !== 'ed25519' || typeof data.publicKey !== 'string') return null;
+32 -4
View File
@@ -6,6 +6,11 @@ export interface UnifiedAccountClient {
accountLabel: string; accountLabel: string;
client: IJMAPClient; client: IJMAPClient;
mailboxes: Mailbox[]; 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 { export interface UnifiedFetchResult {
@@ -60,10 +65,11 @@ export async function fetchUnifiedEmails(
const mailbox = findMailboxByRole(account.mailboxes, role); const mailbox = findMailboxByRole(account.mailboxes, role);
if (!mailbox) return null; if (!mailbox) return null;
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
try { try {
const result = await account.client.getEmails( const result = await account.client.getEmails(
mailbox.id, jmapMailboxId,
undefined, jmapAccountId,
limit, limit,
position, position,
); );
@@ -131,7 +137,8 @@ export async function searchUnifiedEmails(
position: number, position: number,
): Promise<UnifiedFetchResult> { ): Promise<UnifiedFetchResult> {
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => { 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, position: number,
): Promise<UnifiedFetchResult> { ): Promise<UnifiedFetchResult> {
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => { 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( async function fanOutUnifiedQuery(
accounts: UnifiedAccountClient[], accounts: UnifiedAccountClient[],
role: UnifiedMailboxRole, role: UnifiedMailboxRole,
+68 -4
View File
@@ -3,6 +3,8 @@ import { twMerge } from "tailwind-merge";
import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types"; import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types";
import type { UnifiedMailboxRole } from "./jmap/types"; import type { UnifiedMailboxRole } from "./jmap/types";
import { debug } from "./debug"; import { debug } from "./debug";
import { useLocaleStore } from "@/stores/locale-store";
import { useSettingsStore } from "@/stores/settings-store";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@@ -40,25 +42,87 @@ export function generateUUID(): string {
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; 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 { export function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date; const d = typeof date === "string" ? new Date(date) : date;
const now = new Date(); const now = new Date();
const diff = now.getTime() - d.getTime();
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 (dateFormat === "relative") {
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000); const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000); const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000); const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now"; if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`; if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`; if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`; if (days < 7) return `${days}d ago`;
return d.toLocaleDateString(intlLocale, {
return d.toLocaleDateString("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 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",
});
} }
/** /**
+3 -2
View File
@@ -4,6 +4,7 @@
*/ */
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
export interface WebDAVResource { export interface WebDAVResource {
href: string; href: string;
@@ -32,7 +33,7 @@ export class WebDAVClient {
...options?.headers, ...options?.headers,
}; };
return fetch(this.proxyUrl, { return apiFetch(this.proxyUrl, {
method: 'POST', method: 'POST',
headers, headers,
body: options?.body, body: options?.body,
@@ -118,7 +119,7 @@ export class WebDAVClient {
// Use XMLHttpRequest for progress tracking // Use XMLHttpRequest for progress tracking
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); 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-Method', 'PUT');
xhr.setRequestHeader('X-WebDAV-Path', path); xhr.setRequestHeader('X-WebDAV-Path', path);
const slotHeaders = getActiveAccountSlotHeaders(); const slotHeaders = getActiveAccountSlotHeaders();
+112 -15
View File
@@ -132,7 +132,8 @@
"shared": "Sdílené", "shared": "Sdílené",
"mail": "Pošta", "mail": "Pošta",
"nav_label": "Navigace", "nav_label": "Navigace",
"add_app": "Aplikace" "add_app": "Aplikace",
"scheduled": "Naplánováno"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Výchozí aplikace", "title": "Výchozí aplikace",
@@ -239,7 +240,16 @@
"confirm_button": "Vyprázdnit složku", "confirm_button": "Vyprázdnit složku",
"junk_hint": "Můžete vyprázdnit složku Spam a trvale odstranit všechny zprávy.", "junk_hint": "Můžete vyprázdnit složku Spam a trvale odstranit všechny zprávy.",
"trash_hint": "Můžete vyprázdnit Koš a trvale odstranit všechny zprávy." "trash_hint": "Můžete vyprázdnit Koš a trvale odstranit všechny zprávy."
} },
"no_scheduled_emails": "Žádné naplánované e-maily",
"no_scheduled_emails_description": "Zprávy naplánované na později se zobrazí zde.",
"scheduled_count": "{count, plural, one {1 naplánovaný e-mail} few {# naplánované e-maily} other {# naplánovaných e-mailů}}",
"scheduled_actions_hint": "Naplánované zprávy můžete zrušit, přeplánovat nebo upravit pomocí akcí plánování.",
"cancel_scheduled_send": "Zrušit odeslání",
"reschedule_send": "Přeplánovat",
"cancel_and_edit": "Zrušit a upravit",
"cancel_and_compose_again": "Zrušit a napsat znovu",
"reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Není vybrána žádná zpráva", "no_email_selected": "Není vybrána žádná zpráva",
@@ -270,7 +280,7 @@
"print": "Tisk", "print": "Tisk",
"view_source": "Zobrazit zdrojový kód", "view_source": "Zobrazit zdrojový kód",
"export_email": "Exportovat jako .eml", "export_email": "Exportovat jako .eml",
"import_email": "Importovat .eml", "import_email": "Importovat .eml nebo .zip",
"keyboard_shortcuts": "Klávesové zkratky (?)", "keyboard_shortcuts": "Klávesové zkratky (?)",
"email_source": "Zdrojový kód zprávy", "email_source": "Zdrojový kód zprávy",
"draft_banner": "Tato zpráva je koncept", "draft_banner": "Tato zpráva je koncept",
@@ -514,7 +524,17 @@
"ai_verdict": "Verdikt AI", "ai_verdict": "Verdikt AI",
"account": "Účet", "account": "Účet",
"no_subject": "(bez předmětu)" "no_subject": "(bez předmětu)"
} },
"scheduled_banner": "Naplánováno k odeslání {date}",
"scheduled_send_created": "E-mail byl naplánován k odeslání",
"cancel_scheduled_send": "Zrušit odeslání",
"reschedule_send": "Přeplánovat",
"cancel_and_edit": "Zrušit a upravit",
"cancel_and_compose_again": "Zrušit a napsat znovu",
"reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30",
"scheduled_actions_only": "Zobrazení Naplánováno podporuje pouze akce plánování",
"undo_send_scheduled": "Zpráva je naplánována k odeslání",
"undo_send": "Vrátit odeslání"
}, },
"email_composer": { "email_composer": {
"new_message": "Nová zpráva", "new_message": "Nová zpráva",
@@ -607,7 +627,19 @@
}, },
"add_link": "Přidat odkaz", "add_link": "Přidat odkaz",
"link_url_prompt": "Zadejte URL", "link_url_prompt": "Zadejte URL",
"sending": "Odesílání..." "sending": "Odesílání...",
"schedule_send": "Naplánovat odeslání",
"schedule_send_description": "Vyberte, kdy má server tuto zprávu odeslat.",
"schedule_send_required": "Vyberte datum a čas.",
"schedule_send_invalid": "Zadejte platné datum a čas.",
"schedule_send_future": "Vyberte budoucí datum a čas.",
"schedule_send_too_late": "Tento čas je později, než server dovoluje.",
"schedule_send_unsupported": "Naplánované odeslání není pro tento účet podporováno.",
"schedule_send_cleanup_warning": "Naplánované odeslání bylo vytvořeno, ale vyčištění konceptu selhalo.",
"send_delay_unsupported": "Prodleva odeslání není pro tento účet podporována.",
"send_delay_unsupported_confirm": "Tento účet nepodporuje prodlevu odeslání. Odeslat ihned?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Potvrdit", "confirm": "Potvrdit",
@@ -752,7 +784,7 @@
"keep_editing": "Pokračovat v úpravách", "keep_editing": "Pokračovat v úpravách",
"tabs": { "tabs": {
"appearance": "Vzhled", "appearance": "Vzhled",
"language": "Jazyk a region", "language": "Jazyk, region a čas",
"email": "Chování e-mailu", "email": "Chování e-mailu",
"composer": "Psaní zpráv", "composer": "Psaní zpráv",
"privacy": "Soukromí a bezpečnost", "privacy": "Soukromí a bezpečnost",
@@ -775,6 +807,7 @@
"layout": "Vzhled", "layout": "Vzhled",
"reading": "Čtení", "reading": "Čtení",
"composing": "Psaní", "composing": "Psaní",
"downloads": "Stažené",
"content_senders": "Obsah a odesílatelé", "content_senders": "Obsah a odesílatelé",
"about_data": "Info a data", "about_data": "Info a data",
"debug": "Ladění" "debug": "Ladění"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Sjednocená schránka", "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": { "colorful_sidebar_icons": {
"label": "Barevné ikony postranního panelu", "label": "Barevné ikony postranního panelu",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Jazyk a region", "title": "Jazyk, region a čas",
"description": "Nakonfigurujte jazykové a místní předvolby", "description": "Jazyk, formát data, formát času a další regionální předvolby",
"language": { "language": {
"label": "Jazyk", "label": "Jazyk",
"description": "Vyberte preferovaný jazyk", "description": "Vyberte preferovaný jazyk",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "Formát data", "label": "Formát data",
"description": "Jak se mají zobrazovat data", "description": "Jak se zobrazují data v seznamu e-mailů",
"regional": "Místní", "smart": "Chytrý (regionál)",
"iso": "ISO 8601", "relative": "Relativní (před 1 h, před 2 d)",
"custom": "Vlastní" "full": "Vždy úplné datum",
"preview_today": "Dnes:",
"preview_this_week": "Tento týden:",
"preview_older": "Starší:"
}, },
"time_format": { "time_format": {
"label": "Formát času", "label": "Formát času",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Zobrazit náhledy obrázků v přílohách", "label": "Zobrazit náhledy obrázků v přílohách",
"description": "Zobrazovat obrázkové přílohy jako miniatury místo obecných ikon souborů" "description": "Zobrazovat obrázkové přílohy jako miniatury místo obecných ikon souborů"
},
"send_delay": {
"label": "Vrátit odeslání / prodleva odeslání",
"description": "Pozdržet běžné odeslání krátkým oknem na serveru.",
"off": "Vypnuto",
"seconds": "{seconds} sekund",
"unsupported": "Aktuální účet neoznamuje podporu odloženého odeslání. Nastavení zůstane uloženo pro jiné účty."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"categories_description": "Přejmenovat kategorie kontaktů", "categories_description": "Přejmenovat kategorie kontaktů",
"no_categories": "Nenalezeny žádné kategorie" "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": { "filters": {
"title": "Filtry e-mailů", "title": "Filtry e-mailů",
"description": "Vytvářejte pravidla pro automatické třídění, štítkování a správu příchozích 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", "color_tag": "Štítek",
"remove_color": "Odebrat štítek", "remove_color": "Odebrat štítek",
"items_selected": "{count} vybraných zpráv", "items_selected": "{count} vybraných zpráv",
"edit_draft": "Upravit koncept" "edit_draft": "Upravit koncept",
"cancel_scheduled_send": "Zrušit odeslání",
"reschedule_send": "Přeplánovat",
"cancel_and_edit": "Zrušit a upravit",
"cancel_and_compose_again": "Zrušit a napsat znovu"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Označit složku jako přečtenou", "mark_folder_read": "Označit složku jako přečtenou",
@@ -1760,7 +1855,7 @@
"new_subfolder": "Nová podsložka...", "new_subfolder": "Nová podsložka...",
"new_folder": "Nová složka...", "new_folder": "Nová složka...",
"rename": "Přejmenovat...", "rename": "Přejmenovat...",
"import_email": "Importovat .eml...", "import_email": "Importovat .eml nebo .zip...",
"empty_folder": "Vyprázdnit složku", "empty_folder": "Vyprázdnit složku",
"empty_folder_generic": "Vyprázdnit složku", "empty_folder_generic": "Vyprázdnit složku",
"delete_folder": "Smazat složku", "delete_folder": "Smazat složku",
@@ -1829,6 +1924,8 @@
"expand_collapse": "Rozbalit/sbalit vlákno" "expand_collapse": "Rozbalit/sbalit vlákno"
}, },
"composer": { "composer": {
"send": "Odeslat e-mail",
"schedule_send": "Naplánovat odeslání",
"template_picker": "Otevřít výběr šablon" "template_picker": "Otevřít výběr šablon"
} }
}, },
+110 -15
View File
@@ -132,7 +132,8 @@
"shared": "Delt", "shared": "Delt",
"mail": "Mail", "mail": "Mail",
"nav_label": "Navigation", "nav_label": "Navigation",
"add_app": "Apps" "add_app": "Apps",
"scheduled": "Planlagt"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Standardapps", "title": "Standardapps",
@@ -239,7 +240,16 @@
"confirm_button": "Tøm mappe", "confirm_button": "Tøm mappe",
"junk_hint": "Du kan tømme spam-mappen for permanent at fjerne alle beskeder.", "junk_hint": "Du kan tømme spam-mappen for permanent at fjerne alle beskeder.",
"trash_hint": "Du kan tømme papirkurven for permanent at fjerne alle beskeder." "trash_hint": "Du kan tømme papirkurven for permanent at fjerne alle beskeder."
} },
"no_scheduled_emails": "Ingen planlagte e-mails",
"no_scheduled_emails_description": "Beskeder planlagt til senere vises her.",
"scheduled_count": "{count, plural, one {1 planlagt e-mail} other {# planlagte e-mails}}",
"scheduled_actions_hint": "Planlagte beskeder kan annulleres, planlægges om eller redigeres fra deres planlagte handlinger.",
"cancel_scheduled_send": "Annuller afsendelse",
"reschedule_send": "Planlæg om",
"cancel_and_edit": "Annuller og redigér",
"cancel_and_compose_again": "Annuller og skriv igen",
"reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Ingen e-mail valgt", "no_email_selected": "Ingen e-mail valgt",
@@ -270,7 +280,7 @@
"print": "Udskriv", "print": "Udskriv",
"view_source": "Vis kilde", "view_source": "Vis kilde",
"export_email": "Eksportér som .eml", "export_email": "Eksportér som .eml",
"import_email": "Importér .eml", "import_email": "Importér .eml eller .zip",
"keyboard_shortcuts": "Tastaturgenveje (?)", "keyboard_shortcuts": "Tastaturgenveje (?)",
"email_source": "E-mail-kilde", "email_source": "E-mail-kilde",
"draft_banner": "Denne besked er en kladde", "draft_banner": "Denne besked er en kladde",
@@ -514,7 +524,17 @@
"collapse": "Skjul detaljer" "collapse": "Skjul detaljer"
}, },
"send": "Send", "send": "Send",
"more": "mere" "more": "mere",
"scheduled_banner": "Planlagt til afsendelse {date}",
"scheduled_send_created": "E-mail planlagt til afsendelse",
"cancel_scheduled_send": "Annuller afsendelse",
"reschedule_send": "Planlæg om",
"cancel_and_edit": "Annuller og redigér",
"cancel_and_compose_again": "Annuller og skriv igen",
"reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30",
"scheduled_actions_only": "Planlagt-visningen understøtter kun planlagte handlinger",
"undo_send_scheduled": "Besked planlagt til afsendelse",
"undo_send": "Fortryd afsendelse"
}, },
"email_composer": { "email_composer": {
"new_message": "Ny besked", "new_message": "Ny besked",
@@ -607,7 +627,17 @@
"send_anyway": "Send alligevel", "send_anyway": "Send alligevel",
"back": "Tilbage til redigering" "back": "Tilbage til redigering"
}, },
"link_url_prompt": "Indtast URL'en" "link_url_prompt": "Indtast URL'en",
"schedule_send": "Planlæg afsendelse",
"schedule_send_description": "Vælg hvornår serveren skal frigive denne besked.",
"schedule_send_required": "Vælg en dato og et tidspunkt.",
"schedule_send_invalid": "Indtast en gyldig dato og tid.",
"schedule_send_future": "Vælg en fremtidig dato og tid.",
"schedule_send_too_late": "Dette tidspunkt er senere end serveren tillader.",
"schedule_send_unsupported": "Planlagt afsendelse understøttes ikke for denne konto.",
"schedule_send_cleanup_warning": "Planlagt afsendelse blev oprettet, men oprydning af kladde mislykkedes.",
"send_delay_unsupported": "Sendeforsinkelse understøttes ikke for denne konto.",
"send_delay_unsupported_confirm": "Denne konto understøtter ikke sendeforsinkelse. Send straks i stedet?"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Bekræft", "confirm": "Bekræft",
@@ -755,7 +785,7 @@
"search_no_results": "Ingen match i indstillinger", "search_no_results": "Ingen match i indstillinger",
"tabs": { "tabs": {
"appearance": "Udseende", "appearance": "Udseende",
"language": "Sprog & region", "language": "Sprog, region & tid",
"email": "E-mail-adfærd", "email": "E-mail-adfærd",
"composer": "Komponist", "composer": "Komponist",
"privacy": "Privatliv & sikkerhed", "privacy": "Privatliv & sikkerhed",
@@ -778,6 +808,7 @@
"layout": "Layout", "layout": "Layout",
"reading": "Læsning", "reading": "Læsning",
"composing": "Skrivning", "composing": "Skrivning",
"downloads": "Downloads",
"content_senders": "Indhold & afsendere", "content_senders": "Indhold & afsendere",
"about_data": "Om & data", "about_data": "Om & data",
"debug": "Debug" "debug": "Debug"
@@ -846,7 +877,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Samlet postkasse", "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": { "colorful_sidebar_icons": {
"label": "Farverige sidepane-ikoner", "label": "Farverige sidepane-ikoner",
@@ -930,8 +965,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Sprog & region", "title": "Sprog, region & tid",
"description": "Konfigurér sprog og regionale præferencer", "description": "Sprog, datoformat, tidsformat og andre regionale indstillinger",
"language": { "language": {
"label": "Sprog", "label": "Sprog",
"description": "Vælg dit foretrukne sprog", "description": "Vælg dit foretrukne sprog",
@@ -940,10 +975,13 @@
}, },
"date_format": { "date_format": {
"label": "Datoformat", "label": "Datoformat",
"description": "Hvordan datoer skal vises", "description": "Sådan vises datoer på e-mail-listen",
"regional": "Regionalt", "smart": "Smart (regionalt)",
"iso": "ISO 8601", "relative": "Relativ (for 1 t siden, for 2 d siden)",
"custom": "Brugerdefineret" "full": "Altid fuld dato",
"preview_today": "I dag:",
"preview_this_week": "Denne uge:",
"preview_older": "Ældre:"
}, },
"time_format": { "time_format": {
"label": "Tidsformat", "label": "Tidsformat",
@@ -1129,6 +1167,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Vis billedforhåndsvisninger i vedhæftninger", "label": "Vis billedforhåndsvisninger i vedhæftninger",
"description": "Vis billedvedhæftninger som miniaturekort i stedet for generiske filikoner" "description": "Vis billedvedhæftninger som miniaturekort i stedet for generiske filikoner"
},
"send_delay": {
"label": "Fortryd afsendelse / sendeforsinkelse",
"description": "Forsink normale afsendelser med et kort serverstyret tidsrum.",
"off": "Fra",
"seconds": "{seconds} sekunder",
"unsupported": "Den aktuelle konto annoncerer ikke understøttelse af forsinket afsendelse. Indstillingen gemmes stadig for andre konti."
} }
}, },
"composer": { "composer": {
@@ -1498,6 +1543,50 @@
"categories_description": "Omdøb kontaktkategorier", "categories_description": "Omdøb kontaktkategorier",
"no_categories": "Ingen kategorier fundet" "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": { "filters": {
"title": "E-mail-filtre", "title": "E-mail-filtre",
"description": "Opret regler til automatisk at sortere, mærke og administrere indkommende e-mails", "description": "Opret regler til automatisk at sortere, mærke og administrere indkommende e-mails",
@@ -1751,7 +1840,11 @@
"color_tag": "Tag", "color_tag": "Tag",
"remove_color": "Fjern tag", "remove_color": "Fjern tag",
"items_selected": "{count} e-mails valgt", "items_selected": "{count} e-mails valgt",
"edit_draft": "Redigér kladde" "edit_draft": "Redigér kladde",
"cancel_scheduled_send": "Annuller afsendelse",
"reschedule_send": "Planlæg om",
"cancel_and_edit": "Annuller og redigér",
"cancel_and_compose_again": "Annuller og skriv igen"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Markér mappe som læst", "mark_folder_read": "Markér mappe som læst",
@@ -1760,7 +1853,7 @@
"new_subfolder": "Ny undermappe...", "new_subfolder": "Ny undermappe...",
"new_folder": "Ny mappe...", "new_folder": "Ny mappe...",
"rename": "Omdøb...", "rename": "Omdøb...",
"import_email": "Importér .eml...", "import_email": "Importér .eml eller .zip...",
"empty_folder": "Tøm mappe", "empty_folder": "Tøm mappe",
"empty_folder_generic": "Tøm mappe", "empty_folder_generic": "Tøm mappe",
"delete_folder": "Slet mappe", "delete_folder": "Slet mappe",
@@ -1829,6 +1922,8 @@
"expand_collapse": "Udvid/skjul tråd" "expand_collapse": "Udvid/skjul tråd"
}, },
"composer": { "composer": {
"send": "Send e-mail",
"schedule_send": "Planlæg afsendelse",
"template_picker": "Åbn skabelonvælger" "template_picker": "Åbn skabelonvælger"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "E-Mail", "mail": "E-Mail",
"nav_label": "Navigation", "nav_label": "Navigation",
"add_app": "Apps", "add_app": "Apps",
"shared": "Geteilt" "shared": "Geteilt",
"scheduled": "Geplant"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Standard-Apps", "title": "Standard-Apps",
@@ -239,7 +240,16 @@
"confirm_button": "Ordner leeren", "confirm_button": "Ordner leeren",
"junk_hint": "Sie können den Spam-Ordner leeren, um alle Nachrichten dauerhaft zu entfernen.", "junk_hint": "Sie können den Spam-Ordner leeren, um alle Nachrichten dauerhaft zu entfernen.",
"trash_hint": "Sie können den Papierkorb leeren, um alle Nachrichten dauerhaft zu entfernen." "trash_hint": "Sie können den Papierkorb leeren, um alle Nachrichten dauerhaft zu entfernen."
} },
"no_scheduled_emails": "Keine geplanten E-Mails",
"no_scheduled_emails_description": "Nachrichten, die für später geplant sind, erscheinen hier.",
"scheduled_count": "{count, plural, one {1 geplante E-Mail} other {# geplante E-Mails}}",
"scheduled_actions_hint": "Geplante Nachrichten können über die Planungsaktionen abgebrochen, neu geplant oder bearbeitet werden.",
"cancel_scheduled_send": "Senden abbrechen",
"reschedule_send": "Neu planen",
"cancel_and_edit": "Abbrechen und bearbeiten",
"cancel_and_compose_again": "Abbrechen und neu verfassen",
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Keine E-Mail ausgewählt", "no_email_selected": "Keine E-Mail ausgewählt",
@@ -270,7 +280,7 @@
"print": "Drucken", "print": "Drucken",
"view_source": "Quelltext anzeigen", "view_source": "Quelltext anzeigen",
"export_email": "Als .eml exportieren", "export_email": "Als .eml exportieren",
"import_email": ".eml importieren", "import_email": ".eml oder .zip importieren",
"keyboard_shortcuts": "Tastaturkürzel (?)", "keyboard_shortcuts": "Tastaturkürzel (?)",
"email_source": "E-Mail-Quelltext", "email_source": "E-Mail-Quelltext",
"draft_banner": "Diese Nachricht ist ein Entwurf", "draft_banner": "Diese Nachricht ist ein Entwurf",
@@ -514,7 +524,17 @@
"ai_verdict": "KI-Urteil", "ai_verdict": "KI-Urteil",
"account": "Konto", "account": "Konto",
"no_subject": "(kein Betreff)" "no_subject": "(kein Betreff)"
} },
"scheduled_banner": "Geplant zum Senden um {date}",
"scheduled_send_created": "E-Mail wurde zum Senden geplant",
"cancel_scheduled_send": "Senden abbrechen",
"reschedule_send": "Neu planen",
"cancel_and_edit": "Abbrechen und bearbeiten",
"cancel_and_compose_again": "Abbrechen und neu verfassen",
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30",
"scheduled_actions_only": "Die Geplant-Ansicht unterstützt nur Planungsaktionen",
"undo_send_scheduled": "Nachricht ist zum Senden geplant",
"undo_send": "Senden rückgängig"
}, },
"email_composer": { "email_composer": {
"new_message": "Neue Nachricht", "new_message": "Neue Nachricht",
@@ -607,7 +627,19 @@
}, },
"add_link": "Link hinzufügen", "add_link": "Link hinzufügen",
"link_url_prompt": "URL eingeben", "link_url_prompt": "URL eingeben",
"sending": "Wird gesendet..." "sending": "Wird gesendet...",
"schedule_send": "Senden planen",
"schedule_send_description": "Wählen Sie, wann der Server diese Nachricht freigeben soll.",
"schedule_send_required": "Wählen Sie Datum und Uhrzeit.",
"schedule_send_invalid": "Geben Sie ein gültiges Datum und eine gültige Uhrzeit ein.",
"schedule_send_future": "Wählen Sie ein Datum und eine Uhrzeit in der Zukunft.",
"schedule_send_too_late": "Dieser Zeitpunkt liegt später, als der Server erlaubt.",
"schedule_send_unsupported": "Geplantes Senden wird für dieses Konto nicht unterstützt.",
"schedule_send_cleanup_warning": "Geplantes Senden wurde erstellt, aber das Bereinigen des Entwurfs ist fehlgeschlagen.",
"send_delay_unsupported": "Sendeverzögerung wird für dieses Konto nicht unterstützt.",
"send_delay_unsupported_confirm": "Dieses Konto unterstützt keine Sendeverzögerung. Stattdessen sofort senden?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Bestätigen", "confirm": "Bestätigen",
@@ -752,7 +784,7 @@
"keep_editing": "Weiter bearbeiten", "keep_editing": "Weiter bearbeiten",
"tabs": { "tabs": {
"appearance": "Darstellung", "appearance": "Darstellung",
"language": "Sprache & Region", "language": "Sprache, Region & Zeit",
"email": "E-Mail-Verhalten", "email": "E-Mail-Verhalten",
"composer": "Editor", "composer": "Editor",
"privacy": "Datenschutz & Sicherheit", "privacy": "Datenschutz & Sicherheit",
@@ -775,6 +807,7 @@
"layout": "Layout", "layout": "Layout",
"reading": "Lesen", "reading": "Lesen",
"composing": "Verfassen", "composing": "Verfassen",
"downloads": "Downloads",
"content_senders": "Inhalte & Absender", "content_senders": "Inhalte & Absender",
"about_data": "Über & Daten", "about_data": "Über & Daten",
"debug": "Debug" "debug": "Debug"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Gemeinsames Postfach", "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": { "colorful_sidebar_icons": {
"label": "Farbige Seitenleistensymbole", "label": "Farbige Seitenleistensymbole",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Sprache & Region", "title": "Sprache, Region & Zeit",
"description": "Konfigurieren Sie Sprach- und Regionaleinstellungen", "description": "Sprache, Datums- und Zeitformat sowie weitere regionale Einstellungen",
"language": { "language": {
"label": "Sprache", "label": "Sprache",
"description": "Wählen Sie Ihre bevorzugte Sprache", "description": "Wählen Sie Ihre bevorzugte Sprache",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "Datumsformat", "label": "Datumsformat",
"description": "Wie Daten angezeigt werden sollen", "description": "Wie Daten in der E-Mail-Liste angezeigt werden",
"regional": "Regional", "smart": "Intelligent (gebietsschemaabhängig)",
"iso": "ISO 8601", "relative": "Relativ (vor 1 Std., vor 2 Tagen)",
"custom": "Benutzerdefiniert" "full": "Immer vollständiges Datum",
"preview_today": "Heute:",
"preview_this_week": "Diese Woche:",
"preview_older": "Älter:"
}, },
"time_format": { "time_format": {
"label": "Zeitformat", "label": "Zeitformat",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Bildvorschau in Anhängen anzeigen", "label": "Bildvorschau in Anhängen anzeigen",
"description": "Bildanhänge als Miniaturansichten statt als generische Dateisymbole anzeigen" "description": "Bildanhänge als Miniaturansichten statt als generische Dateisymbole anzeigen"
},
"send_delay": {
"label": "Senden rückgängig / Sendeverzögerung",
"description": "Normales Senden serverseitig kurz verzögern.",
"off": "Aus",
"seconds": "{seconds} Sekunden",
"unsupported": "Das aktuelle Konto meldet keine Unterstützung für verzögertes Senden. Die Einstellung bleibt für andere Konten gespeichert."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen", "group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen",
"group_by_letter_label": "Nach Anfangsbuchstaben gruppieren" "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": { "filters": {
"title": "E-Mail-Filter", "title": "E-Mail-Filter",
"description": "Erstellen Sie Regeln, um eingehende E-Mails automatisch zu sortieren, zu kennzeichnen und zu verwalten", "description": "Erstellen Sie Regeln, um eingehende E-Mails automatisch zu sortieren, zu kennzeichnen und zu verwalten",
@@ -1751,7 +1842,11 @@
"color_tag": "Label", "color_tag": "Label",
"remove_color": "Label entfernen", "remove_color": "Label entfernen",
"items_selected": "{count} E-Mails ausgewählt", "items_selected": "{count} E-Mails ausgewählt",
"edit_draft": "Entwurf bearbeiten" "edit_draft": "Entwurf bearbeiten",
"cancel_scheduled_send": "Senden abbrechen",
"reschedule_send": "Neu planen",
"cancel_and_edit": "Abbrechen und bearbeiten",
"cancel_and_compose_again": "Abbrechen und neu verfassen"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Ordner als gelesen markieren", "mark_folder_read": "Ordner als gelesen markieren",
@@ -1760,7 +1855,7 @@
"new_subfolder": "Neuer Unterordner...", "new_subfolder": "Neuer Unterordner...",
"new_folder": "Neuer Ordner...", "new_folder": "Neuer Ordner...",
"rename": "Umbenennen...", "rename": "Umbenennen...",
"import_email": ".eml importieren...", "import_email": ".eml oder .zip importieren...",
"empty_folder": "Ordner leeren", "empty_folder": "Ordner leeren",
"empty_folder_generic": "Ordner leeren", "empty_folder_generic": "Ordner leeren",
"delete_folder": "Ordner löschen", "delete_folder": "Ordner löschen",
@@ -1829,6 +1924,8 @@
"expand_collapse": "Unterhaltung erweitern/einklappen" "expand_collapse": "Unterhaltung erweitern/einklappen"
}, },
"composer": { "composer": {
"send": "E-Mail senden",
"schedule_send": "Senden planen",
"template_picker": "Vorlagenauswahl öffnen" "template_picker": "Vorlagenauswahl öffnen"
} }
}, },
+111 -15
View File
@@ -132,7 +132,8 @@
"shared": "Shared", "shared": "Shared",
"mail": "Mail", "mail": "Mail",
"nav_label": "Navigation", "nav_label": "Navigation",
"add_app": "Apps" "add_app": "Apps",
"scheduled": "Scheduled"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Default apps", "title": "Default apps",
@@ -239,7 +240,16 @@
"confirm_button": "Empty folder", "confirm_button": "Empty folder",
"junk_hint": "You can empty the Junk folder to permanently remove all messages.", "junk_hint": "You can empty the Junk folder to permanently remove all messages.",
"trash_hint": "You can empty the Trash folder to permanently remove all messages." "trash_hint": "You can empty the Trash folder to permanently remove all messages."
} },
"no_scheduled_emails": "No scheduled emails",
"no_scheduled_emails_description": "Messages scheduled for later will appear here.",
"scheduled_count": "{count, plural, one {1 scheduled email} other {# scheduled emails}}",
"scheduled_actions_hint": "Scheduled messages can be canceled, rescheduled, or edited from their scheduled actions.",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
"cancel_and_compose_again": "Cancel and compose again",
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "No email selected", "no_email_selected": "No email selected",
@@ -270,7 +280,7 @@
"print": "Print", "print": "Print",
"view_source": "View source", "view_source": "View source",
"export_email": "Export as .eml", "export_email": "Export as .eml",
"import_email": "Import .eml", "import_email": "Import .eml or .zip",
"keyboard_shortcuts": "Keyboard shortcuts (?)", "keyboard_shortcuts": "Keyboard shortcuts (?)",
"email_source": "Email Source", "email_source": "Email Source",
"draft_banner": "This message is a draft", "draft_banner": "This message is a draft",
@@ -514,7 +524,17 @@
"collapse": "Hide details" "collapse": "Hide details"
}, },
"send": "Send", "send": "Send",
"more": "more" "more": "more",
"scheduled_banner": "Scheduled to send at {date}",
"scheduled_send_created": "Email scheduled for sending",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
"cancel_and_compose_again": "Cancel and compose again",
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30",
"scheduled_actions_only": "Scheduled view supports scheduled actions only",
"undo_send_scheduled": "Message scheduled for sending",
"undo_send": "Undo send"
}, },
"email_composer": { "email_composer": {
"new_message": "New Message", "new_message": "New Message",
@@ -607,7 +627,17 @@
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?", "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"send_anyway": "Send anyway", "send_anyway": "Send anyway",
"back": "Back to editing" "back": "Back to editing"
} },
"schedule_send": "Schedule send",
"schedule_send_description": "Choose when the server should release this message.",
"schedule_send_required": "Choose a date and time.",
"schedule_send_invalid": "Enter a valid date and time.",
"schedule_send_future": "Choose a future date and time.",
"schedule_send_too_late": "This time is later than the server allows.",
"schedule_send_unsupported": "Scheduled send is not supported for this account.",
"schedule_send_cleanup_warning": "Scheduled send was created, but draft cleanup failed.",
"send_delay_unsupported": "Send delay is not supported for this account.",
"send_delay_unsupported_confirm": "This account does not support send delay. Send immediately instead?"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirm", "confirm": "Confirm",
@@ -755,7 +785,7 @@
"search_no_results": "No matching settings", "search_no_results": "No matching settings",
"tabs": { "tabs": {
"appearance": "Appearance", "appearance": "Appearance",
"language": "Language & Region", "language": "Language, Region & Time",
"email": "Email Behavior", "email": "Email Behavior",
"composer": "Composer", "composer": "Composer",
"privacy": "Privacy & Security", "privacy": "Privacy & Security",
@@ -778,6 +808,7 @@
"layout": "Layout", "layout": "Layout",
"reading": "Reading", "reading": "Reading",
"composing": "Composing", "composing": "Composing",
"downloads": "Downloads",
"content_senders": "Content & Senders", "content_senders": "Content & Senders",
"about_data": "About & Data", "about_data": "About & Data",
"debug": "Debug" "debug": "Debug"
@@ -846,7 +877,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "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": { "colorful_sidebar_icons": {
"label": "Colorful Sidebar Icons", "label": "Colorful Sidebar Icons",
@@ -930,8 +965,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Language & Region", "title": "Language, Region & Time",
"description": "Configure language and regional preferences", "description": "Language, date format, time format, and other regional preferences",
"language": { "language": {
"label": "Language", "label": "Language",
"description": "Choose your preferred language", "description": "Choose your preferred language",
@@ -940,10 +975,13 @@
}, },
"date_format": { "date_format": {
"label": "Date Format", "label": "Date Format",
"description": "How dates should be displayed", "description": "How dates are shown in the email list",
"regional": "Regional", "smart": "Smart (locale-aware)",
"iso": "ISO 8601", "relative": "Relative (1h ago, 2d ago)",
"custom": "Custom" "full": "Always full date",
"preview_today": "Today:",
"preview_this_week": "This week:",
"preview_older": "Older:"
}, },
"time_format": { "time_format": {
"label": "Time Format", "label": "Time Format",
@@ -973,6 +1011,7 @@
"label": "Delete Action", "label": "Delete Action",
"description": "What happens when you delete an email", "description": "What happens when you delete an email",
"trash": "Move to Trash", "trash": "Move to Trash",
"trash_and_read": "Move to Trash and mark as read",
"permanent": "Delete Permanently", "permanent": "Delete Permanently",
"warning": "Emails will be permanently deleted and cannot be recovered. This action is irreversible." "warning": "Emails will be permanently deleted and cannot be recovered. This action is irreversible."
}, },
@@ -1129,6 +1168,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Show image previews in attachments", "label": "Show image previews in attachments",
"description": "Render image attachments as thumbnail cards instead of generic file icons" "description": "Render image attachments as thumbnail cards instead of generic file icons"
},
"send_delay": {
"label": "Undo send / send delay",
"description": "Delay normal sends by a short server-side window.",
"off": "Off",
"seconds": "{seconds} seconds",
"unsupported": "The current account does not advertise delayed-send support. The setting is still saved for other accounts."
} }
}, },
"composer": { "composer": {
@@ -1498,6 +1544,50 @@
"categories_description": "Rename contact categories", "categories_description": "Rename contact categories",
"no_categories": "No categories found" "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": { "filters": {
"title": "Email Filters", "title": "Email Filters",
"description": "Create rules to automatically sort, label, and manage incoming emails", "description": "Create rules to automatically sort, label, and manage incoming emails",
@@ -1751,7 +1841,11 @@
"color_tag": "Tag", "color_tag": "Tag",
"remove_color": "Remove tag", "remove_color": "Remove tag",
"items_selected": "{count} emails selected", "items_selected": "{count} emails selected",
"edit_draft": "Edit Draft" "edit_draft": "Edit Draft",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
"cancel_and_compose_again": "Cancel and compose again"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Mark folder as read", "mark_folder_read": "Mark folder as read",
@@ -1760,7 +1854,7 @@
"new_subfolder": "New subfolder...", "new_subfolder": "New subfolder...",
"new_folder": "New folder...", "new_folder": "New folder...",
"rename": "Rename...", "rename": "Rename...",
"import_email": "Import .eml...", "import_email": "Import .eml or .zip...",
"empty_folder": "Empty folder", "empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder", "empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder", "delete_folder": "Delete folder",
@@ -1829,6 +1923,8 @@
"expand_collapse": "Expand/collapse thread" "expand_collapse": "Expand/collapse thread"
}, },
"composer": { "composer": {
"send": "Send email",
"schedule_send": "Schedule send",
"template_picker": "Open template picker" "template_picker": "Open template picker"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "Correo", "mail": "Correo",
"nav_label": "Navegación", "nav_label": "Navegación",
"add_app": "Apps", "add_app": "Apps",
"shared": "Compartido" "shared": "Compartido",
"scheduled": "Programados"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Aplicaciones predeterminadas", "title": "Aplicaciones predeterminadas",
@@ -239,7 +240,16 @@
"confirm_button": "Vaciar carpeta", "confirm_button": "Vaciar carpeta",
"junk_hint": "Puede vaciar la carpeta de Spam para eliminar permanentemente todos los mensajes.", "junk_hint": "Puede vaciar la carpeta de Spam para eliminar permanentemente todos los mensajes.",
"trash_hint": "Puede vaciar la Papelera para eliminar permanentemente todos los mensajes." "trash_hint": "Puede vaciar la Papelera para eliminar permanentemente todos los mensajes."
} },
"no_scheduled_emails": "No hay correos programados",
"no_scheduled_emails_description": "Los mensajes programados para más tarde aparecerán aquí.",
"scheduled_count": "{count, plural, one {1 correo programado} other {# correos programados}}",
"scheduled_actions_hint": "Los mensajes programados se pueden cancelar, reprogramar o editar desde sus acciones.",
"cancel_scheduled_send": "Cancelar envío",
"reschedule_send": "Reprogramar",
"cancel_and_edit": "Cancelar y editar",
"cancel_and_compose_again": "Cancelar y redactar de nuevo",
"reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Ningún correo seleccionado", "no_email_selected": "Ningún correo seleccionado",
@@ -270,7 +280,7 @@
"print": "Imprimir", "print": "Imprimir",
"view_source": "Ver código fuente", "view_source": "Ver código fuente",
"export_email": "Exportar como .eml", "export_email": "Exportar como .eml",
"import_email": "Importar .eml", "import_email": "Importar .eml o .zip",
"keyboard_shortcuts": "Atajos de teclado (?)", "keyboard_shortcuts": "Atajos de teclado (?)",
"email_source": "Código Fuente del Correo", "email_source": "Código Fuente del Correo",
"draft_banner": "Este mensaje es un borrador", "draft_banner": "Este mensaje es un borrador",
@@ -514,7 +524,17 @@
"ai_verdict": "Veredicto de IA", "ai_verdict": "Veredicto de IA",
"account": "Cuenta", "account": "Cuenta",
"no_subject": "(sin asunto)" "no_subject": "(sin asunto)"
} },
"scheduled_banner": "Programado para enviarse el {date}",
"scheduled_send_created": "Correo programado para envío",
"cancel_scheduled_send": "Cancelar envío",
"reschedule_send": "Reprogramar",
"cancel_and_edit": "Cancelar y editar",
"cancel_and_compose_again": "Cancelar y redactar de nuevo",
"reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30",
"scheduled_actions_only": "La vista Programados solo admite acciones de programación",
"undo_send_scheduled": "Mensaje programado para envío",
"undo_send": "Deshacer envío"
}, },
"email_composer": { "email_composer": {
"new_message": "Nuevo Mensaje", "new_message": "Nuevo Mensaje",
@@ -607,7 +627,19 @@
}, },
"add_link": "Añadir enlace", "add_link": "Añadir enlace",
"link_url_prompt": "Introduce la URL", "link_url_prompt": "Introduce la URL",
"sending": "Enviando..." "sending": "Enviando...",
"schedule_send": "Programar envío",
"schedule_send_description": "Elige cuándo debe liberar el servidor este mensaje.",
"schedule_send_required": "Elige una fecha y hora.",
"schedule_send_invalid": "Introduce una fecha y hora válidas.",
"schedule_send_future": "Elige una fecha y hora futuras.",
"schedule_send_too_late": "Esta hora es posterior a lo que permite el servidor.",
"schedule_send_unsupported": "El envío programado no es compatible con esta cuenta.",
"schedule_send_cleanup_warning": "Se creó el envío programado, pero falló la limpieza del borrador.",
"send_delay_unsupported": "La demora de envío no es compatible con esta cuenta.",
"send_delay_unsupported_confirm": "Esta cuenta no admite demora de envío. ¿Enviar inmediatamente?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmar", "confirm": "Confirmar",
@@ -752,7 +784,7 @@
"keep_editing": "Seguir editando", "keep_editing": "Seguir editando",
"tabs": { "tabs": {
"appearance": "Apariencia", "appearance": "Apariencia",
"language": "Idioma y Región", "language": "Idioma, Región y Hora",
"email": "Comportamiento del Correo", "email": "Comportamiento del Correo",
"composer": "Editor", "composer": "Editor",
"privacy": "Privacidad y Seguridad", "privacy": "Privacidad y Seguridad",
@@ -775,6 +807,7 @@
"layout": "Diseño", "layout": "Diseño",
"reading": "Lectura", "reading": "Lectura",
"composing": "Redacción", "composing": "Redacción",
"downloads": "Descargas",
"content_senders": "Contenido y remitentes", "content_senders": "Contenido y remitentes",
"about_data": "Acerca de y datos", "about_data": "Acerca de y datos",
"debug": "Depuración" "debug": "Depuración"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Buzón unificado", "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": { "colorful_sidebar_icons": {
"label": "Iconos de barra lateral a color", "label": "Iconos de barra lateral a color",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Idioma y Región", "title": "Idioma, Región y Hora",
"description": "Configure las preferencias de idioma y región", "description": "Idioma, formato de fecha, formato de hora y otras preferencias regionales",
"language": { "language": {
"label": "Idioma", "label": "Idioma",
"description": "Elija su idioma preferido", "description": "Elija su idioma preferido",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "Formato de Fecha", "label": "Formato de Fecha",
"description": "Cómo se deben mostrar las fechas", "description": "Cómo se muestran las fechas en la lista de correos",
"regional": "Regional", "smart": "Inteligente (según región)",
"iso": "ISO 8601", "relative": "Relativo (hace 1 h, hace 2 d)",
"custom": "Personalizado" "full": "Fecha completa siempre",
"preview_today": "Hoy:",
"preview_this_week": "Esta semana:",
"preview_older": "Más antiguo:"
}, },
"time_format": { "time_format": {
"label": "Formato de Hora", "label": "Formato de Hora",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Mostrar vistas previas de imágenes en adjuntos", "label": "Mostrar vistas previas de imágenes en adjuntos",
"description": "Mostrar las imágenes adjuntas como tarjetas de miniatura en lugar de iconos de archivo genéricos" "description": "Mostrar las imágenes adjuntas como tarjetas de miniatura en lugar de iconos de archivo genéricos"
},
"send_delay": {
"label": "Deshacer envío / demora de envío",
"description": "Retrasa envíos normales con una breve ventana del servidor.",
"off": "Desactivado",
"seconds": "{seconds} segundos",
"unsupported": "La cuenta actual no anuncia compatibilidad con envío demorado. La opción seguirá guardada para otras cuentas."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos", "group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos",
"group_by_letter_label": "Agrupar por primera letra" "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": { "filters": {
"title": "Filtros de correo", "title": "Filtros de correo",
"description": "Cree reglas para ordenar, etiquetar y gestionar automáticamente los correos entrantes", "description": "Cree reglas para ordenar, etiquetar y gestionar automáticamente los correos entrantes",
@@ -1751,7 +1842,11 @@
"color_tag": "Etiqueta", "color_tag": "Etiqueta",
"remove_color": "Eliminar etiqueta", "remove_color": "Eliminar etiqueta",
"items_selected": "{count} correos seleccionados", "items_selected": "{count} correos seleccionados",
"edit_draft": "Editar borrador" "edit_draft": "Editar borrador",
"cancel_scheduled_send": "Cancelar envío",
"reschedule_send": "Reprogramar",
"cancel_and_edit": "Cancelar y editar",
"cancel_and_compose_again": "Cancelar y redactar de nuevo"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Marcar carpeta como leída", "mark_folder_read": "Marcar carpeta como leída",
@@ -1760,7 +1855,7 @@
"new_subfolder": "Nueva subcarpeta...", "new_subfolder": "Nueva subcarpeta...",
"new_folder": "Nueva carpeta...", "new_folder": "Nueva carpeta...",
"rename": "Renombrar...", "rename": "Renombrar...",
"import_email": "Importar .eml...", "import_email": "Importar .eml o .zip...",
"empty_folder": "Vaciar carpeta", "empty_folder": "Vaciar carpeta",
"empty_folder_generic": "Vaciar carpeta", "empty_folder_generic": "Vaciar carpeta",
"delete_folder": "Eliminar carpeta", "delete_folder": "Eliminar carpeta",
@@ -1829,6 +1924,8 @@
"expand_collapse": "Expandir/contraer conversación" "expand_collapse": "Expandir/contraer conversación"
}, },
"composer": { "composer": {
"send": "Enviar correo",
"schedule_send": "Programar envío",
"template_picker": "Abrir selector de plantillas" "template_picker": "Abrir selector de plantillas"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "Messagerie", "mail": "Messagerie",
"nav_label": "Navigation", "nav_label": "Navigation",
"add_app": "Apps", "add_app": "Apps",
"shared": "Partagé" "shared": "Partagé",
"scheduled": "Planifiés"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Applications par défaut", "title": "Applications par défaut",
@@ -239,7 +240,16 @@
"confirm_button": "Vider le dossier", "confirm_button": "Vider le dossier",
"junk_hint": "Vous pouvez vider le dossier Indésirables pour supprimer définitivement tous les messages.", "junk_hint": "Vous pouvez vider le dossier Indésirables pour supprimer définitivement tous les messages.",
"trash_hint": "Vous pouvez vider la corbeille pour supprimer définitivement tous les messages." "trash_hint": "Vous pouvez vider la corbeille pour supprimer définitivement tous les messages."
} },
"no_scheduled_emails": "Aucun e-mail planifié",
"no_scheduled_emails_description": "Les messages planifiés pour plus tard apparaîtront ici.",
"scheduled_count": "{count, plural, one {1 e-mail planifié} other {# e-mails planifiés}}",
"scheduled_actions_hint": "Les messages planifiés peuvent être annulés, replanifiés ou modifiés depuis leurs actions.",
"cancel_scheduled_send": "Annuler lenvoi",
"reschedule_send": "Replanifier",
"cancel_and_edit": "Annuler et modifier",
"cancel_and_compose_again": "Annuler et rédiger à nouveau",
"reschedule_prompt": "Saisissez une nouvelle date/heure, p. ex. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Aucun email sélectionné", "no_email_selected": "Aucun email sélectionné",
@@ -270,7 +280,7 @@
"print": "Imprimer", "print": "Imprimer",
"view_source": "Voir la source", "view_source": "Voir la source",
"export_email": "Exporter en .eml", "export_email": "Exporter en .eml",
"import_email": "Importer un .eml", "import_email": "Importer .eml ou .zip",
"keyboard_shortcuts": "Raccourcis clavier (?)", "keyboard_shortcuts": "Raccourcis clavier (?)",
"email_source": "Source de l'email", "email_source": "Source de l'email",
"draft_banner": "Ce message est un brouillon", "draft_banner": "Ce message est un brouillon",
@@ -514,7 +524,17 @@
"ai_verdict": "Verdict IA", "ai_verdict": "Verdict IA",
"account": "Compte", "account": "Compte",
"no_subject": "(sans objet)" "no_subject": "(sans objet)"
} },
"scheduled_banner": "Envoi planifié à {date}",
"scheduled_send_created": "E-mail planifié pour envoi",
"cancel_scheduled_send": "Annuler lenvoi",
"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 lenvoi"
}, },
"email_composer": { "email_composer": {
"new_message": "Nouveau message", "new_message": "Nouveau message",
@@ -607,7 +627,19 @@
}, },
"add_link": "Ajouter un lien", "add_link": "Ajouter un lien",
"link_url_prompt": "Saisissez l'URL", "link_url_prompt": "Saisissez l'URL",
"sending": "Envoi..." "sending": "Envoi...",
"schedule_send": "Planifier lenvoi",
"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": "Lenvoi planifié nest pas pris en charge pour ce compte.",
"schedule_send_cleanup_warning": "Lenvoi planifié a été créé, mais le nettoyage du brouillon a échoué.",
"send_delay_unsupported": "Le délai denvoi nest pas pris en charge pour ce compte.",
"send_delay_unsupported_confirm": "Ce compte ne prend pas en charge le délai denvoi. Envoyer immédiatement ?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmer", "confirm": "Confirmer",
@@ -752,7 +784,7 @@
"keep_editing": "Continuer l'édition", "keep_editing": "Continuer l'édition",
"tabs": { "tabs": {
"appearance": "Apparence", "appearance": "Apparence",
"language": "Langue et région", "language": "Langue, région et heure",
"email": "Comportement email", "email": "Comportement email",
"composer": "Compositeur", "composer": "Compositeur",
"privacy": "Confidentialité et sécurité", "privacy": "Confidentialité et sécurité",
@@ -775,6 +807,7 @@
"layout": "Mise en page", "layout": "Mise en page",
"reading": "Lecture", "reading": "Lecture",
"composing": "Rédaction", "composing": "Rédaction",
"downloads": "Téléchargements",
"content_senders": "Contenu et expéditeurs", "content_senders": "Contenu et expéditeurs",
"about_data": "À propos et données", "about_data": "À propos et données",
"debug": "Débogage" "debug": "Débogage"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Boîte aux lettres unifiée", "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": { "colorful_sidebar_icons": {
"label": "Icônes colorées dans la barre latérale", "label": "Icônes colorées dans la barre latérale",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Langue et région", "title": "Langue, région et heure",
"description": "Configurez vos préférences linguistiques et régionales", "description": "Langue, format de date, format d'heure et autres préférences régionales",
"language": { "language": {
"label": "Langue", "label": "Langue",
"description": "Choisissez votre langue préférée", "description": "Choisissez votre langue préférée",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "Format de date", "label": "Format de date",
"description": "Comment les dates doivent être affichées", "description": "Comment les dates apparaissent dans la liste des e-mails",
"regional": "Régional", "smart": "Intelligent (adapté à la région)",
"iso": "ISO 8601", "relative": "Relatif (il y a 1 h, il y a 2 j)",
"custom": "Personnalisé" "full": "Toujours la date complète",
"preview_today": "Aujourd'hui :",
"preview_this_week": "Cette semaine :",
"preview_older": "Plus ancien :"
}, },
"time_format": { "time_format": {
"label": "Format d'heure", "label": "Format d'heure",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Afficher les aperçus d'images dans les pièces jointes", "label": "Afficher les aperçus d'images dans les pièces jointes",
"description": "Afficher les images jointes sous forme de vignettes plutôt qu'avec des icônes de fichier génériques" "description": "Afficher les images jointes sous forme de vignettes plutôt qu'avec des icônes de fichier génériques"
},
"send_delay": {
"label": "Annuler lenvoi / délai denvoi",
"description": "Retarde les envois normaux avec une courte fenêtre côté serveur.",
"off": "Désactivé",
"seconds": "{seconds} secondes",
"unsupported": "Le compte actuel nannonce pas la prise en charge de lenvoi différé. Le réglage reste enregistré pour dautres comptes."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts", "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" "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 despaces, de tirets bas ou de tirets en un seul caractère."
},
"after_export": {
"label": "Après lexport",
"description": "Déplacer éventuellement le-mail après lavoir exporté en .eml.",
"keep": "Garder dans la boîte",
"archive": "Déplacer vers les archives",
"trash": "Déplacer vers la corbeille"
}
},
"filters": { "filters": {
"title": "Filtres de courrier", "title": "Filtres de courrier",
"description": "Créez des règles pour trier, étiqueter et gérer automatiquement les courriers entrants", "description": "Créez des règles pour trier, étiqueter et gérer automatiquement les courriers entrants",
@@ -1751,7 +1842,11 @@
"color_tag": "Étiquette", "color_tag": "Étiquette",
"remove_color": "Supprimer l'étiquette", "remove_color": "Supprimer l'étiquette",
"items_selected": "{count} emails sélectionnés", "items_selected": "{count} emails sélectionnés",
"edit_draft": "Modifier le brouillon" "edit_draft": "Modifier le brouillon",
"cancel_scheduled_send": "Annuler lenvoi",
"reschedule_send": "Replanifier",
"cancel_and_edit": "Annuler et modifier",
"cancel_and_compose_again": "Annuler et rédiger à nouveau"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Marquer le dossier comme lu", "mark_folder_read": "Marquer le dossier comme lu",
@@ -1760,7 +1855,7 @@
"new_subfolder": "Nouveau sous-dossier...", "new_subfolder": "Nouveau sous-dossier...",
"new_folder": "Nouveau dossier...", "new_folder": "Nouveau dossier...",
"rename": "Renommer...", "rename": "Renommer...",
"import_email": "Importer un .eml...", "import_email": "Importer .eml ou .zip...",
"empty_folder": "Vider le dossier", "empty_folder": "Vider le dossier",
"empty_folder_generic": "Vider le dossier", "empty_folder_generic": "Vider le dossier",
"delete_folder": "Supprimer le dossier", "delete_folder": "Supprimer le dossier",
@@ -1829,6 +1924,8 @@
"expand_collapse": "Développer/réduire la conversation" "expand_collapse": "Développer/réduire la conversation"
}, },
"composer": { "composer": {
"send": "Envoyer l'e-mail",
"schedule_send": "Planifier l'envoi",
"template_picker": "Ouvrir le sélecteur de modèles" "template_picker": "Ouvrir le sélecteur de modèles"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "Posta", "mail": "Posta",
"nav_label": "Navigazione", "nav_label": "Navigazione",
"add_app": "App", "add_app": "App",
"shared": "Condiviso" "shared": "Condiviso",
"scheduled": "Programmate"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "App predefinite", "title": "App predefinite",
@@ -239,7 +240,16 @@
"confirm_button": "Svuota cartella", "confirm_button": "Svuota cartella",
"junk_hint": "Puoi svuotare la cartella Spam per rimuovere definitivamente tutti i messaggi.", "junk_hint": "Puoi svuotare la cartella Spam per rimuovere definitivamente tutti i messaggi.",
"trash_hint": "Puoi svuotare il Cestino per rimuovere definitivamente tutti i messaggi." "trash_hint": "Puoi svuotare il Cestino per rimuovere definitivamente tutti i messaggi."
} },
"no_scheduled_emails": "Nessuna e-mail programmata",
"no_scheduled_emails_description": "I messaggi programmati per più tardi appariranno qui.",
"scheduled_count": "{count, plural, one {1 e-mail programmata} other {# e-mail programmate}}",
"scheduled_actions_hint": "I messaggi programmati possono essere annullati, riprogrammati o modificati dalle azioni di programmazione.",
"cancel_scheduled_send": "Annulla invio",
"reschedule_send": "Riprogramma",
"cancel_and_edit": "Annulla e modifica",
"cancel_and_compose_again": "Annulla e componi di nuovo",
"reschedule_prompt": "Inserisci una nuova data/ora, ad es. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Nessun messaggio selezionato", "no_email_selected": "Nessun messaggio selezionato",
@@ -270,7 +280,7 @@
"print": "Stampa", "print": "Stampa",
"view_source": "Visualizza sorgente", "view_source": "Visualizza sorgente",
"export_email": "Esporta come .eml", "export_email": "Esporta come .eml",
"import_email": "Importa .eml", "import_email": "Importa .eml o .zip",
"keyboard_shortcuts": "Scorciatoie da tastiera (?)", "keyboard_shortcuts": "Scorciatoie da tastiera (?)",
"email_source": "Sorgente del messaggio", "email_source": "Sorgente del messaggio",
"draft_banner": "Questo messaggio è una bozza", "draft_banner": "Questo messaggio è una bozza",
@@ -514,7 +524,17 @@
"ai_verdict": "Verdetto IA", "ai_verdict": "Verdetto IA",
"account": "Account", "account": "Account",
"no_subject": "(nessun oggetto)" "no_subject": "(nessun oggetto)"
} },
"scheduled_banner": "Programmato per linvio il {date}",
"scheduled_send_created": "E-mail programmata per linvio",
"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 linvio",
"undo_send": "Annulla invio"
}, },
"email_composer": { "email_composer": {
"new_message": "Nuovo messaggio", "new_message": "Nuovo messaggio",
@@ -607,7 +627,19 @@
}, },
"add_link": "Aggiungi link", "add_link": "Aggiungi link",
"link_url_prompt": "Inserisci l'URL", "link_url_prompt": "Inserisci l'URL",
"sending": "Invio in corso..." "sending": "Invio in corso...",
"schedule_send": "Programma invio",
"schedule_send_description": "Scegli quando il server deve rilasciare questo messaggio.",
"schedule_send_required": "Scegli una data e unora.",
"schedule_send_invalid": "Inserisci una data e unora valide.",
"schedule_send_future": "Scegli una data e unora future.",
"schedule_send_too_late": "Questo orario supera il limite consentito dal server.",
"schedule_send_unsupported": "Linvio programmato non è supportato per questo account.",
"schedule_send_cleanup_warning": "Linvio programmato è stato creato, ma la pulizia della bozza non è riuscita.",
"send_delay_unsupported": "Il ritardo di invio non è supportato per questo account.",
"send_delay_unsupported_confirm": "Questo account non supporta il ritardo di invio. Inviare subito?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Conferma", "confirm": "Conferma",
@@ -752,7 +784,7 @@
"keep_editing": "Continua a modificare", "keep_editing": "Continua a modificare",
"tabs": { "tabs": {
"appearance": "Aspetto", "appearance": "Aspetto",
"language": "Lingua e regione", "language": "Lingua, regione e ora",
"email": "Comportamento email", "email": "Comportamento email",
"composer": "Editor", "composer": "Editor",
"privacy": "Privacy e sicurezza", "privacy": "Privacy e sicurezza",
@@ -775,6 +807,7 @@
"layout": "Layout", "layout": "Layout",
"reading": "Lettura", "reading": "Lettura",
"composing": "Composizione", "composing": "Composizione",
"downloads": "Download",
"content_senders": "Contenuto e mittenti", "content_senders": "Contenuto e mittenti",
"about_data": "Informazioni e dati", "about_data": "Informazioni e dati",
"debug": "Debug" "debug": "Debug"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Casella di posta unificata", "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": { "colorful_sidebar_icons": {
"label": "Icone colorate nella barra laterale", "label": "Icone colorate nella barra laterale",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Lingua e regione", "title": "Lingua, regione e ora",
"description": "Configura le preferenze di lingua e regionali", "description": "Lingua, formato data, formato ora e altre preferenze regionali",
"language": { "language": {
"label": "Lingua", "label": "Lingua",
"description": "Scegli la tua lingua preferita", "description": "Scegli la tua lingua preferita",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "Formato data", "label": "Formato data",
"description": "Come devono essere visualizzate le date", "description": "Come vengono visualizzate le date nell'elenco delle e-mail",
"regional": "Regionale", "smart": "Intelligente (in base alla regione)",
"iso": "ISO 8601", "relative": "Relativo (1 h fa, 2 g fa)",
"custom": "Personalizzato" "full": "Sempre data completa",
"preview_today": "Oggi:",
"preview_this_week": "Questa settimana:",
"preview_older": "Più vecchio:"
}, },
"time_format": { "time_format": {
"label": "Formato ora", "label": "Formato ora",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Mostra anteprime delle immagini negli allegati", "label": "Mostra anteprime delle immagini negli allegati",
"description": "Mostra gli allegati immagine come miniature anziché con icone di file generiche" "description": "Mostra gli allegati immagine come miniature anziché con icone di file generiche"
},
"send_delay": {
"label": "Annulla invio / ritardo di invio",
"description": "Ritarda gli invii normali con una breve finestra lato server.",
"off": "Disattivato",
"seconds": "{seconds} secondi",
"unsupported": "Laccount corrente non dichiara il supporto allinvio ritardato. Limpostazione resta salvata per altri account."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti", "group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti",
"group_by_letter_label": "Raggruppa per prima lettera" "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 dellallegato",
"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 lesportazione",
"description": "Sposta facoltativamente lemail dopo averla esportata come .eml.",
"keep": "Mantieni nella casella",
"archive": "Sposta nellarchivio",
"trash": "Sposta nel cestino"
}
},
"filters": { "filters": {
"title": "Filtri email", "title": "Filtri email",
"description": "Crea regole per ordinare, etichettare e gestire automaticamente le email in arrivo", "description": "Crea regole per ordinare, etichettare e gestire automaticamente le email in arrivo",
@@ -1751,7 +1842,11 @@
"color_tag": "Etichetta", "color_tag": "Etichetta",
"remove_color": "Rimuovi etichetta", "remove_color": "Rimuovi etichetta",
"items_selected": "{count} messaggi selezionati", "items_selected": "{count} messaggi selezionati",
"edit_draft": "Modifica bozza" "edit_draft": "Modifica bozza",
"cancel_scheduled_send": "Annulla invio",
"reschedule_send": "Riprogramma",
"cancel_and_edit": "Annulla e modifica",
"cancel_and_compose_again": "Annulla e componi di nuovo"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Segna cartella come letta", "mark_folder_read": "Segna cartella come letta",
@@ -1760,7 +1855,7 @@
"new_subfolder": "Nuova sottocartella...", "new_subfolder": "Nuova sottocartella...",
"new_folder": "Nuova cartella...", "new_folder": "Nuova cartella...",
"rename": "Rinomina...", "rename": "Rinomina...",
"import_email": "Importa .eml...", "import_email": "Importa .eml o .zip...",
"empty_folder": "Svuota cartella", "empty_folder": "Svuota cartella",
"empty_folder_generic": "Svuota cartella", "empty_folder_generic": "Svuota cartella",
"delete_folder": "Elimina cartella", "delete_folder": "Elimina cartella",
@@ -1829,6 +1924,8 @@
"expand_collapse": "Espandi/comprimi conversazione" "expand_collapse": "Espandi/comprimi conversazione"
}, },
"composer": { "composer": {
"send": "Invia email",
"schedule_send": "Programma invio",
"template_picker": "Apri selettore modelli" "template_picker": "Apri selettore modelli"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "メール", "mail": "メール",
"nav_label": "ナビゲーション", "nav_label": "ナビゲーション",
"add_app": "アプリ", "add_app": "アプリ",
"shared": "共有" "shared": "共有",
"scheduled": "予約済み"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "既定のアプリ", "title": "既定のアプリ",
@@ -239,7 +240,16 @@
"confirm_button": "フォルダを空にする", "confirm_button": "フォルダを空にする",
"junk_hint": "迷惑メールフォルダを空にして、すべてのメールを完全に削除できます。", "junk_hint": "迷惑メールフォルダを空にして、すべてのメールを完全に削除できます。",
"trash_hint": "ゴミ箱を空にして、すべてのメールを完全に削除できます。" "trash_hint": "ゴミ箱を空にして、すべてのメールを完全に削除できます。"
} },
"no_scheduled_emails": "予約送信はありません",
"no_scheduled_emails_description": "後で送信するよう予約したメッセージがここに表示されます。",
"scheduled_count": "{count, plural, one {1 件の予約送信} other {# 件の予約送信}}",
"scheduled_actions_hint": "予約メッセージは予約アクションからキャンセル、再予約、編集できます。",
"cancel_scheduled_send": "送信をキャンセル",
"reschedule_send": "再予約",
"cancel_and_edit": "キャンセルして編集",
"cancel_and_compose_again": "キャンセルして新規作成",
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "メールが選択されていません", "no_email_selected": "メールが選択されていません",
@@ -270,7 +280,7 @@
"print": "印刷", "print": "印刷",
"view_source": "ソースを表示", "view_source": "ソースを表示",
"export_email": ".emlとしてエクスポート", "export_email": ".emlとしてエクスポート",
"import_email": ".emlをインポート", "import_email": ".eml または .zip をインポート",
"keyboard_shortcuts": "キーボードショートカット (?)", "keyboard_shortcuts": "キーボードショートカット (?)",
"email_source": "メールソース", "email_source": "メールソース",
"draft_banner": "このメッセージは下書きです", "draft_banner": "このメッセージは下書きです",
@@ -514,7 +524,17 @@
"ai_verdict": "AIの判定", "ai_verdict": "AIの判定",
"account": "アカウント", "account": "アカウント",
"no_subject": "(件名なし)" "no_subject": "(件名なし)"
} },
"scheduled_banner": "{date} に送信予定",
"scheduled_send_created": "メールを送信予約しました",
"cancel_scheduled_send": "送信をキャンセル",
"reschedule_send": "再予約",
"cancel_and_edit": "キャンセルして編集",
"cancel_and_compose_again": "キャンセルして新規作成",
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30",
"scheduled_actions_only": "予約済みビューでは予約アクションのみ利用できます",
"undo_send_scheduled": "メッセージは送信予約されています",
"undo_send": "送信を取り消す"
}, },
"email_composer": { "email_composer": {
"new_message": "新規メッセージ", "new_message": "新規メッセージ",
@@ -607,7 +627,19 @@
}, },
"add_link": "リンクを追加", "add_link": "リンクを追加",
"link_url_prompt": "URLを入力してください", "link_url_prompt": "URLを入力してください",
"sending": "送信中..." "sending": "送信中...",
"schedule_send": "送信を予約",
"schedule_send_description": "サーバーがこのメッセージを送信する日時を選択します。",
"schedule_send_required": "日時を選択してください。",
"schedule_send_invalid": "有効な日時を入力してください。",
"schedule_send_future": "未来の日時を選択してください。",
"schedule_send_too_late": "この日時はサーバーが許可する範囲を超えています。",
"schedule_send_unsupported": "このアカウントでは予約送信はサポートされていません。",
"schedule_send_cleanup_warning": "予約送信は作成されましたが、下書きのクリーンアップに失敗しました。",
"send_delay_unsupported": "このアカウントでは送信遅延はサポートされていません。",
"send_delay_unsupported_confirm": "このアカウントは送信遅延に対応していません。すぐに送信しますか?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "確認", "confirm": "確認",
@@ -752,7 +784,7 @@
"keep_editing": "編集を続ける", "keep_editing": "編集を続ける",
"tabs": { "tabs": {
"appearance": "外観", "appearance": "外観",
"language": "言語地域", "language": "言語地域、時刻",
"email": "メール動作", "email": "メール動作",
"composer": "作成", "composer": "作成",
"privacy": "プライバシーとセキュリティ", "privacy": "プライバシーとセキュリティ",
@@ -775,6 +807,7 @@
"layout": "レイアウト", "layout": "レイアウト",
"reading": "閲覧", "reading": "閲覧",
"composing": "作成", "composing": "作成",
"downloads": "ダウンロード",
"content_senders": "コンテンツと送信者", "content_senders": "コンテンツと送信者",
"about_data": "情報とデータ", "about_data": "情報とデータ",
"debug": "デバッグ" "debug": "デバッグ"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "統合メールボックス", "label": "統合メールボックス",
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示" "description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示",
"include_group": {
"label": "グループ受信トレイを含める",
"description": "共有/グループ受信トレイも統合ビューに含めます。"
}
}, },
"colorful_sidebar_icons": { "colorful_sidebar_icons": {
"label": "カラフルなサイドバーアイコン", "label": "カラフルなサイドバーアイコン",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "言語地域", "title": "言語地域、時刻",
"description": "言語地域設定を構成", "description": "言語、日付形式、時刻形式、その他の地域設定",
"language": { "language": {
"label": "言語", "label": "言語",
"description": "お好みの言語を選択", "description": "お好みの言語を選択",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "日付形式", "label": "日付形式",
"description": "日付の表示形式", "description": "メール一覧での日付の表示方法",
"regional": "地域設定", "smart": "スマート(地域に合わせる)",
"iso": "ISO 8601", "relative": "相対表示(1時間前、2日前)",
"custom": "カスタム" "full": "常に完全な日付",
"preview_today": "今日:",
"preview_this_week": "今週:",
"preview_older": "それ以前:"
}, },
"time_format": { "time_format": {
"label": "時刻形式", "label": "時刻形式",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "添付ファイル内の画像プレビューを表示", "label": "添付ファイル内の画像プレビューを表示",
"description": "画像の添付ファイルを汎用ファイルアイコンではなくサムネイルカードとして表示します" "description": "画像の添付ファイルを汎用ファイルアイコンではなくサムネイルカードとして表示します"
},
"send_delay": {
"label": "送信取り消し / 送信遅延",
"description": "通常の送信をサーバー側で短時間遅延します。",
"off": "オフ",
"seconds": "{seconds} 秒",
"unsupported": "現在のアカウントは遅延送信のサポートを通知していません。この設定は他のアカウント用に保存されます。"
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示", "group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示",
"group_by_letter_label": "頭文字でグループ化" "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": { "filters": {
"title": "メールフィルター", "title": "メールフィルター",
"description": "受信メールを自動で振り分け、ラベル付け、管理するルールを作成します", "description": "受信メールを自動で振り分け、ラベル付け、管理するルールを作成します",
@@ -1751,7 +1842,11 @@
"color_tag": "ラベル", "color_tag": "ラベル",
"remove_color": "ラベルを削除", "remove_color": "ラベルを削除",
"items_selected": "{count}件のメールを選択", "items_selected": "{count}件のメールを選択",
"edit_draft": "下書きを編集" "edit_draft": "下書きを編集",
"cancel_scheduled_send": "送信をキャンセル",
"reschedule_send": "再予約",
"cancel_and_edit": "キャンセルして編集",
"cancel_and_compose_again": "キャンセルして新規作成"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "フォルダーを既読にする", "mark_folder_read": "フォルダーを既読にする",
@@ -1760,7 +1855,7 @@
"new_subfolder": "新しいサブフォルダー...", "new_subfolder": "新しいサブフォルダー...",
"new_folder": "新しいフォルダー...", "new_folder": "新しいフォルダー...",
"rename": "名前を変更...", "rename": "名前を変更...",
"import_email": ".eml をインポート...", "import_email": ".eml または .zip をインポート...",
"empty_folder": "フォルダーを空にする", "empty_folder": "フォルダーを空にする",
"empty_folder_generic": "フォルダーを空にする", "empty_folder_generic": "フォルダーを空にする",
"delete_folder": "フォルダーを削除", "delete_folder": "フォルダーを削除",
@@ -1829,6 +1924,8 @@
"expand_collapse": "スレッドの展開/折りたたみ" "expand_collapse": "スレッドの展開/折りたたみ"
}, },
"composer": { "composer": {
"send": "メールを送信",
"schedule_send": "送信を予約",
"template_picker": "テンプレートピッカーを開く" "template_picker": "テンプレートピッカーを開く"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "메일", "mail": "메일",
"nav_label": "내비게이션", "nav_label": "내비게이션",
"add_app": "앱", "add_app": "앱",
"shared": "공유됨" "shared": "공유됨",
"scheduled": "예약됨"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "기본 앱", "title": "기본 앱",
@@ -239,7 +240,16 @@
"confirm_button": "폴더 비우기", "confirm_button": "폴더 비우기",
"junk_hint": "스팸함을 비워 모든 메시지를 영구적으로 삭제할 수 있어요.", "junk_hint": "스팸함을 비워 모든 메시지를 영구적으로 삭제할 수 있어요.",
"trash_hint": "휴지통을 비워 모든 메시지를 영구적으로 삭제할 수 있어요." "trash_hint": "휴지통을 비워 모든 메시지를 영구적으로 삭제할 수 있어요."
} },
"no_scheduled_emails": "예약된 메일 없음",
"no_scheduled_emails_description": "나중에 보내도록 예약한 메시지가 여기에 표시됩니다.",
"scheduled_count": "{count, plural, one {예약된 메일 1개} other {예약된 메일 #개}}",
"scheduled_actions_hint": "예약된 메시지는 예약 작업에서 취소, 재예약 또는 편집할 수 있습니다.",
"cancel_scheduled_send": "보내기 취소",
"reschedule_send": "다시 예약",
"cancel_and_edit": "취소하고 편집",
"cancel_and_compose_again": "취소하고 다시 작성",
"reschedule_prompt": "새 날짜/시간을 입력하세요. 예: 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "메일이 선택되지 않았어요", "no_email_selected": "메일이 선택되지 않았어요",
@@ -270,7 +280,7 @@
"print": "인쇄", "print": "인쇄",
"view_source": "원본 보기", "view_source": "원본 보기",
"export_email": ".eml 파일로 내보내기", "export_email": ".eml 파일로 내보내기",
"import_email": ".eml 파일 가져오기", "import_email": ".eml 또는 .zip 가져오기",
"keyboard_shortcuts": "단축키 (?)", "keyboard_shortcuts": "단축키 (?)",
"email_source": "메일 원본", "email_source": "메일 원본",
"draft_banner": "작성 중인 임시보관 메일이에요", "draft_banner": "작성 중인 임시보관 메일이에요",
@@ -514,7 +524,17 @@
"ai_verdict": "AI 판정", "ai_verdict": "AI 판정",
"account": "계정", "account": "계정",
"no_subject": "(제목 없음)" "no_subject": "(제목 없음)"
} },
"scheduled_banner": "{date}에 보내도록 예약됨",
"scheduled_send_created": "메일 보내기가 예약되었습니다",
"cancel_scheduled_send": "보내기 취소",
"reschedule_send": "다시 예약",
"cancel_and_edit": "취소하고 편집",
"cancel_and_compose_again": "취소하고 다시 작성",
"reschedule_prompt": "새 날짜/시간을 입력하세요. 예: 2026-05-04T15:30",
"scheduled_actions_only": "예약됨 보기에서는 예약 작업만 사용할 수 있습니다",
"undo_send_scheduled": "메시지가 보내기로 예약되었습니다",
"undo_send": "보내기 실행 취소"
}, },
"email_composer": { "email_composer": {
"new_message": "새 메시지", "new_message": "새 메시지",
@@ -607,7 +627,19 @@
}, },
"add_link": "링크 추가", "add_link": "링크 추가",
"link_url_prompt": "URL을 입력하세요", "link_url_prompt": "URL을 입력하세요",
"sending": "전송 중..." "sending": "전송 중...",
"schedule_send": "보내기 예약",
"schedule_send_description": "서버가 이 메시지를 보낼 시간을 선택하세요.",
"schedule_send_required": "날짜와 시간을 선택하세요.",
"schedule_send_invalid": "유효한 날짜와 시간을 입력하세요.",
"schedule_send_future": "미래의 날짜와 시간을 선택하세요.",
"schedule_send_too_late": "이 시간은 서버가 허용하는 범위를 벗어납니다.",
"schedule_send_unsupported": "이 계정은 예약 보내기를 지원하지 않습니다.",
"schedule_send_cleanup_warning": "예약 보내기는 생성되었지만 초안 정리에 실패했습니다.",
"send_delay_unsupported": "이 계정은 보내기 지연을 지원하지 않습니다.",
"send_delay_unsupported_confirm": "이 계정은 보내기 지연을 지원하지 않습니다. 바로 보낼까요?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "확인", "confirm": "확인",
@@ -752,7 +784,7 @@
"keep_editing": "계속 수정하기", "keep_editing": "계속 수정하기",
"tabs": { "tabs": {
"appearance": "화면 설정", "appearance": "화면 설정",
"language": "언어 지역", "language": "언어, 지역 및 시간",
"email": "메일 동작", "email": "메일 동작",
"composer": "메일 쓰기", "composer": "메일 쓰기",
"privacy": "개인정보 및 보안", "privacy": "개인정보 및 보안",
@@ -775,6 +807,7 @@
"layout": "레이아웃", "layout": "레이아웃",
"reading": "읽기", "reading": "읽기",
"composing": "작성", "composing": "작성",
"downloads": "다운로드",
"content_senders": "콘텐츠 및 발신자", "content_senders": "콘텐츠 및 발신자",
"about_data": "정보 및 데이터", "about_data": "정보 및 데이터",
"debug": "디버그" "debug": "디버그"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "통합 메일함", "label": "통합 메일함",
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다" "description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다",
"include_group": {
"label": "그룹 받은편지함 포함",
"description": "공유/그룹 받은편지함도 통합 보기에 포함합니다."
}
}, },
"colorful_sidebar_icons": { "colorful_sidebar_icons": {
"label": "컬러풀한 사이드바 아이콘", "label": "컬러풀한 사이드바 아이콘",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "언어 지역", "title": "언어, 지역 및 시간",
"description": "언어와 지역 형식을 설정해 주세요", "description": "언어, 날짜 형식, 시간 형식 및 기타 지역 환경설정",
"language": { "language": {
"label": "언어", "label": "언어",
"description": "사용할 언어를 선택해 주세요", "description": "사용할 언어를 선택해 주세요",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "날짜 형식", "label": "날짜 형식",
"description": "날짜가 표시되는 방식을 설정해요", "description": "이메일 목록에 날짜가 표시되는 방식",
"regional": "지역 설정", "smart": "스마트 (지역에 맞춤)",
"iso": "ISO 8601", "relative": "상대 시간 (1시간 전, 2일 전)",
"custom": "사용자 지정" "full": "항상 전체 날짜",
"preview_today": "오늘:",
"preview_this_week": "이번 주:",
"preview_older": "이전:"
}, },
"time_format": { "time_format": {
"label": "시간 형식", "label": "시간 형식",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "첨부 파일에 이미지 미리보기 표시", "label": "첨부 파일에 이미지 미리보기 표시",
"description": "이미지 첨부 파일을 일반 파일 아이콘 대신 썸네일 카드로 표시합니다" "description": "이미지 첨부 파일을 일반 파일 아이콘 대신 썸네일 카드로 표시합니다"
},
"send_delay": {
"label": "보내기 취소 / 보내기 지연",
"description": "일반 보내기를 서버 측 짧은 시간 동안 지연합니다.",
"off": "끔",
"seconds": "{seconds}초",
"unsupported": "현재 계정은 지연 보내기 지원을 알리지 않습니다. 설정은 다른 계정을 위해 계속 저장됩니다."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시", "group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시",
"group_by_letter_label": "첫 글자로 그룹화" "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": { "filters": {
"title": "이메일 필터", "title": "이메일 필터",
"description": "새로 온 메일을 자동으로 분류하고 라벨을 붙이는 규칙을 만들어 보세요", "description": "새로 온 메일을 자동으로 분류하고 라벨을 붙이는 규칙을 만들어 보세요",
@@ -1751,7 +1842,11 @@
"color_tag": "태그", "color_tag": "태그",
"remove_color": "태그 제거", "remove_color": "태그 제거",
"items_selected": "{count}개의 메일 선택됨", "items_selected": "{count}개의 메일 선택됨",
"edit_draft": "임시보관 메일 수정" "edit_draft": "임시보관 메일 수정",
"cancel_scheduled_send": "보내기 취소",
"reschedule_send": "다시 예약",
"cancel_and_edit": "취소하고 편집",
"cancel_and_compose_again": "취소하고 다시 작성"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "폴더를 읽음으로 표시", "mark_folder_read": "폴더를 읽음으로 표시",
@@ -1760,7 +1855,7 @@
"new_subfolder": "새 하위 폴더...", "new_subfolder": "새 하위 폴더...",
"new_folder": "새 폴더...", "new_folder": "새 폴더...",
"rename": "이름 바꾸기...", "rename": "이름 바꾸기...",
"import_email": ".eml 가져오기...", "import_email": ".eml 또는 .zip 가져오기...",
"empty_folder": "폴더 비우기", "empty_folder": "폴더 비우기",
"empty_folder_generic": "폴더 비우기", "empty_folder_generic": "폴더 비우기",
"delete_folder": "폴더 삭제", "delete_folder": "폴더 삭제",
@@ -1829,6 +1924,8 @@
"expand_collapse": "대화 펼치기/접기" "expand_collapse": "대화 펼치기/접기"
}, },
"composer": { "composer": {
"send": "이메일 보내기",
"schedule_send": "보내기 예약",
"template_picker": "템플릿 선택기 열기" "template_picker": "템플릿 선택기 열기"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "Pasts", "mail": "Pasts",
"nav_label": "Navigācija", "nav_label": "Navigācija",
"add_app": "Lietotnes", "add_app": "Lietotnes",
"shared": "Koplietots" "shared": "Koplietots",
"scheduled": "Ieplānots"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Noklusējuma lietotnes", "title": "Noklusējuma lietotnes",
@@ -239,7 +240,16 @@
"confirm_button": "Iztīrīt mapi", "confirm_button": "Iztīrīt mapi",
"junk_hint": "Jūs varat iztīrīt mapi Mēstules, lai neatgriezeniski dzēstu visus ziņojumus.", "junk_hint": "Jūs varat iztīrīt mapi Mēstules, lai neatgriezeniski dzēstu visus ziņojumus.",
"trash_hint": "Jūs varat iztīrīt Atkritni, lai neatgriezeniski dzēstu visus ziņojumus." "trash_hint": "Jūs varat iztīrīt Atkritni, lai neatgriezeniski dzēstu visus ziņojumus."
} },
"no_scheduled_emails": "Nav ieplānotu e-pastu",
"no_scheduled_emails_description": "Ziņojumi, kas ieplānoti vēlākai nosūtīšanai, būs redzami šeit.",
"scheduled_count": "{count, plural, one {1 ieplānots e-pasts} other {# ieplānoti e-pasti}}",
"scheduled_actions_hint": "Ieplānotos ziņojumus var atcelt, pārplānot vai rediģēt no ieplānotajām darbībām.",
"cancel_scheduled_send": "Atcelt sūtīšanu",
"reschedule_send": "Pārplānot",
"cancel_and_edit": "Atcelt un rediģēt",
"cancel_and_compose_again": "Atcelt un rakstīt no jauna",
"reschedule_prompt": "Ievadiet jaunu datumu/laiku, piem. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Nav atlasīta neviena vēstule", "no_email_selected": "Nav atlasīta neviena vēstule",
@@ -270,7 +280,7 @@
"print": "Drukāt", "print": "Drukāt",
"view_source": "Skatīt avota kodu", "view_source": "Skatīt avota kodu",
"export_email": "Eksportēt kā .eml", "export_email": "Eksportēt kā .eml",
"import_email": "Importēt .eml", "import_email": "Importēt .eml vai .zip",
"keyboard_shortcuts": "Īsinājumtaustiņi (?)", "keyboard_shortcuts": "Īsinājumtaustiņi (?)",
"email_source": "Vēstules avota kods", "email_source": "Vēstules avota kods",
"draft_banner": "Šī vēstule ir melnraksts", "draft_banner": "Šī vēstule ir melnraksts",
@@ -514,7 +524,17 @@
"ai_verdict": "AI vērtējums", "ai_verdict": "AI vērtējums",
"account": "Konts", "account": "Konts",
"no_subject": "(bez temata)" "no_subject": "(bez temata)"
} },
"scheduled_banner": "Ieplānots nosūtīšanai {date}",
"scheduled_send_created": "E-pasts ieplānots nosūtīšanai",
"cancel_scheduled_send": "Atcelt sūtīšanu",
"reschedule_send": "Pārplānot",
"cancel_and_edit": "Atcelt un rediģēt",
"cancel_and_compose_again": "Atcelt un rakstīt no jauna",
"reschedule_prompt": "Ievadiet jaunu datumu/laiku, piem. 2026-05-04T15:30",
"scheduled_actions_only": "Ieplānoto skatā ir pieejamas tikai ieplānošanas darbības",
"undo_send_scheduled": "Ziņojums ir ieplānots nosūtīšanai",
"undo_send": "Atsaukt sūtīšanu"
}, },
"email_composer": { "email_composer": {
"new_message": "Jauns ziņojums", "new_message": "Jauns ziņojums",
@@ -607,7 +627,19 @@
}, },
"add_link": "Pievienot saiti", "add_link": "Pievienot saiti",
"link_url_prompt": "Ievadiet URL", "link_url_prompt": "Ievadiet URL",
"sending": "Sūta..." "sending": "Sūta...",
"schedule_send": "Ieplānot sūtīšanu",
"schedule_send_description": "Izvēlieties, kad serverim jānosūta šis ziņojums.",
"schedule_send_required": "Izvēlieties datumu un laiku.",
"schedule_send_invalid": "Ievadiet derīgu datumu un laiku.",
"schedule_send_future": "Izvēlieties datumu un laiku nākotnē.",
"schedule_send_too_late": "Šis laiks ir vēlāk, nekā serveris atļauj.",
"schedule_send_unsupported": "Šim kontam ieplānota sūtīšana netiek atbalstīta.",
"schedule_send_cleanup_warning": "Ieplānotā sūtīšana tika izveidota, bet melnraksta tīrīšana neizdevās.",
"send_delay_unsupported": "Šim kontam sūtīšanas aizture netiek atbalstīta.",
"send_delay_unsupported_confirm": "Šis konts neatbalsta sūtīšanas aizturi. Sūtīt uzreiz?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Apstiprināt", "confirm": "Apstiprināt",
@@ -752,7 +784,7 @@
"keep_editing": "Turpināt rediģēšanu", "keep_editing": "Turpināt rediģēšanu",
"tabs": { "tabs": {
"appearance": "Izskats", "appearance": "Izskats",
"language": "Valoda un reģions", "language": "Valoda, reģions un laiks",
"email": "Pasta darbība", "email": "Pasta darbība",
"composer": "Redaktors", "composer": "Redaktors",
"privacy": "Privātums un drošība", "privacy": "Privātums un drošība",
@@ -775,6 +807,7 @@
"layout": "Izkārtojums", "layout": "Izkārtojums",
"reading": "Lasīšana", "reading": "Lasīšana",
"composing": "Rakstīšana", "composing": "Rakstīšana",
"downloads": "Lejupielādes",
"content_senders": "Saturs un sūtītāji", "content_senders": "Saturs un sūtītāji",
"about_data": "Par un dati", "about_data": "Par un dati",
"debug": "Atkļūdošana" "debug": "Atkļūdošana"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Apvienotā pastkaste", "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": { "colorful_sidebar_icons": {
"label": "Krāsainas sānjoslas ikonas", "label": "Krāsainas sānjoslas ikonas",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Valoda un reģions", "title": "Valoda, reģions un laiks",
"description": "Iestatiet valodas un reģionālās preferences", "description": "Valoda, datuma formāts, laika formāts un citas reģionālās preferences",
"language": { "language": {
"label": "Valoda", "label": "Valoda",
"description": "Izvēlieties vēlamo valodu", "description": "Izvēlieties vēlamo valodu",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "Datuma formāts", "label": "Datuma formāts",
"description": "Kā attēlot datumus", "description": "Kā datumi tiek attēloti e-pasta sarakstā",
"regional": "Reģionālais", "smart": "Gudrs (atbilstoši reģionam)",
"iso": "ISO 8601", "relative": "Relatīvs (pirms 1 st., pirms 2 d.)",
"custom": "Pielāgots" "full": "Vienmēr pilns datums",
"preview_today": "Šodien:",
"preview_this_week": "Šajā nedēļā:",
"preview_older": "Vecāks:"
}, },
"time_format": { "time_format": {
"label": "Laika formāts", "label": "Laika formāts",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Rādīt attēlu priekšskatījumus pielikumos", "label": "Rādīt attēlu priekšskatījumus pielikumos",
"description": "Rādīt attēlu pielikumus kā sīktēlu kartītes, nevis vispārīgas failu ikonas" "description": "Rādīt attēlu pielikumus kā sīktēlu kartītes, nevis vispārīgas failu ikonas"
},
"send_delay": {
"label": "Atsaukt sūtīšanu / sūtīšanas aizture",
"description": "Aizturiet parastu sūtīšanu īsā servera puses logā.",
"off": "Izslēgts",
"seconds": "{seconds} sekundes",
"unsupported": "Pašreizējais konts neziņo par aizturētas sūtīšanas atbalstu. Iestatījums joprojām tiks saglabāts citiem kontiem."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā", "group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā",
"group_by_letter_label": "Grupēt pēc pirmā burta" "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": { "filters": {
"title": "Pasta filtri", "title": "Pasta filtri",
"description": "Izveidojiet noteikumus automātiskai vēstuļu šķirošanai un pārvaldībai", "description": "Izveidojiet noteikumus automātiskai vēstuļu šķirošanai un pārvaldībai",
@@ -1751,7 +1842,11 @@
"color_tag": "Tags", "color_tag": "Tags",
"remove_color": "Noņemt tagu", "remove_color": "Noņemt tagu",
"items_selected": "{count} vēstules atlasītas", "items_selected": "{count} vēstules atlasītas",
"edit_draft": "Rediģēt melnrakstu" "edit_draft": "Rediģēt melnrakstu",
"cancel_scheduled_send": "Atcelt sūtīšanu",
"reschedule_send": "Pārplānot",
"cancel_and_edit": "Atcelt un rediģēt",
"cancel_and_compose_again": "Atcelt un rakstīt no jauna"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Atzīmēt mapi kā lasītu", "mark_folder_read": "Atzīmēt mapi kā lasītu",
@@ -1760,7 +1855,7 @@
"new_subfolder": "Jauna apakšmape...", "new_subfolder": "Jauna apakšmape...",
"new_folder": "Jauna mape...", "new_folder": "Jauna mape...",
"rename": "Pārsaukt...", "rename": "Pārsaukt...",
"import_email": "Importēt .eml...", "import_email": "Importēt .eml vai .zip...",
"empty_folder": "Iztukšot mapi", "empty_folder": "Iztukšot mapi",
"empty_folder_generic": "Iztukšot mapi", "empty_folder_generic": "Iztukšot mapi",
"delete_folder": "Dzēst mapi", "delete_folder": "Dzēst mapi",
@@ -1829,6 +1924,8 @@
"expand_collapse": "Izvērst/sairt sarunu" "expand_collapse": "Izvērst/sairt sarunu"
}, },
"composer": { "composer": {
"send": "Nosūtīt e-pastu",
"schedule_send": "Ieplānot nosūtīšanu",
"template_picker": "Atvērt veidņu izvēli" "template_picker": "Atvērt veidņu izvēli"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "E-mail", "mail": "E-mail",
"nav_label": "Navigatie", "nav_label": "Navigatie",
"add_app": "Apps", "add_app": "Apps",
"shared": "Gedeeld" "shared": "Gedeeld",
"scheduled": "Gepland"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Standaardapps", "title": "Standaardapps",
@@ -239,7 +240,16 @@
"confirm_button": "Map legen", "confirm_button": "Map legen",
"junk_hint": "U kunt de map Spam legen om alle berichten permanent te verwijderen.", "junk_hint": "U kunt de map Spam legen om alle berichten permanent te verwijderen.",
"trash_hint": "U kunt de prullenbak legen om alle berichten permanent te verwijderen." "trash_hint": "U kunt de prullenbak legen om alle berichten permanent te verwijderen."
} },
"no_scheduled_emails": "Geen geplande e-mails",
"no_scheduled_emails_description": "Berichten die voor later zijn gepland verschijnen hier.",
"scheduled_count": "{count, plural, one {1 geplande e-mail} other {# geplande e-mails}}",
"scheduled_actions_hint": "Geplande berichten kunnen worden geannuleerd, opnieuw gepland of bewerkt via hun geplande acties.",
"cancel_scheduled_send": "Verzenden annuleren",
"reschedule_send": "Opnieuw plannen",
"cancel_and_edit": "Annuleren en bewerken",
"cancel_and_compose_again": "Annuleren en opnieuw opstellen",
"reschedule_prompt": "Voer een nieuwe datum/tijd in, bijv. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Geen e-mail geselecteerd", "no_email_selected": "Geen e-mail geselecteerd",
@@ -270,7 +280,7 @@
"print": "Afdrukken", "print": "Afdrukken",
"view_source": "Bron bekijken", "view_source": "Bron bekijken",
"export_email": "Exporteren als .eml", "export_email": "Exporteren als .eml",
"import_email": ".eml importeren", "import_email": ".eml of .zip importeren",
"keyboard_shortcuts": "Sneltoetsen (?)", "keyboard_shortcuts": "Sneltoetsen (?)",
"email_source": "E-mailbron", "email_source": "E-mailbron",
"draft_banner": "Dit bericht is een concept", "draft_banner": "Dit bericht is een concept",
@@ -514,7 +524,17 @@
"ai_verdict": "AI-oordeel", "ai_verdict": "AI-oordeel",
"account": "Account", "account": "Account",
"no_subject": "(geen onderwerp)" "no_subject": "(geen onderwerp)"
} },
"scheduled_banner": "Gepland om te verzenden op {date}",
"scheduled_send_created": "E-mail gepland voor verzending",
"cancel_scheduled_send": "Verzenden annuleren",
"reschedule_send": "Opnieuw plannen",
"cancel_and_edit": "Annuleren en bewerken",
"cancel_and_compose_again": "Annuleren en opnieuw opstellen",
"reschedule_prompt": "Voer een nieuwe datum/tijd in, bijv. 2026-05-04T15:30",
"scheduled_actions_only": "De geplande weergave ondersteunt alleen geplande acties",
"undo_send_scheduled": "Bericht gepland voor verzending",
"undo_send": "Verzenden ongedaan maken"
}, },
"email_composer": { "email_composer": {
"new_message": "Nieuw bericht", "new_message": "Nieuw bericht",
@@ -607,7 +627,19 @@
}, },
"add_link": "Link toevoegen", "add_link": "Link toevoegen",
"link_url_prompt": "Voer de URL in", "link_url_prompt": "Voer de URL in",
"sending": "Bezig met verzenden..." "sending": "Bezig met verzenden...",
"schedule_send": "Verzenden plannen",
"schedule_send_description": "Kies wanneer de server dit bericht moet vrijgeven.",
"schedule_send_required": "Kies een datum en tijd.",
"schedule_send_invalid": "Voer een geldige datum en tijd in.",
"schedule_send_future": "Kies een toekomstige datum en tijd.",
"schedule_send_too_late": "Dit tijdstip is later dan de server toestaat.",
"schedule_send_unsupported": "Gepland verzenden wordt niet ondersteund voor dit account.",
"schedule_send_cleanup_warning": "Gepland verzenden is aangemaakt, maar het opschonen van de conceptversie is mislukt.",
"send_delay_unsupported": "Verzendvertraging wordt niet ondersteund voor dit account.",
"send_delay_unsupported_confirm": "Dit account ondersteunt geen verzendvertraging. Meteen verzenden?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Bevestigen", "confirm": "Bevestigen",
@@ -752,7 +784,7 @@
"keep_editing": "Doorgaan met bewerken", "keep_editing": "Doorgaan met bewerken",
"tabs": { "tabs": {
"appearance": "Uiterlijk", "appearance": "Uiterlijk",
"language": "Taal & Regio", "language": "Taal, Regio & Tijd",
"email": "E-mailgedrag", "email": "E-mailgedrag",
"composer": "Opstellen", "composer": "Opstellen",
"privacy": "Privacy & Beveiliging", "privacy": "Privacy & Beveiliging",
@@ -775,6 +807,7 @@
"layout": "Indeling", "layout": "Indeling",
"reading": "Lezen", "reading": "Lezen",
"composing": "Opstellen", "composing": "Opstellen",
"downloads": "Downloads",
"content_senders": "Inhoud en afzenders", "content_senders": "Inhoud en afzenders",
"about_data": "Over en gegevens", "about_data": "Over en gegevens",
"debug": "Debuggen" "debug": "Debuggen"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Gecombineerd postvak", "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": { "colorful_sidebar_icons": {
"label": "Gekleurde zijbalkpictogrammen", "label": "Gekleurde zijbalkpictogrammen",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Taal & Regio", "title": "Taal, Regio & Tijd",
"description": "Configureer taal- en regiovoorkeuren", "description": "Taal, datumnotatie, tijdnotatie en andere regionale voorkeuren",
"language": { "language": {
"label": "Taal", "label": "Taal",
"description": "Kies je voorkeurstaal", "description": "Kies je voorkeurstaal",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "Datumnotatie", "label": "Datumnotatie",
"description": "Hoe datums moeten worden weergegeven", "description": "Hoe datums worden weergegeven in de e-maillijst",
"regional": "Regionaal", "smart": "Slim (regionaal)",
"iso": "ISO 8601", "relative": "Relatief (1 u geleden, 2 d geleden)",
"custom": "Aangepast" "full": "Altijd volledige datum",
"preview_today": "Vandaag:",
"preview_this_week": "Deze week:",
"preview_older": "Ouder:"
}, },
"time_format": { "time_format": {
"label": "Tijdnotatie", "label": "Tijdnotatie",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Voorbeelden van afbeeldingen tonen in bijlagen", "label": "Voorbeelden van afbeeldingen tonen in bijlagen",
"description": "Toon afbeeldingsbijlagen als miniatuurkaarten in plaats van generieke bestandspictogrammen" "description": "Toon afbeeldingsbijlagen als miniatuurkaarten in plaats van generieke bestandspictogrammen"
},
"send_delay": {
"label": "Verzenden ongedaan maken / verzendvertraging",
"description": "Vertraag normale verzending met een korte server-side periode.",
"off": "Uit",
"seconds": "{seconds} seconden",
"unsupported": "Het huidige account meldt geen ondersteuning voor vertraagd verzenden. De instelling blijft opgeslagen voor andere accounts."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst", "group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst",
"group_by_letter_label": "Groeperen op eerste letter" "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": { "filters": {
"title": "E-mailfilters", "title": "E-mailfilters",
"description": "Maak regels om inkomende e-mails automatisch te sorteren, labelen en beheren", "description": "Maak regels om inkomende e-mails automatisch te sorteren, labelen en beheren",
@@ -1751,7 +1842,11 @@
"color_tag": "Label", "color_tag": "Label",
"remove_color": "Label verwijderen", "remove_color": "Label verwijderen",
"items_selected": "{count} e-mails geselecteerd", "items_selected": "{count} e-mails geselecteerd",
"edit_draft": "Concept bewerken" "edit_draft": "Concept bewerken",
"cancel_scheduled_send": "Verzenden annuleren",
"reschedule_send": "Opnieuw plannen",
"cancel_and_edit": "Annuleren en bewerken",
"cancel_and_compose_again": "Annuleren en opnieuw opstellen"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Map markeren als gelezen", "mark_folder_read": "Map markeren als gelezen",
@@ -1760,7 +1855,7 @@
"new_subfolder": "Nieuwe submap...", "new_subfolder": "Nieuwe submap...",
"new_folder": "Nieuwe map...", "new_folder": "Nieuwe map...",
"rename": "Hernoemen...", "rename": "Hernoemen...",
"import_email": ".eml importeren...", "import_email": ".eml of .zip importeren...",
"empty_folder": "Map leegmaken", "empty_folder": "Map leegmaken",
"empty_folder_generic": "Map leegmaken", "empty_folder_generic": "Map leegmaken",
"delete_folder": "Map verwijderen", "delete_folder": "Map verwijderen",
@@ -1829,6 +1924,8 @@
"expand_collapse": "Gesprek uitklappen/inklappen" "expand_collapse": "Gesprek uitklappen/inklappen"
}, },
"composer": { "composer": {
"send": "E-mail verzenden",
"schedule_send": "Verzenden plannen",
"template_picker": "Sjabloonkiezer openen" "template_picker": "Sjabloonkiezer openen"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "Poczta", "mail": "Poczta",
"nav_label": "Nawigacja", "nav_label": "Nawigacja",
"add_app": "Aplikacje", "add_app": "Aplikacje",
"shared": "Udostępnione" "shared": "Udostępnione",
"scheduled": "Zaplanowane"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Aplikacje domyślne", "title": "Aplikacje domyślne",
@@ -239,7 +240,16 @@
"confirm_button": "Opróżnij folder", "confirm_button": "Opróżnij folder",
"junk_hint": "Możesz opróżnić folder Spam, aby trwale usunąć wszystkie wiadomości.", "junk_hint": "Możesz opróżnić folder Spam, aby trwale usunąć wszystkie wiadomości.",
"trash_hint": "Możesz opróżnić Kosz, aby trwale usunąć wszystkie wiadomości." "trash_hint": "Możesz opróżnić Kosz, aby trwale usunąć wszystkie wiadomości."
} },
"no_scheduled_emails": "Brak zaplanowanych e-maili",
"no_scheduled_emails_description": "Wiadomości zaplanowane na później pojawią się tutaj.",
"scheduled_count": "{count, plural, one {1 zaplanowany e-mail} few {# zaplanowane e-maile} other {# zaplanowanych e-maili}}",
"scheduled_actions_hint": "Zaplanowane wiadomości można anulować, przełożyć lub edytować z poziomu akcji planowania.",
"cancel_scheduled_send": "Anuluj wysyłkę",
"reschedule_send": "Przełóż",
"cancel_and_edit": "Anuluj i edytuj",
"cancel_and_compose_again": "Anuluj i napisz ponownie",
"reschedule_prompt": "Wprowadź nową datę/godzinę, np. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Nie wybrano wiadomości", "no_email_selected": "Nie wybrano wiadomości",
@@ -270,7 +280,7 @@
"print": "Drukuj", "print": "Drukuj",
"view_source": "Pokaż źródło", "view_source": "Pokaż źródło",
"export_email": "Eksportuj jako .eml", "export_email": "Eksportuj jako .eml",
"import_email": "Importuj .eml", "import_email": "Importuj .eml lub .zip",
"keyboard_shortcuts": "Skróty klawiszowe (?)", "keyboard_shortcuts": "Skróty klawiszowe (?)",
"email_source": "Źródło wiadomości", "email_source": "Źródło wiadomości",
"draft_banner": "Ta wiadomość jest szkicem", "draft_banner": "Ta wiadomość jest szkicem",
@@ -514,7 +524,17 @@
"ai_verdict": "Werdykt AI", "ai_verdict": "Werdykt AI",
"account": "Konto", "account": "Konto",
"no_subject": "(brak tematu)" "no_subject": "(brak tematu)"
} },
"scheduled_banner": "Zaplanowano wysyłkę na {date}",
"scheduled_send_created": "E-mail zaplanowany do wysłania",
"cancel_scheduled_send": "Anuluj wysyłkę",
"reschedule_send": "Przełóż",
"cancel_and_edit": "Anuluj i edytuj",
"cancel_and_compose_again": "Anuluj i napisz ponownie",
"reschedule_prompt": "Wprowadź nową datę/godzinę, np. 2026-05-04T15:30",
"scheduled_actions_only": "Widok Zaplanowane obsługuje tylko akcje planowania",
"undo_send_scheduled": "Wiadomość zaplanowana do wysłania",
"undo_send": "Cofnij wysyłkę"
}, },
"email_composer": { "email_composer": {
"new_message": "Nowa wiadomość", "new_message": "Nowa wiadomość",
@@ -607,7 +627,19 @@
}, },
"add_link": "Dodaj link", "add_link": "Dodaj link",
"link_url_prompt": "Wprowadź adres URL", "link_url_prompt": "Wprowadź adres URL",
"sending": "Wysyłanie..." "sending": "Wysyłanie...",
"schedule_send": "Zaplanuj wysyłkę",
"schedule_send_description": "Wybierz, kiedy serwer ma wysłać tę wiadomość.",
"schedule_send_required": "Wybierz datę i godzinę.",
"schedule_send_invalid": "Wprowadź prawidłową datę i godzinę.",
"schedule_send_future": "Wybierz datę i godzinę w przyszłości.",
"schedule_send_too_late": "Ten czas jest późniejszy, niż pozwala serwer.",
"schedule_send_unsupported": "Zaplanowana wysyłka nie jest obsługiwana dla tego konta.",
"schedule_send_cleanup_warning": "Zaplanowana wysyłka została utworzona, ale czyszczenie wersji roboczej nie powiodło się.",
"send_delay_unsupported": "Opóźnienie wysyłki nie jest obsługiwane dla tego konta.",
"send_delay_unsupported_confirm": "To konto nie obsługuje opóźnienia wysyłki. Wysłać natychmiast?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Potwierdź", "confirm": "Potwierdź",
@@ -752,7 +784,7 @@
"keep_editing": "Kontynuuj edycję", "keep_editing": "Kontynuuj edycję",
"tabs": { "tabs": {
"appearance": "Wygląd", "appearance": "Wygląd",
"language": "Język i region", "language": "Język, region i czas",
"email": "Zachowanie poczty e-mail", "email": "Zachowanie poczty e-mail",
"composer": "Redagowanie", "composer": "Redagowanie",
"privacy": "Prywatność i bezpieczeństwo", "privacy": "Prywatność i bezpieczeństwo",
@@ -775,6 +807,7 @@
"layout": "Układ", "layout": "Układ",
"reading": "Czytanie", "reading": "Czytanie",
"composing": "Tworzenie", "composing": "Tworzenie",
"downloads": "Pobrane",
"content_senders": "Treść i nadawcy", "content_senders": "Treść i nadawcy",
"about_data": "O programie i dane", "about_data": "O programie i dane",
"debug": "Debugowanie" "debug": "Debugowanie"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Wspólna skrzynka", "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": { "colorful_sidebar_icons": {
"label": "Kolorowe ikony paska bocznego", "label": "Kolorowe ikony paska bocznego",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Język i region", "title": "Język, region i czas",
"description": "Skonfiguruj preferencje językowe i regionalne", "description": "Język, format daty, format godziny i inne preferencje regionalne",
"language": { "language": {
"label": "Język", "label": "Język",
"description": "Wybierz preferowany język", "description": "Wybierz preferowany język",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "Format daty", "label": "Format daty",
"description": "Jak mają być wyświetlane daty", "description": "Jak daty są wyświetlane na liście e-maili",
"regional": "Regionalny", "smart": "Inteligentny (regionalny)",
"iso": "ISO 8601", "relative": "Względny (1 g temu, 2 d temu)",
"custom": "Niestandardowy" "full": "Zawsze pełna data",
"preview_today": "Dzisiaj:",
"preview_this_week": "W tym tygodniu:",
"preview_older": "Starsze:"
}, },
"time_format": { "time_format": {
"label": "Format czasu", "label": "Format czasu",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Pokaż podgląd obrazów w załącznikach", "label": "Pokaż podgląd obrazów w załącznikach",
"description": "Wyświetlaj załączone obrazy jako miniatury zamiast ogólnych ikon plików" "description": "Wyświetlaj załączone obrazy jako miniatury zamiast ogólnych ikon plików"
},
"send_delay": {
"label": "Cofnij wysyłkę / opóźnienie wysyłki",
"description": "Opóźnia normalne wysyłanie krótkim oknem po stronie serwera.",
"off": "Wyłączone",
"seconds": "{seconds} sekund",
"unsupported": "Bieżące konto nie zgłasza obsługi opóźnionej wysyłki. Ustawienie pozostanie zapisane dla innych kont."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów", "group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów",
"group_by_letter_label": "Grupuj według pierwszej litery" "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": { "filters": {
"title": "Filtry wiadomości e-mail", "title": "Filtry wiadomości e-mail",
"description": "Twórz reguły, aby automatycznie sortować, etykietować i zarządzać przychodzącymi wiadomościami 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", "color_tag": "Etykieta",
"remove_color": "Usuń etykietę", "remove_color": "Usuń etykietę",
"items_selected": "{count} zaznaczonych wiadomości", "items_selected": "{count} zaznaczonych wiadomości",
"edit_draft": "Edytuj szkic" "edit_draft": "Edytuj szkic",
"cancel_scheduled_send": "Anuluj wysyłkę",
"reschedule_send": "Przełóż",
"cancel_and_edit": "Anuluj i edytuj",
"cancel_and_compose_again": "Anuluj i napisz ponownie"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Oznacz folder jako przeczytany", "mark_folder_read": "Oznacz folder jako przeczytany",
@@ -1760,7 +1855,7 @@
"new_subfolder": "Nowy podfolder...", "new_subfolder": "Nowy podfolder...",
"new_folder": "Nowy folder...", "new_folder": "Nowy folder...",
"rename": "Zmień nazwę...", "rename": "Zmień nazwę...",
"import_email": "Importuj .eml...", "import_email": "Importuj .eml lub .zip...",
"empty_folder": "Opróżnij folder", "empty_folder": "Opróżnij folder",
"empty_folder_generic": "Opróżnij folder", "empty_folder_generic": "Opróżnij folder",
"delete_folder": "Usuń folder", "delete_folder": "Usuń folder",
@@ -1829,6 +1924,8 @@
"expand_collapse": "Rozwiń/zwiń wątek" "expand_collapse": "Rozwiń/zwiń wątek"
}, },
"composer": { "composer": {
"send": "Wyślij e-mail",
"schedule_send": "Zaplanuj wysyłkę",
"template_picker": "Otwórz wybór szablonu" "template_picker": "Otwórz wybór szablonu"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "E-mail", "mail": "E-mail",
"nav_label": "Navegação", "nav_label": "Navegação",
"add_app": "Apps", "add_app": "Apps",
"shared": "Compartilhado" "shared": "Compartilhado",
"scheduled": "Agendados"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Aplicativos padrão", "title": "Aplicativos padrão",
@@ -239,7 +240,16 @@
"confirm_button": "Esvaziar pasta", "confirm_button": "Esvaziar pasta",
"junk_hint": "Você pode esvaziar a pasta de Spam para remover permanentemente todas as mensagens.", "junk_hint": "Você pode esvaziar a pasta de Spam para remover permanentemente todas as mensagens.",
"trash_hint": "Você pode esvaziar a Lixeira para remover permanentemente todas as mensagens." "trash_hint": "Você pode esvaziar a Lixeira para remover permanentemente todas as mensagens."
} },
"no_scheduled_emails": "Nenhum e-mail agendado",
"no_scheduled_emails_description": "Mensagens agendadas para mais tarde aparecerão aqui.",
"scheduled_count": "{count, plural, one {1 e-mail agendado} other {# e-mails agendados}}",
"scheduled_actions_hint": "Mensagens agendadas podem ser canceladas, reagendadas ou editadas pelas ações de agendamento.",
"cancel_scheduled_send": "Cancelar envio",
"reschedule_send": "Reagendar",
"cancel_and_edit": "Cancelar e editar",
"cancel_and_compose_again": "Cancelar e escrever novamente",
"reschedule_prompt": "Informe uma nova data/hora, ex. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Nenhum e-mail selecionado", "no_email_selected": "Nenhum e-mail selecionado",
@@ -270,7 +280,7 @@
"print": "Imprimir", "print": "Imprimir",
"view_source": "Ver código-fonte", "view_source": "Ver código-fonte",
"export_email": "Exportar como .eml", "export_email": "Exportar como .eml",
"import_email": "Importar .eml", "import_email": "Importar .eml ou .zip",
"keyboard_shortcuts": "Atalhos de teclado (?)", "keyboard_shortcuts": "Atalhos de teclado (?)",
"email_source": "Código-fonte do E-mail", "email_source": "Código-fonte do E-mail",
"draft_banner": "Esta mensagem é um rascunho", "draft_banner": "Esta mensagem é um rascunho",
@@ -514,7 +524,17 @@
"ai_verdict": "Veredito da IA", "ai_verdict": "Veredito da IA",
"account": "Conta", "account": "Conta",
"no_subject": "(sem assunto)" "no_subject": "(sem assunto)"
} },
"scheduled_banner": "Agendado para enviar em {date}",
"scheduled_send_created": "E-mail agendado para envio",
"cancel_scheduled_send": "Cancelar envio",
"reschedule_send": "Reagendar",
"cancel_and_edit": "Cancelar e editar",
"cancel_and_compose_again": "Cancelar e escrever novamente",
"reschedule_prompt": "Informe uma nova data/hora, ex. 2026-05-04T15:30",
"scheduled_actions_only": "A visualização Agendados permite apenas ações de agendamento",
"undo_send_scheduled": "Mensagem agendada para envio",
"undo_send": "Desfazer envio"
}, },
"email_composer": { "email_composer": {
"new_message": "Nova Mensagem", "new_message": "Nova Mensagem",
@@ -607,7 +627,19 @@
}, },
"add_link": "Adicionar link", "add_link": "Adicionar link",
"link_url_prompt": "Insira a URL", "link_url_prompt": "Insira a URL",
"sending": "Enviando..." "sending": "Enviando...",
"schedule_send": "Agendar envio",
"schedule_send_description": "Escolha quando o servidor deve liberar esta mensagem.",
"schedule_send_required": "Escolha uma data e hora.",
"schedule_send_invalid": "Informe uma data e hora válidas.",
"schedule_send_future": "Escolha uma data e hora no futuro.",
"schedule_send_too_late": "Este horário é posterior ao permitido pelo servidor.",
"schedule_send_unsupported": "Envio agendado não é compatível com esta conta.",
"schedule_send_cleanup_warning": "O envio agendado foi criado, mas a limpeza do rascunho falhou.",
"send_delay_unsupported": "Atraso de envio não é compatível com esta conta.",
"send_delay_unsupported_confirm": "Esta conta não oferece atraso de envio. Enviar imediatamente?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmar", "confirm": "Confirmar",
@@ -752,7 +784,7 @@
"keep_editing": "Continuar editando", "keep_editing": "Continuar editando",
"tabs": { "tabs": {
"appearance": "Aparência", "appearance": "Aparência",
"language": "Idioma e Região", "language": "Idioma, Região e Hora",
"email": "Comportamento de E-mail", "email": "Comportamento de E-mail",
"composer": "Editor", "composer": "Editor",
"privacy": "Privacidade e Segurança", "privacy": "Privacidade e Segurança",
@@ -775,6 +807,7 @@
"layout": "Layout", "layout": "Layout",
"reading": "Leitura", "reading": "Leitura",
"composing": "Composição", "composing": "Composição",
"downloads": "Downloads",
"content_senders": "Conteúdo e remetentes", "content_senders": "Conteúdo e remetentes",
"about_data": "Sobre e dados", "about_data": "Sobre e dados",
"debug": "Depuração" "debug": "Depuração"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Caixa de correio unificada", "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": { "colorful_sidebar_icons": {
"label": "Ícones coloridos na barra lateral", "label": "Ícones coloridos na barra lateral",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Idioma e Região", "title": "Idioma, Região e Hora",
"description": "Configure preferências de idioma e região", "description": "Idioma, formato de data, formato de hora e outras preferências regionais",
"language": { "language": {
"label": "Idioma", "label": "Idioma",
"description": "Escolha seu idioma preferido", "description": "Escolha seu idioma preferido",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "Formato de Data", "label": "Formato de Data",
"description": "Como as datas devem ser exibidas", "description": "Como as datas são mostradas na lista de e-mails",
"regional": "Regional", "smart": "Inteligente (regional)",
"iso": "ISO 8601", "relative": "Relativo (há 1 h, há 2 d)",
"custom": "Personalizado" "full": "Sempre data completa",
"preview_today": "Hoje:",
"preview_this_week": "Esta semana:",
"preview_older": "Mais antigo:"
}, },
"time_format": { "time_format": {
"label": "Formato de Hora", "label": "Formato de Hora",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Mostrar prévias de imagens em anexos", "label": "Mostrar prévias de imagens em anexos",
"description": "Renderizar anexos de imagem como miniaturas em vez de ícones genéricos de arquivo" "description": "Renderizar anexos de imagem como miniaturas em vez de ícones genéricos de arquivo"
},
"send_delay": {
"label": "Desfazer envio / atraso de envio",
"description": "Atrase envios normais com uma breve janela no servidor.",
"off": "Desativado",
"seconds": "{seconds} segundos",
"unsupported": "A conta atual não anuncia suporte a envio atrasado. A configuração continuará salva para outras contas."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos", "group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos",
"group_by_letter_label": "Agrupar pela primeira letra" "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": { "filters": {
"title": "Filtros de e-mail", "title": "Filtros de e-mail",
"description": "Crie regras para classificar, rotular e gerenciar automaticamente os e-mails recebidos", "description": "Crie regras para classificar, rotular e gerenciar automaticamente os e-mails recebidos",
@@ -1751,7 +1842,11 @@
"color_tag": "Etiqueta", "color_tag": "Etiqueta",
"remove_color": "Remover etiqueta", "remove_color": "Remover etiqueta",
"items_selected": "{count} e-mails selecionados", "items_selected": "{count} e-mails selecionados",
"edit_draft": "Editar rascunho" "edit_draft": "Editar rascunho",
"cancel_scheduled_send": "Cancelar envio",
"reschedule_send": "Reagendar",
"cancel_and_edit": "Cancelar e editar",
"cancel_and_compose_again": "Cancelar e escrever novamente"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Marcar pasta como lida", "mark_folder_read": "Marcar pasta como lida",
@@ -1760,7 +1855,7 @@
"new_subfolder": "Nova subpasta...", "new_subfolder": "Nova subpasta...",
"new_folder": "Nova pasta...", "new_folder": "Nova pasta...",
"rename": "Renomear...", "rename": "Renomear...",
"import_email": "Importar .eml...", "import_email": "Importar .eml ou .zip...",
"empty_folder": "Esvaziar pasta", "empty_folder": "Esvaziar pasta",
"empty_folder_generic": "Esvaziar pasta", "empty_folder_generic": "Esvaziar pasta",
"delete_folder": "Excluir pasta", "delete_folder": "Excluir pasta",
@@ -1829,6 +1924,8 @@
"expand_collapse": "Expandir/recolher conversa" "expand_collapse": "Expandir/recolher conversa"
}, },
"composer": { "composer": {
"send": "Enviar e-mail",
"schedule_send": "Agendar envio",
"template_picker": "Abrir seletor de modelos" "template_picker": "Abrir seletor de modelos"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "Почта", "mail": "Почта",
"nav_label": "Навигация", "nav_label": "Навигация",
"add_app": "Приложения", "add_app": "Приложения",
"shared": "Общие" "shared": "Общие",
"scheduled": "Запланировано"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Приложения по умолчанию", "title": "Приложения по умолчанию",
@@ -239,7 +240,16 @@
"confirm_button": "Очистить папку", "confirm_button": "Очистить папку",
"junk_hint": "Вы можете очистить папку Спам для постоянного удаления всех сообщений.", "junk_hint": "Вы можете очистить папку Спам для постоянного удаления всех сообщений.",
"trash_hint": "Вы можете очистить Корзину для постоянного удаления всех сообщений." "trash_hint": "Вы можете очистить Корзину для постоянного удаления всех сообщений."
} },
"no_scheduled_emails": "Нет запланированных писем",
"no_scheduled_emails_description": "Сообщения, запланированные на позже, появятся здесь.",
"scheduled_count": "{count, plural, one {# запланированное письмо} few {# запланированных письма} other {# запланированных писем}}",
"scheduled_actions_hint": "Запланированные сообщения можно отменить, перенести или изменить через действия планирования.",
"cancel_scheduled_send": "Отменить отправку",
"reschedule_send": "Перенести",
"cancel_and_edit": "Отменить и изменить",
"cancel_and_compose_again": "Отменить и написать заново",
"reschedule_prompt": "Введите новую дату/время, например 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Письмо не выбрано", "no_email_selected": "Письмо не выбрано",
@@ -270,7 +280,7 @@
"print": "Распечатать", "print": "Распечатать",
"view_source": "Просмотреть исходный код", "view_source": "Просмотреть исходный код",
"export_email": "Экспортировать как .eml", "export_email": "Экспортировать как .eml",
"import_email": "Импортировать .eml", "import_email": "Импортировать .eml или .zip",
"keyboard_shortcuts": "Сочетания клавиш (?)", "keyboard_shortcuts": "Сочетания клавиш (?)",
"email_source": "Исходный код письма", "email_source": "Исходный код письма",
"draft_banner": "Это письмо является черновиком", "draft_banner": "Это письмо является черновиком",
@@ -514,7 +524,17 @@
"ai_verdict": "Вердикт ИИ", "ai_verdict": "Вердикт ИИ",
"account": "Аккаунт", "account": "Аккаунт",
"no_subject": "(без темы)" "no_subject": "(без темы)"
} },
"scheduled_banner": "Запланировано к отправке {date}",
"scheduled_send_created": "Письмо запланировано к отправке",
"cancel_scheduled_send": "Отменить отправку",
"reschedule_send": "Перенести",
"cancel_and_edit": "Отменить и изменить",
"cancel_and_compose_again": "Отменить и написать заново",
"reschedule_prompt": "Введите новую дату/время, например 2026-05-04T15:30",
"scheduled_actions_only": "В представлении запланированных доступны только действия планирования",
"undo_send_scheduled": "Сообщение запланировано к отправке",
"undo_send": "Отменить отправку"
}, },
"email_composer": { "email_composer": {
"new_message": "Новое письмо", "new_message": "Новое письмо",
@@ -607,7 +627,19 @@
}, },
"add_link": "Добавить ссылку", "add_link": "Добавить ссылку",
"link_url_prompt": "Введите URL", "link_url_prompt": "Введите URL",
"sending": "Отправка..." "sending": "Отправка...",
"schedule_send": "Запланировать отправку",
"schedule_send_description": "Выберите, когда сервер должен отправить это сообщение.",
"schedule_send_required": "Выберите дату и время.",
"schedule_send_invalid": "Введите действительные дату и время.",
"schedule_send_future": "Выберите дату и время в будущем.",
"schedule_send_too_late": "Это время позже, чем разрешает сервер.",
"schedule_send_unsupported": "Запланированная отправка не поддерживается для этой учетной записи.",
"schedule_send_cleanup_warning": "Запланированная отправка создана, но очистка черновика не удалась.",
"send_delay_unsupported": "Задержка отправки не поддерживается для этой учетной записи.",
"send_delay_unsupported_confirm": "Эта учетная запись не поддерживает задержку отправки. Отправить сразу?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Подтвердить", "confirm": "Подтвердить",
@@ -752,7 +784,7 @@
"keep_editing": "Продолжить редактирование", "keep_editing": "Продолжить редактирование",
"tabs": { "tabs": {
"appearance": "Внешний вид", "appearance": "Внешний вид",
"language": "Язык и регион", "language": "Язык, регион и время",
"email": "Поведение почты", "email": "Поведение почты",
"composer": "Редактор", "composer": "Редактор",
"privacy": "Конфиденциальность и безопасность", "privacy": "Конфиденциальность и безопасность",
@@ -775,6 +807,7 @@
"layout": "Макет", "layout": "Макет",
"reading": "Чтение", "reading": "Чтение",
"composing": "Написание", "composing": "Написание",
"downloads": "Загрузки",
"content_senders": "Содержимое и отправители", "content_senders": "Содержимое и отправители",
"about_data": "О программе и данные", "about_data": "О программе и данные",
"debug": "Отладка" "debug": "Отладка"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Общий почтовый ящик", "label": "Общий почтовый ящик",
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов" "description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов",
"include_group": {
"label": "Включать групповые ящики",
"description": "Также объединять общие/групповые ящики в едином представлении."
}
}, },
"colorful_sidebar_icons": { "colorful_sidebar_icons": {
"label": "Цветные значки боковой панели", "label": "Цветные значки боковой панели",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Язык и регион", "title": "Язык, регион и время",
"description": "Настройте языковые и региональные предпочтения", "description": "Язык, формат даты, формат времени и другие региональные настройки",
"language": { "language": {
"label": "Язык", "label": "Язык",
"description": "Выберите предпочтительный язык", "description": "Выберите предпочтительный язык",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "Формат даты", "label": "Формат даты",
"description": "Как отображать даты", "description": "Как отображаются даты в списке писем",
"regional": "Региональный", "smart": "Умный (по региону)",
"iso": "ISO 8601", "relative": "Относительный (1 ч назад, 2 д назад)",
"custom": "Пользовательский" "full": "Всегда полная дата",
"preview_today": "Сегодня:",
"preview_this_week": "На этой неделе:",
"preview_older": "Старее:"
}, },
"time_format": { "time_format": {
"label": "Формат времени", "label": "Формат времени",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Показывать миниатюры изображений во вложениях", "label": "Показывать миниатюры изображений во вложениях",
"description": "Отображать вложенные изображения в виде миниатюр вместо обычных значков файлов" "description": "Отображать вложенные изображения в виде миниатюр вместо обычных значков файлов"
},
"send_delay": {
"label": "Отмена отправки / задержка отправки",
"description": "Задерживает обычную отправку на короткий серверный интервал.",
"off": "Выкл.",
"seconds": "{seconds} сек.",
"unsupported": "Текущая учетная запись не заявляет поддержку отложенной отправки. Настройка сохранится для других учетных записей."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов", "group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов",
"group_by_letter_label": "Группировать по первой букве" "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": { "filters": {
"title": "Фильтры почты", "title": "Фильтры почты",
"description": "Создавайте правила для автоматической сортировки, маркировки и управления входящими письмами", "description": "Создавайте правила для автоматической сортировки, маркировки и управления входящими письмами",
@@ -1751,7 +1842,11 @@
"color_tag": "Тег", "color_tag": "Тег",
"remove_color": "Удалить тег", "remove_color": "Удалить тег",
"items_selected": "{count} писем выбрано", "items_selected": "{count} писем выбрано",
"edit_draft": "Редактировать черновик" "edit_draft": "Редактировать черновик",
"cancel_scheduled_send": "Отменить отправку",
"reschedule_send": "Перенести",
"cancel_and_edit": "Отменить и изменить",
"cancel_and_compose_again": "Отменить и написать заново"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Отметить папку как прочитанную", "mark_folder_read": "Отметить папку как прочитанную",
@@ -1760,7 +1855,7 @@
"new_subfolder": "Новая вложенная папка...", "new_subfolder": "Новая вложенная папка...",
"new_folder": "Новая папка...", "new_folder": "Новая папка...",
"rename": "Переименовать...", "rename": "Переименовать...",
"import_email": "Импортировать .eml...", "import_email": "Импортировать .eml или .zip...",
"empty_folder": "Очистить папку", "empty_folder": "Очистить папку",
"empty_folder_generic": "Очистить папку", "empty_folder_generic": "Очистить папку",
"delete_folder": "Удалить папку", "delete_folder": "Удалить папку",
@@ -1829,6 +1924,8 @@
"expand_collapse": "Развернуть/свернуть цепочку" "expand_collapse": "Развернуть/свернуть цепочку"
}, },
"composer": { "composer": {
"send": "Отправить письмо",
"schedule_send": "Запланировать отправку",
"template_picker": "Открыть выбор шаблонов" "template_picker": "Открыть выбор шаблонов"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"shared": "Paylaşılan", "shared": "Paylaşılan",
"mail": "Posta", "mail": "Posta",
"nav_label": "Gezinme", "nav_label": "Gezinme",
"add_app": "Uygulamalar" "add_app": "Uygulamalar",
"scheduled": "Zamanlandı"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Varsayılan uygulamalar", "title": "Varsayılan uygulamalar",
@@ -239,7 +240,16 @@
"confirm_button": "Klasörü boşalt", "confirm_button": "Klasörü boşalt",
"junk_hint": "Tüm iletileri kalıcı olarak kaldırmak için Önemsiz klasörünü boşaltabilirsiniz.", "junk_hint": "Tüm iletileri kalıcı olarak kaldırmak için Önemsiz klasörünü boşaltabilirsiniz.",
"trash_hint": "Tüm iletileri kalıcı olarak kaldırmak için Çöp Kutusu klasörünü boşaltabilirsiniz." "trash_hint": "Tüm iletileri kalıcı olarak kaldırmak için Çöp Kutusu klasörünü boşaltabilirsiniz."
} },
"no_scheduled_emails": "Zamanlanmış e-posta yok",
"no_scheduled_emails_description": "Daha sonra gönderilecek iletiler burada görünür.",
"scheduled_count": "{count, plural, one {1 zamanlanmış e-posta} other {# zamanlanmış e-posta}}",
"scheduled_actions_hint": "Zamanlanmış iletiler zamanlama işlemlerinden iptal edilebilir, yeniden zamanlanabilir veya düzenlenebilir.",
"cancel_scheduled_send": "Göndermeyi iptal et",
"reschedule_send": "Yeniden zamanla",
"cancel_and_edit": "İptal et ve düzenle",
"cancel_and_compose_again": "İptal et ve yeniden yaz",
"reschedule_prompt": "Yeni tarih/saat girin, ör. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "E-posta seçilmedi", "no_email_selected": "E-posta seçilmedi",
@@ -270,7 +280,7 @@
"print": "Yazdır", "print": "Yazdır",
"view_source": "Kaynağı görüntüle", "view_source": "Kaynağı görüntüle",
"export_email": ".eml olarak dışa aktar", "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ı (?)", "keyboard_shortcuts": "Klavye kısayolları (?)",
"email_source": "E-posta Kaynağı", "email_source": "E-posta Kaynağı",
"draft_banner": "Bu ileti bir taslaktır", "draft_banner": "Bu ileti bir taslaktır",
@@ -514,7 +524,17 @@
"ai_verdict": "Yapay zeka kararı", "ai_verdict": "Yapay zeka kararı",
"account": "Hesap", "account": "Hesap",
"no_subject": "(konu yok)" "no_subject": "(konu yok)"
} },
"scheduled_banner": "{date} tarihinde gönderilmek üzere zamanlandı",
"scheduled_send_created": "E-posta gönderim için zamanlandı",
"cancel_scheduled_send": "Göndermeyi iptal et",
"reschedule_send": "Yeniden zamanla",
"cancel_and_edit": "İptal et ve düzenle",
"cancel_and_compose_again": "İptal et ve yeniden yaz",
"reschedule_prompt": "Yeni tarih/saat girin, ör. 2026-05-04T15:30",
"scheduled_actions_only": "Zamanlandı görünümü yalnızca zamanlama işlemlerini destekler",
"undo_send_scheduled": "İleti gönderim için zamanlandı",
"undo_send": "Göndermeyi geri al"
}, },
"email_composer": { "email_composer": {
"new_message": "Yeni İleti", "new_message": "Yeni İleti",
@@ -607,7 +627,19 @@
"message": "İletinizde \"{keyword}\" geçiyor ancak hiçbir dosya eklenmemiş. Yine de gönderilsin mi?", "message": "İletinizde \"{keyword}\" geçiyor ancak hiçbir dosya eklenmemiş. Yine de gönderilsin mi?",
"send_anyway": "Yine de gönder", "send_anyway": "Yine de gönder",
"back": "Düzenlemeye dön" "back": "Düzenlemeye dön"
} },
"schedule_send": "Göndermeyi zamanla",
"schedule_send_description": "Sunucunun bu iletiyi ne zaman göndereceğini seçin.",
"schedule_send_required": "Bir tarih ve saat seçin.",
"schedule_send_invalid": "Geçerli bir tarih ve saat girin.",
"schedule_send_future": "Gelecekte bir tarih ve saat seçin.",
"schedule_send_too_late": "Bu zaman sunucunun izin verdiğinden daha geç.",
"schedule_send_unsupported": "Bu hesap için zamanlanmış gönderim desteklenmiyor.",
"schedule_send_cleanup_warning": "Zamanlanmış gönderim oluşturuldu, ancak taslak temizliği başarısız oldu.",
"send_delay_unsupported": "Bu hesap için gönderim gecikmesi desteklenmiyor.",
"send_delay_unsupported_confirm": "Bu hesap gönderim gecikmesini desteklemiyor. Hemen gönderilsin mi?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Onayla", "confirm": "Onayla",
@@ -752,7 +784,7 @@
"keep_editing": "Düzenlemeye devam et", "keep_editing": "Düzenlemeye devam et",
"tabs": { "tabs": {
"appearance": "Görünüm", "appearance": "Görünüm",
"language": "Dil ve Bölge", "language": "Dil, Bölge ve Saat",
"email": "E-posta Davranışı", "email": "E-posta Davranışı",
"composer": "Yazıcı", "composer": "Yazıcı",
"privacy": "Gizlilik ve Güvenlik", "privacy": "Gizlilik ve Güvenlik",
@@ -775,6 +807,7 @@
"layout": "Düzen", "layout": "Düzen",
"reading": "Okuma", "reading": "Okuma",
"composing": "Yazma", "composing": "Yazma",
"downloads": "İndirilenler",
"content_senders": "İçerik ve Göndericiler", "content_senders": "İçerik ve Göndericiler",
"about_data": "Hakkında ve Veriler", "about_data": "Hakkında ve Veriler",
"debug": "Hata Ayıklama" "debug": "Hata Ayıklama"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Birleşik Posta Kutusu", "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": { "colorful_sidebar_icons": {
"label": "Renkli Kenar Çubuğu Simgeleri", "label": "Renkli Kenar Çubuğu Simgeleri",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Dil ve Bölge", "title": "Dil, Bölge ve Saat",
"description": "Dil ve bölgesel tercihleri yapılandırın", "description": "Dil, tarih biçimi, saat biçimi ve diğer bölgesel tercihler",
"language": { "language": {
"label": "Dil", "label": "Dil",
"description": "Tercih ettiğiniz dili seçin", "description": "Tercih ettiğiniz dili seçin",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "Tarih Biçimi", "label": "Tarih Biçimi",
"description": "Tarihlerin nasıl görüntüleneceği", "description": "Tarihlerin e-posta listesinde nasıl gösterileceği",
"regional": "Bölgesel", "smart": "Akıllı (bölgeye duyarlı)",
"iso": "ISO 8601", "relative": "Göreceli (1 sa önce, 2 g önce)",
"custom": "Özel" "full": "Her zaman tam tarih",
"preview_today": "Bugün:",
"preview_this_week": "Bu hafta:",
"preview_older": "Daha eski:"
}, },
"time_format": { "time_format": {
"label": "Saat Biçimi", "label": "Saat Biçimi",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Eklerde resim önizlemelerini göster", "label": "Eklerde resim önizlemelerini göster",
"description": "Resim eklerini, genel dosya simgeleri yerine küçük resim kartları olarak göster" "description": "Resim eklerini, genel dosya simgeleri yerine küçük resim kartları olarak göster"
},
"send_delay": {
"label": "Göndermeyi geri al / gönderim gecikmesi",
"description": "Normal gönderimleri sunucu tarafında kısa bir süre geciktirin.",
"off": "Kapalı",
"seconds": "{seconds} saniye",
"unsupported": "Geçerli hesap gecikmeli gönderim desteği bildirmiyor. Ayar diğer hesaplar için kaydedilmeye devam eder."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"categories_description": "Kişi kategorilerini yeniden adlandırın", "categories_description": "Kişi kategorilerini yeniden adlandırın",
"no_categories": "Kategori bulunamadı" "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": { "filters": {
"title": "E-posta Filtreleri", "title": "E-posta Filtreleri",
"description": "Gelen e-postaları otomatik olarak sıralamak, etiketlemek ve yönetmek için kurallar oluşturun", "description": "Gelen e-postaları otomatik olarak sıralamak, etiketlemek ve yönetmek için kurallar oluşturun",
@@ -1751,7 +1842,11 @@
"color_tag": "Etiket", "color_tag": "Etiket",
"remove_color": "Etiketi kaldır", "remove_color": "Etiketi kaldır",
"items_selected": "{count} e-posta seçildi", "items_selected": "{count} e-posta seçildi",
"edit_draft": "Taslağı Düzenle" "edit_draft": "Taslağı Düzenle",
"cancel_scheduled_send": "Göndermeyi iptal et",
"reschedule_send": "Yeniden zamanla",
"cancel_and_edit": "İptal et ve düzenle",
"cancel_and_compose_again": "İptal et ve yeniden yaz"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Klasörü okundu olarak işaretle", "mark_folder_read": "Klasörü okundu olarak işaretle",
@@ -1760,7 +1855,7 @@
"new_subfolder": "Yeni alt klasör...", "new_subfolder": "Yeni alt klasör...",
"new_folder": "Yeni klasör...", "new_folder": "Yeni klasör...",
"rename": "Yeniden adlandı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": "Klasörü boşalt",
"empty_folder_generic": "Klasörü boşalt", "empty_folder_generic": "Klasörü boşalt",
"delete_folder": "Klasörü sil", "delete_folder": "Klasörü sil",
@@ -1829,6 +1924,8 @@
"expand_collapse": "İleti dizisini genişlet/daralt" "expand_collapse": "İleti dizisini genişlet/daralt"
}, },
"composer": { "composer": {
"send": "E-posta gönder",
"schedule_send": "Göndermeyi planla",
"template_picker": "Şablon seçiciyi aç" "template_picker": "Şablon seçiciyi aç"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "Пошта", "mail": "Пошта",
"nav_label": "Навігація", "nav_label": "Навігація",
"add_app": "програми", "add_app": "програми",
"shared": "Спільні" "shared": "Спільні",
"scheduled": "Заплановано"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "Програми за замовчуванням", "title": "Програми за замовчуванням",
@@ -239,7 +240,16 @@
"confirm_button": "Порожня папка", "confirm_button": "Порожня папка",
"junk_hint": "Ви можете очистити папку «Сміття», щоб остаточно видалити всі повідомлення.", "junk_hint": "Ви можете очистити папку «Сміття», щоб остаточно видалити всі повідомлення.",
"trash_hint": "Ви можете очистити папку «Кошик», щоб остаточно видалити всі повідомлення." "trash_hint": "Ви можете очистити папку «Кошик», щоб остаточно видалити всі повідомлення."
} },
"no_scheduled_emails": "Немає запланованих листів",
"no_scheduled_emails_description": "Повідомлення, заплановані на пізніше, з’являться тут.",
"scheduled_count": "{count, plural, one {# запланований лист} few {# заплановані листи} other {# запланованих листів}}",
"scheduled_actions_hint": "Заплановані повідомлення можна скасувати, перенести або редагувати через дії планування.",
"cancel_scheduled_send": "Скасувати надсилання",
"reschedule_send": "Перенести",
"cancel_and_edit": "Скасувати й редагувати",
"cancel_and_compose_again": "Скасувати й написати знову",
"reschedule_prompt": "Введіть нову дату/час, напр. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Електронна адреса не вибрана", "no_email_selected": "Електронна адреса не вибрана",
@@ -270,7 +280,7 @@
"print": "Роздрукувати", "print": "Роздрукувати",
"view_source": "Переглянути джерело", "view_source": "Переглянути джерело",
"export_email": "Експортувати як .eml", "export_email": "Експортувати як .eml",
"import_email": "Імпорт .eml", "import_email": "Імпорт .eml або .zip",
"keyboard_shortcuts": "Комбінації клавіш (?)", "keyboard_shortcuts": "Комбінації клавіш (?)",
"email_source": "Джерело електронної пошти", "email_source": "Джерело електронної пошти",
"draft_banner": "Це повідомлення є чернеткою", "draft_banner": "Це повідомлення є чернеткою",
@@ -514,7 +524,17 @@
"ai_verdict": "Висновок ШІ", "ai_verdict": "Висновок ШІ",
"account": "Обліковий запис", "account": "Обліковий запис",
"no_subject": "(без теми)" "no_subject": "(без теми)"
} },
"scheduled_banner": "Заплановано на надсилання {date}",
"scheduled_send_created": "Лист заплановано до надсилання",
"cancel_scheduled_send": "Скасувати надсилання",
"reschedule_send": "Перенести",
"cancel_and_edit": "Скасувати й редагувати",
"cancel_and_compose_again": "Скасувати й написати знову",
"reschedule_prompt": "Введіть нову дату/час, напр. 2026-05-04T15:30",
"scheduled_actions_only": "У поданні Заплановано доступні лише дії планування",
"undo_send_scheduled": "Повідомлення заплановано до надсилання",
"undo_send": "Скасувати надсилання"
}, },
"email_composer": { "email_composer": {
"new_message": "Нове повідомлення", "new_message": "Нове повідомлення",
@@ -607,7 +627,19 @@
}, },
"add_link": "Додати посилання", "add_link": "Додати посилання",
"link_url_prompt": "Введіть URL", "link_url_prompt": "Введіть URL",
"sending": "Надсилання..." "sending": "Надсилання...",
"schedule_send": "Запланувати надсилання",
"schedule_send_description": "Виберіть, коли сервер має надіслати це повідомлення.",
"schedule_send_required": "Виберіть дату й час.",
"schedule_send_invalid": "Введіть дійсні дату й час.",
"schedule_send_future": "Виберіть дату й час у майбутньому.",
"schedule_send_too_late": "Цей час пізніший, ніж дозволяє сервер.",
"schedule_send_unsupported": "Заплановане надсилання не підтримується для цього облікового запису.",
"schedule_send_cleanup_warning": "Заплановане надсилання створено, але очищення чернетки не вдалося.",
"send_delay_unsupported": "Затримка надсилання не підтримується для цього облікового запису.",
"send_delay_unsupported_confirm": "Цей обліковий запис не підтримує затримку надсилання. Надіслати негайно?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Підтвердити", "confirm": "Підтвердити",
@@ -752,7 +784,7 @@
"keep_editing": "Продовжуйте редагувати", "keep_editing": "Продовжуйте редагувати",
"tabs": { "tabs": {
"appearance": "Зовнішній вигляд", "appearance": "Зовнішній вигляд",
"language": "Мова та регіон", "language": "Мова, регіон і час",
"email": "Поведінка електронної пошти", "email": "Поведінка електронної пошти",
"composer": "Композитор", "composer": "Композитор",
"privacy": "Конфіденційність і безпека", "privacy": "Конфіденційність і безпека",
@@ -775,6 +807,7 @@
"layout": "Макет", "layout": "Макет",
"reading": "Читання", "reading": "Читання",
"composing": "Написання", "composing": "Написання",
"downloads": "Завантаження",
"content_senders": "Вміст і відправники", "content_senders": "Вміст і відправники",
"about_data": "Про програму та дані", "about_data": "Про програму та дані",
"debug": "Налагодження" "debug": "Налагодження"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "Спільна поштова скринька", "label": "Спільна поштова скринька",
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів" "description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів",
"include_group": {
"label": "Включати групові скриньки",
"description": "Також об'єднувати спільні/групові скриньки у спільному перегляді."
}
}, },
"colorful_sidebar_icons": { "colorful_sidebar_icons": {
"label": "Кольорові значки бічної панелі", "label": "Кольорові значки бічної панелі",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "Мова та регіон", "title": "Мова, регіон і час",
"description": "Налаштуйте мовні та регіональні параметри", "description": "Мова, формат дати, формат часу та інші регіональні налаштування",
"language": { "language": {
"label": "Мова", "label": "Мова",
"description": "Виберіть бажану мову", "description": "Виберіть бажану мову",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "Формат дати", "label": "Формат дати",
"description": "Як мають відображатися дати", "description": "Як відображаються дати у списку листів",
"regional": "Регіональний", "smart": "Розумний (за регіоном)",
"iso": "ISO 8601", "relative": "Відносний (1 год тому, 2 дн тому)",
"custom": "Довільний" "full": "Завжди повна дата",
"preview_today": "Сьогодні:",
"preview_this_week": "Цього тижня:",
"preview_older": "Старіше:"
}, },
"time_format": { "time_format": {
"label": "Формат часу", "label": "Формат часу",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "Показувати попередній перегляд зображень у вкладеннях", "label": "Показувати попередній перегляд зображень у вкладеннях",
"description": "Відображати вкладені зображення як ескізи замість загальних піктограм файлів" "description": "Відображати вкладені зображення як ескізи замість загальних піктограм файлів"
},
"send_delay": {
"label": "Скасувати надсилання / затримка надсилання",
"description": "Затримує звичайне надсилання на короткий серверний інтервал.",
"off": "Вимкнено",
"seconds": "{seconds} с",
"unsupported": "Поточний обліковий запис не повідомляє про підтримку відкладеного надсилання. Налаштування залишиться збереженим для інших облікових записів."
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів", "group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів",
"group_by_letter_label": "Групувати за першою літерою" "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": { "filters": {
"title": "Фільтри електронної пошти", "title": "Фільтри електронної пошти",
"description": "Створіть правила для автоматичного сортування, позначення та керування вхідними електронними листами", "description": "Створіть правила для автоматичного сортування, позначення та керування вхідними електронними листами",
@@ -1751,7 +1842,11 @@
"color_tag": "Мітка", "color_tag": "Мітка",
"remove_color": "Видалити мітку", "remove_color": "Видалити мітку",
"items_selected": "Вибрано електронних листів: {count}", "items_selected": "Вибрано електронних листів: {count}",
"edit_draft": "Редагувати чернетку" "edit_draft": "Редагувати чернетку",
"cancel_scheduled_send": "Скасувати надсилання",
"reschedule_send": "Перенести",
"cancel_and_edit": "Скасувати й редагувати",
"cancel_and_compose_again": "Скасувати й написати знову"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "Позначити папку як прочитану", "mark_folder_read": "Позначити папку як прочитану",
@@ -1760,7 +1855,7 @@
"new_subfolder": "Нова вкладена папка...", "new_subfolder": "Нова вкладена папка...",
"new_folder": "Нова папка...", "new_folder": "Нова папка...",
"rename": "Перейменувати...", "rename": "Перейменувати...",
"import_email": "Імпортувати .eml...", "import_email": "Імпортувати .eml або .zip...",
"empty_folder": "Очистити папку", "empty_folder": "Очистити папку",
"empty_folder_generic": "Очистити папку", "empty_folder_generic": "Очистити папку",
"delete_folder": "Видалити папку", "delete_folder": "Видалити папку",
@@ -1829,6 +1924,8 @@
"expand_collapse": "Розгорнути/згорнути ланцюжок" "expand_collapse": "Розгорнути/згорнути ланцюжок"
}, },
"composer": { "composer": {
"send": "Надіслати лист",
"schedule_send": "Запланувати надсилання",
"template_picker": "Відкрити засіб вибору шаблону" "template_picker": "Відкрити засіб вибору шаблону"
} }
}, },
+112 -15
View File
@@ -132,7 +132,8 @@
"mail": "邮件", "mail": "邮件",
"nav_label": "导航", "nav_label": "导航",
"add_app": "应用", "add_app": "应用",
"shared": "共享" "shared": "共享",
"scheduled": "已计划"
}, },
"protocol_handlers": { "protocol_handlers": {
"title": "默认应用", "title": "默认应用",
@@ -239,7 +240,16 @@
"confirm_button": "清空文件夹", "confirm_button": "清空文件夹",
"junk_hint": "您可以清空垃圾邮件文件夹以永久删除所有邮件。", "junk_hint": "您可以清空垃圾邮件文件夹以永久删除所有邮件。",
"trash_hint": "您可以清空已删除文件夹以永久删除所有邮件。" "trash_hint": "您可以清空已删除文件夹以永久删除所有邮件。"
} },
"no_scheduled_emails": "没有计划发送的邮件",
"no_scheduled_emails_description": "计划稍后发送的邮件会显示在这里。",
"scheduled_count": "{count, plural, one {1 封计划发送邮件} other {# 封计划发送邮件}}",
"scheduled_actions_hint": "计划发送的邮件可以通过计划操作取消、重新计划或编辑。",
"cancel_scheduled_send": "取消发送",
"reschedule_send": "重新计划",
"cancel_and_edit": "取消并编辑",
"cancel_and_compose_again": "取消并重新撰写",
"reschedule_prompt": "输入新的日期/时间,例如 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "未选择邮件", "no_email_selected": "未选择邮件",
@@ -270,7 +280,7 @@
"print": "打印", "print": "打印",
"view_source": "查看源码", "view_source": "查看源码",
"export_email": "导出为 .eml", "export_email": "导出为 .eml",
"import_email": "导入 .eml", "import_email": "导入 .eml 或 .zip",
"keyboard_shortcuts": "键盘快捷键(?)", "keyboard_shortcuts": "键盘快捷键(?)",
"email_source": "邮件源码", "email_source": "邮件源码",
"draft_banner": "这是一封草稿邮件", "draft_banner": "这是一封草稿邮件",
@@ -514,7 +524,17 @@
"ai_verdict": "AI 判断", "ai_verdict": "AI 判断",
"account": "账户", "account": "账户",
"no_subject": "(无主题)" "no_subject": "(无主题)"
} },
"scheduled_banner": "计划于 {date} 发送",
"scheduled_send_created": "邮件已计划发送",
"cancel_scheduled_send": "取消发送",
"reschedule_send": "重新计划",
"cancel_and_edit": "取消并编辑",
"cancel_and_compose_again": "取消并重新撰写",
"reschedule_prompt": "输入新的日期/时间,例如 2026-05-04T15:30",
"scheduled_actions_only": "计划发送视图仅支持计划操作",
"undo_send_scheduled": "邮件已计划发送",
"undo_send": "撤销发送"
}, },
"email_composer": { "email_composer": {
"new_message": "新邮件", "new_message": "新邮件",
@@ -607,7 +627,19 @@
}, },
"add_link": "添加链接", "add_link": "添加链接",
"link_url_prompt": "输入 URL", "link_url_prompt": "输入 URL",
"sending": "发送中..." "sending": "发送中...",
"schedule_send": "计划发送",
"schedule_send_description": "选择服务器应何时发送此邮件。",
"schedule_send_required": "请选择日期和时间。",
"schedule_send_invalid": "请输入有效的日期和时间。",
"schedule_send_future": "请选择未来的日期和时间。",
"schedule_send_too_late": "该时间晚于服务器允许的范围。",
"schedule_send_unsupported": "此账户不支持计划发送。",
"schedule_send_cleanup_warning": "已创建计划发送,但清理草稿失败。",
"send_delay_unsupported": "此账户不支持发送延迟。",
"send_delay_unsupported_confirm": "此账户不支持发送延迟。是否立即发送?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "确认", "confirm": "确认",
@@ -752,7 +784,7 @@
"keep_editing": "继续编辑", "keep_editing": "继续编辑",
"tabs": { "tabs": {
"appearance": "外观", "appearance": "外观",
"language": "语言地区", "language": "语言地区与时间",
"email": "邮件行为", "email": "邮件行为",
"composer": "邮件撰写", "composer": "邮件撰写",
"privacy": "隐私与安全", "privacy": "隐私与安全",
@@ -775,6 +807,7 @@
"layout": "布局", "layout": "布局",
"reading": "阅读", "reading": "阅读",
"composing": "撰写", "composing": "撰写",
"downloads": "下载",
"content_senders": "内容和发件人", "content_senders": "内容和发件人",
"about_data": "关于和数据", "about_data": "关于和数据",
"debug": "调试" "debug": "调试"
@@ -843,7 +876,11 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"label": "统一邮箱", "label": "统一邮箱",
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)" "description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)",
"include_group": {
"label": "包含群组收件箱",
"description": "在统一视图中也合并共享/群组收件箱。"
}
}, },
"colorful_sidebar_icons": { "colorful_sidebar_icons": {
"label": "彩色侧边栏图标", "label": "彩色侧边栏图标",
@@ -927,8 +964,8 @@
} }
}, },
"language_region": { "language_region": {
"title": "语言地区", "title": "语言地区与时间",
"description": "配置语言和区域首选项", "description": "语言、日期格式、时间格式以及其他区域设置",
"language": { "language": {
"label": "语言", "label": "语言",
"description": "选择您的首选语言", "description": "选择您的首选语言",
@@ -937,10 +974,13 @@
}, },
"date_format": { "date_format": {
"label": "日期格式", "label": "日期格式",
"description": "日期应如何显示", "description": "电子邮件列表中日期的显示方式",
"regional": "区域格式", "smart": "智能(按地区)",
"iso": "ISO 8601", "relative": "相对时间(1 小时前、2 天前)",
"custom": "自定义" "full": "始终完整日期",
"preview_today": "今天:",
"preview_this_week": "本周:",
"preview_older": "更早:"
}, },
"time_format": { "time_format": {
"label": "时间格式", "label": "时间格式",
@@ -1126,6 +1166,13 @@
"attachment_image_previews": { "attachment_image_previews": {
"label": "在附件中显示图片预览", "label": "在附件中显示图片预览",
"description": "将图片附件显示为缩略图卡片,而不是通用文件图标" "description": "将图片附件显示为缩略图卡片,而不是通用文件图标"
},
"send_delay": {
"label": "撤销发送 / 发送延迟",
"description": "通过服务器端短暂窗口延迟普通发送。",
"off": "关闭",
"seconds": "{seconds} 秒",
"unsupported": "当前账户未声明支持延迟发送。该设置仍会为其他账户保存。"
} }
}, },
"composer": { "composer": {
@@ -1495,6 +1542,50 @@
"group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题", "group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题",
"group_by_letter_label": "按首字母分组" "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": { "filters": {
"title": "邮件过滤器", "title": "邮件过滤器",
"description": "创建规则,自动整理、标记和管理收到的邮件", "description": "创建规则,自动整理、标记和管理收到的邮件",
@@ -1751,7 +1842,11 @@
"color_tag": "标签", "color_tag": "标签",
"remove_color": "删除标签", "remove_color": "删除标签",
"items_selected": "已选择 {count} 封邮件", "items_selected": "已选择 {count} 封邮件",
"edit_draft": "编辑草稿" "edit_draft": "编辑草稿",
"cancel_scheduled_send": "取消发送",
"reschedule_send": "重新计划",
"cancel_and_edit": "取消并编辑",
"cancel_and_compose_again": "取消并重新撰写"
}, },
"mailbox_context_menu": { "mailbox_context_menu": {
"mark_folder_read": "将文件夹标记为已读", "mark_folder_read": "将文件夹标记为已读",
@@ -1760,7 +1855,7 @@
"new_subfolder": "新建子文件夹...", "new_subfolder": "新建子文件夹...",
"new_folder": "新建文件夹...", "new_folder": "新建文件夹...",
"rename": "重命名...", "rename": "重命名...",
"import_email": "导入 .eml...", "import_email": "导入 .eml 或 .zip...",
"empty_folder": "清空文件夹", "empty_folder": "清空文件夹",
"empty_folder_generic": "清空文件夹", "empty_folder_generic": "清空文件夹",
"delete_folder": "删除文件夹", "delete_folder": "删除文件夹",
@@ -1829,6 +1924,8 @@
"expand_collapse": "展开/折叠会话" "expand_collapse": "展开/折叠会话"
}, },
"composer": { "composer": {
"send": "发送邮件",
"schedule_send": "定时发送",
"template_picker": "打开模板选择器" "template_picker": "打开模板选择器"
} }
}, },
-12
View File
@@ -5971,7 +5971,6 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -7643,17 +7642,6 @@
} }
} }
}, },
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+3 -3
View File
@@ -97,8 +97,8 @@ export async function proxy(request: NextRequest) {
const pluginFrameOrigins = await getEnabledPluginFrameOrigins(); const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
const frameSrc = const frameSrc =
pluginFrameOrigins.length > 0 pluginFrameOrigins.length > 0
? `frame-src 'self' ${pluginFrameOrigins.join(" ")}` ? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
: `frame-src 'self'`; : `frame-src 'self' blob:`;
const csp = [ const csp = [
`default-src 'self'`, `default-src 'self'`,
@@ -108,7 +108,7 @@ export async function proxy(request: NextRequest) {
`font-src 'self'`, `font-src 'self'`,
`connect-src ${connectSrc}`, `connect-src ${connectSrc}`,
frameSrc, frameSrc,
`object-src 'none'`, `object-src 'self' blob:`,
`base-uri 'self'`, `base-uri 'self'`,
`form-action 'self'`, `form-action 'self'`,
`frame-ancestors ${frameAncestors}`, `frame-ancestors ${frameAncestors}`,
+10 -3
View File
@@ -7,6 +7,7 @@ import { normalizeAllDayDuration } from '@/lib/calendar-utils';
import { parseDuration } from '@/components/calendar/event-card'; import { parseDuration } from '@/components/calendar/event-card';
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { parseISO } from 'date-fns';
import { generateUUID } from '@/lib/utils'; import { generateUUID } from '@/lib/utils';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar'; import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
@@ -302,8 +303,12 @@ export const useCalendarStore = create<CalendarStore>()(
after: start, after: start,
before: end, before: end,
}); });
// Filter out malformed events missing required 'start' field // Filter out malformed events missing required 'start' field, or
const validEvents = rawEvents.filter(e => typeof e.start === 'string' && e.start); // 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; const droppedEvents = rawEvents.length - validEvents.length;
// Expand recurring events client-side (Stalwart doesn't support // Expand recurring events client-side (Stalwart doesn't support
// mutations on synthetic IDs from server-side expandRecurrences) // mutations on synthetic IDs from server-side expandRecurrences)
@@ -366,7 +371,9 @@ export const useCalendarStore = create<CalendarStore>()(
accounts.map(async ({ client, localAccountId }) => { accounts.map(async ({ client, localAccountId }) => {
try { try {
const raw = await client.queryAllCalendarEvents({ after: start, before: end }); 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); const expanded = expandRecurringEvents(valid, start, end);
return prefixEventsWithLocalAccount( return prefixEventsWithLocalAccount(
expanded, expanded,
+414 -44
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types"; import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult } from "@/lib/jmap/types";
import type { UnifiedMailboxRole } from "@/lib/jmap/types"; import type { UnifiedMailboxRole } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
@@ -11,6 +11,17 @@ import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, adv
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
type ScheduledSubmissionMetadata = {
submissionId: string;
sendAt: string;
identityId: string;
undoStatus: 'pending' | 'final' | 'canceled';
};
const VIRTUAL_SCHEDULED_MAILBOX_ID = '__scheduled__';
type PendingUndoSend = { submissionId: string; emailId?: string; sendAt: string; isSmime: boolean };
interface EmailStore { interface EmailStore {
emails: Email[]; emails: Email[];
mailboxes: Mailbox[]; mailboxes: Mailbox[];
@@ -67,6 +78,17 @@ interface EmailStore {
unifiedErrors: Map<string, string>; // accountId -> error message unifiedErrors: Map<string, string>; // accountId -> error message
unifiedCounts: UnifiedMailboxCounts[]; unifiedCounts: UnifiedMailboxCounts[];
// Scheduled send state
scheduledEmails: ScheduledEmail[];
scheduledEmailIds: Set<string>;
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>;
scheduledTotal: number;
scheduledHasMore: boolean;
scheduledNextPosition: number;
isLoadingScheduled: boolean;
isScheduledView: boolean;
pendingUndoSend: PendingUndoSend | null;
setEmails: (emails: Email[]) => void; setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void; setMailboxes: (mailboxes: Mailbox[]) => void;
/** Cache or update the mailbox list for a specific account. */ /** Cache or update the mailbox list for a specific account. */
@@ -111,8 +133,8 @@ interface EmailStore {
loadMoreEmails: (client: IJMAPClient) => Promise<void>; loadMoreEmails: (client: IJMAPClient) => Promise<void>;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>; fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>; fetchQuota: (client: IJMAPClient) => Promise<void>;
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], envelopeMailFrom?: string) => Promise<void>; sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string) => Promise<SendEmailResult>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>; sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>; deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>; moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
@@ -126,11 +148,16 @@ interface EmailStore {
* pass the active account's id explicitly (no `__default__` sentinel). * pass the active account's id explicitly (no `__default__` sentinel).
* `destMailboxId` is the raw JMAP id on the destination server (not the * `destMailboxId` is the raw JMAP id on the destination server (not the
* `accountId:mailboxId` namespace used for shared folders). * `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: ( crossAccountMoveEmails: (
emailIdsBySource: Map<string, string[]>, emailIdsBySource: Map<string, string[]>,
destAccountId: string, destAccountId: string,
destMailboxId: string, destMailboxId: string,
destJmapAccountId?: string,
) => Promise<void>; ) => Promise<void>;
searchEmails: (client: IJMAPClient, query: string) => Promise<void>; searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
advancedSearch: (client: IJMAPClient) => Promise<void>; advancedSearch: (client: IJMAPClient) => Promise<void>;
@@ -180,6 +207,16 @@ interface EmailStore {
refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<void>; refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<void>;
exitUnifiedView: () => void; exitUnifiedView: () => void;
fetchScheduledEmails: (client: IJMAPClient) => Promise<void>;
loadMoreScheduledEmails: (client: IJMAPClient) => Promise<void>;
cancelScheduledEmail: (client: IJMAPClient, submissionId: string, emailId?: string) => Promise<void>;
cancelScheduledEmailForEdit: (client: IJMAPClient, email: ScheduledEmail | Email) => Promise<Email | null>;
rescheduleScheduledEmail: (client: IJMAPClient, submissionId: string, emailId: string, identityId: string, delayedUntil: string) => Promise<SendEmailResult>;
cancelUndoSend: (client: IJMAPClient, pending: PendingUndoSend) => Promise<Email | null>;
clearPendingUndoSend: () => void;
refreshScheduledMetadata: (client: IJMAPClient) => Promise<void>;
setScheduledView: (isScheduledView: boolean) => void;
// Mock data for demo // Mock data for demo
loadMockData: () => void; loadMockData: () => void;
} }
@@ -214,6 +251,38 @@ function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | n
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId])); return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
} }
function annotateScheduledEmails(
emails: Email[],
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
): Email[] {
if (scheduledSubmissionByEmailId.size === 0) return emails;
return emails.map(email => annotateScheduledEmail(email, scheduledSubmissionByEmailId));
}
function annotateScheduledEmail(
email: Email,
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
): Email {
const scheduled = scheduledSubmissionByEmailId.get(email.id);
if (!scheduled) return email;
return {
...email,
scheduledSendAt: scheduled.sendAt,
emailSubmissionId: scheduled.submissionId,
scheduledIdentityId: scheduled.identityId,
scheduledUndoStatus: scheduled.undoStatus,
isScheduled: true,
};
}
function shouldClearPendingUndoSend(pending: PendingUndoSend | null, scheduledEmails: ScheduledEmail[]): boolean {
if (!pending) return false;
const pendingSendTime = new Date(pending.sendAt).getTime();
if (Number.isFinite(pendingSendTime) && pendingSendTime <= Date.now()) return true;
const scheduledEmail = scheduledEmails.find(email => email.emailSubmissionId === pending.submissionId);
return scheduledEmail?.scheduledUndoStatus !== undefined && scheduledEmail.scheduledUndoStatus !== 'pending';
}
/** /**
* When the mail view is showing a non-active account (Pro shell's * When the mail view is showing a non-active account (Pro shell's
* Thunderbird-style sidebar), redirect read/write operations to that * Thunderbird-style sidebar), redirect read/write operations to that
@@ -239,20 +308,22 @@ function resolveActionMailboxes(): Mailbox[] {
return state.mailboxes; return state.mailboxes;
} }
/**
* After a mailbox-list mutation (create/rename/delete/etc.), refresh the
* cache for whichever account we're operating on. Writes the result to the
* standard `mailboxes` slot for the active account, or the per-account
* cache for non-active accounts so the Pro sidebar stays in sync.
*/
/** /**
* Builds the `UnifiedAccountClient[]` list used by every unified fan-out * Builds the `UnifiedAccountClient[]` list used by every unified fan-out
* action (browse, load-more, search). Each entry has a JMAP client plus a * action (browse, load-more, search). Each entry has a JMAP client plus a
* fresh mailbox list so the helpers can resolve the role mailbox per account. * fresh mailbox list so the helpers can resolve the role mailbox per account.
* Accounts whose mailbox fetch fails are skipped - the unified result will * Accounts whose mailbox fetch fails are skipped - the unified result will
* surface that in its per-account error map. * 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 authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected);
const allClients = useAuthStore.getState().getAllConnectedClients(); const allClients = useAuthStore.getState().getAllConnectedClients();
const built: UnifiedAccountClient[] = []; const built: UnifiedAccountClient[] = [];
@@ -260,8 +331,31 @@ async function buildUnifiedAccountClients(): Promise<UnifiedAccountClient[]> {
const c = allClients.get(a.id); const c = allClients.get(a.id);
if (!c) continue; if (!c) continue;
try { try {
const mailboxes = await c.getMailboxes(); const mailboxes = includeGroup ? await c.getAllMailboxes() : await c.getMailboxes();
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes }); 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 { } catch {
/* skip account on mailbox fetch failure */ /* skip account on mailbox fetch failure */
} }
@@ -269,6 +363,12 @@ async function buildUnifiedAccountClients(): Promise<UnifiedAccountClient[]> {
return built; return built;
} }
/**
* After a mailbox-list mutation (create/rename/delete/etc.), refresh the
* cache for whichever account we're operating on. Writes the result to the
* standard `mailboxes` slot for the active account, or the per-account
* cache for non-active accounts so the Pro sidebar stays in sync.
*/
async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise<void> { async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise<void> {
const viewingId = useEmailStore.getState().viewingAccountId; const viewingId = useEmailStore.getState().viewingAccountId;
const client = resolveActionClient(fallbackClient); const client = resolveActionClient(fallbackClient);
@@ -351,6 +451,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
unifiedErrors: new Map(), unifiedErrors: new Map(),
unifiedCounts: [], unifiedCounts: [],
// Scheduled send state
scheduledEmails: [],
scheduledEmailIds: new Set(),
scheduledSubmissionByEmailId: new Map(),
scheduledTotal: 0,
scheduledHasMore: false,
scheduledNextPosition: 0,
isLoadingScheduled: false,
isScheduledView: false,
pendingUndoSend: null,
// Spam undo cache // Spam undo cache
spamUndoCache: new Map(), spamUndoCache: new Map(),
@@ -480,7 +591,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Auto-select inbox if no mailbox is selected or the current selection // Auto-select inbox if no mailbox is selected or the current selection
// doesn't exist in the fetched list (e.g. after an account switch) // doesn't exist in the fetched list (e.g. after an account switch)
const currentSelectedMailbox = get().selectedMailbox; const currentSelectedMailbox = get().selectedMailbox;
const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox); const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID
|| (currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox));
const loadingPatch = isInitialLoad ? { isLoading: false } : {}; const loadingPatch = isInitialLoad ? { isLoading: false } : {};
if (!selectionValid) { if (!selectionValid) {
// Find inbox from PRIMARY account (not shared accounts) // Find inbox from PRIMARY account (not shared accounts)
@@ -532,6 +644,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoading: true, error: null }); // Keep previous emails visible during transition set({ isLoading: true, error: null }); // Keep previous emails visible during transition
try { try {
const targetMailboxId = mailboxId || get().selectedMailbox; const targetMailboxId = mailboxId || get().selectedMailbox;
if (targetMailboxId === VIRTUAL_SCHEDULED_MAILBOX_ID) {
set({ isLoading: false, emails: [], hasMoreEmails: false, totalEmails: 0 });
await get().fetchScheduledEmails(client);
return;
}
const effectiveClient = resolveActionClient(client); const effectiveClient = resolveActionClient(client);
// Find the mailbox to get its accountId (for shared folder support) // Find the mailbox to get its accountId (for shared folder support)
@@ -553,7 +670,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// all folders that carry the tag are returned. // all folders that carry the tag are returned.
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter); const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
set({ set({
emails: result.emails, emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore, hasMoreEmails: result.hasMore,
totalEmails: result.total, totalEmails: result.total,
isLoading: false isLoading: false
@@ -583,8 +700,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoadingMore: true, error: null }); set({ isLoadingMore: true, error: null });
try { try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage; const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const position = emails.length; const position = emails.length;
const built = await buildUnifiedAccountClients(); const built = await buildUnifiedAccountClients({ includeGroup });
const { searchFilters } = get(); const { searchFilters } = get();
const hasFilters = !isFilterEmpty(searchFilters); const hasFilters = !isFilterEmpty(searchFilters);
const result = hasFilters const result = hasFilters
@@ -620,6 +738,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoadingMore: true, error: null }); set({ isLoadingMore: true, error: null });
try { try {
if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
set({ isLoadingMore: false });
await get().loadMoreScheduledEmails(client);
return;
}
const effectiveClient = resolveActionClient(client); const effectiveClient = resolveActionClient(client);
// Get emails per page from settings // Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage; const emailsPerPage = useSettingsStore.getState().emailsPerPage;
@@ -665,7 +789,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Deduplicate: the server may return overlapping results if new emails // Deduplicate: the server may return overlapping results if new emails
// arrived between paginated requests and shifted positions. // arrived between paginated requests and shifted positions.
const existingIds = new Set(currentEmails.map(e => e.id)); const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter((e: Email) => !existingIds.has(e.id)); const newEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId).filter((e: Email) => !existingIds.has(e.id));
set({ set({
emails: [...currentEmails, ...newEmails], emails: [...currentEmails, ...newEmails],
@@ -695,7 +819,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const email = await resolveActionClient(client).getEmail(emailId, accountId); const email = await resolveActionClient(client).getEmail(emailId, accountId);
if (email) { if (email) {
set({ selectedEmail: email }); const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId);
set({ selectedEmail: annotatedEmail });
return annotatedEmail;
} }
return email; return email;
} catch (error) { } catch (error) {
@@ -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 }); set({ isLoading: true, error: null });
try { try {
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, envelopeMailFrom); const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom);
// Refresh handled by UI layer for immediate feedback // Refresh handled by UI layer for immediate feedback
set({ isLoading: false }); set({
isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: false }
: get().pendingUndoSend,
});
return result;
} catch (error) { } catch (error) {
set({ set({
error: error instanceof Error ? error.message : "Failed to send email", error: error instanceof Error ? error.message : "Failed to send email",
@@ -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 }); set({ isLoading: true, error: null });
try { try {
const mailboxes = await client.getMailboxes(); const mailboxes = await client.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (!sentMailbox) throw new Error('No sent mailbox found'); if (!sentMailbox) throw new Error('No sent mailbox found');
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id); const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil, envelopeRecipients);
set({ isLoading: false }); set({
isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: true }
: get().pendingUndoSend,
});
return result;
} catch (error) { } catch (error) {
set({ set({
error: error instanceof Error ? error.message : "Failed to send email", error: error instanceof Error ? error.message : "Failed to send email",
@@ -773,14 +911,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
forceDelete = true; forceDelete = true;
} }
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox // If deleteAction is 'trash' or 'trash-and-read' and not forced permanent delete, try to move to trash mailbox
if (deleteAction === 'trash' && !forceDelete) { if ((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete) {
const trashMailbox = findTrashMailbox(mailboxes, { accountId }); const trashMailbox = findTrashMailbox(mailboxes, { accountId });
const alsoMarkRead = deleteAction === 'trash-and-read' && isUnread;
if (trashMailbox) { if (trashMailbox) {
// Use originalId for shared mailboxes if available // Use originalId for shared mailboxes if available
const trashId = trashMailbox.originalId || trashMailbox.id; 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) // Remove from local state (email moved to trash, not in current view)
set((state) => { set((state) => {
@@ -803,9 +945,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return { return {
...mailbox, ...mailbox,
totalEmails: mailbox.totalEmails + 1, totalEmails: mailbox.totalEmails + 1,
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails, unreadEmails: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
totalThreads: mailbox.totalThreads + 1, totalThreads: mailbox.totalThreads + 1,
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
}; };
} }
return mailbox; 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; if (emailIdsBySource.size === 0) return;
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
@@ -1124,7 +1266,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} }
const blob = await sourceClient.fetchBlob(full.blobId); const blob = await sourceClient.fetchBlob(full.blobId);
const keywords: Record<string, boolean> = { ...(full.keywords ?? {}) }; 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); await sourceClient.deleteEmail(emailId);
return emailId; return emailId;
}), }),
@@ -1257,7 +1399,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const emailsPerPage = useSettingsStore.getState().emailsPerPage; const emailsPerPage = useSettingsStore.getState().emailsPerPage;
if (isUnifiedView && unifiedRole) { 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 result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({ set({
@@ -1283,7 +1426,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({ set({
emails: result.emails, emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
externalSearchResults: externals, externalSearchResults: externals,
hasMoreEmails: result.hasMore, hasMoreEmails: result.hasMore,
totalEmails: result.total, totalEmails: result.total,
@@ -1323,7 +1466,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const emailsPerPage = useSettingsStore.getState().emailsPerPage; const emailsPerPage = useSettingsStore.getState().emailsPerPage;
if (isUnifiedView && unifiedRole) { if (isUnifiedView && unifiedRole) {
const built = await buildUnifiedAccountClients(); const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup });
const result = await advancedSearchUnifiedEmails( const result = await advancedSearchUnifiedEmails(
built, built,
unifiedRole, unifiedRole,
@@ -1357,7 +1501,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters }); const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({ set({
emails: result.emails, emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
externalSearchResults: externals, externalSearchResults: externals,
hasMoreEmails: result.hasMore, hasMoreEmails: result.hasMore,
totalEmails: result.total, totalEmails: result.total,
@@ -1514,6 +1658,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
const isInJunk = currentMailbox?.role === 'junk'; const isInJunk = currentMailbox?.role === 'junk';
const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk); 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). // Group emails by accountId (handles unified view and search results spanning accounts).
const emailsByAccount = new Map<string, string[]>(); const emailsByAccount = new Map<string, string[]>();
@@ -1554,7 +1699,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return; return;
} }
const trashId = trashMailbox.originalId || trashMailbox.id; 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)); ids.forEach(id => movedEmailIds.add(id));
}); });
await Promise.allSettled(promises); await Promise.allSettled(promises);
@@ -1745,7 +1890,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}); });
try { 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 => ({ set(state => ({
emails: state.emails.filter(e => e.id !== emailId), emails: state.emails.filter(e => e.id !== emailId),
@@ -1800,16 +1947,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}, },
batchMarkAsSpam: async (client, emailIds) => { batchMarkAsSpam: async (client, emailIds) => {
const { selectedMailbox } = get(); const { selectedMailbox, emails } = get();
const mailboxes = resolveActionMailboxes(); const mailboxes = resolveActionMailboxes();
const effectiveClient = resolveActionClient(client); const effectiveClient = resolveActionClient(client);
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return; if (!currentMailbox) return;
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
try { try {
for (const emailId of emailIds) { 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 => ({ set(state => ({
@@ -1880,6 +2031,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
get().fetchTagCounts(client); get().fetchTagCounts(client);
} }
if (accountChanges.EmailSubmission) {
await get().refreshScheduledMetadata(client);
if (get().isScheduledView) {
await get().fetchScheduledEmails(client);
}
}
// Handle Mailbox state changes - refresh mailbox list // Handle Mailbox state changes - refresh mailbox list
if (accountChanges.Mailbox) { if (accountChanges.Mailbox) {
await get().fetchMailboxes(client); await get().fetchMailboxes(client);
@@ -1927,6 +2085,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Only refresh if a mailbox is currently selected // Only refresh if a mailbox is currently selected
if (!selectedMailbox) return; if (!selectedMailbox) return;
if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
await get().fetchScheduledEmails(client);
return;
}
try { try {
// Fetch emails for the current mailbox without clearing the list first // Fetch emails for the current mailbox without clearing the list first
// This provides a smoother update experience // This provides a smoother update experience
@@ -1953,6 +2116,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} }
const currentEmails = get().emails; const currentEmails = get().emails;
const previousTotal = get().totalEmails;
// Only notify for genuinely new incoming mail in the Inbox. // Only notify for genuinely new incoming mail in the Inbox.
// Without these guards the toast/sound also fires when sending, // Without these guards the toast/sound also fires when sending,
@@ -1970,15 +2134,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Merge the refreshed first page with the existing loaded emails. // Merge the refreshed first page with the existing loaded emails.
// This avoids discarding already-loaded pages which would cause the // This avoids discarding already-loaded pages which would cause the
// virtual list to shrink and then rapidly re-load (scroll bounce). // virtual list to shrink and then rapidly re-load (scroll bounce).
const freshMap = new Map(result.emails.map((e: Email) => [e.id, e])); const refreshedEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId);
// Build the merged list: start with the fresh first page, then append // Build the merged list: start with the fresh first page, then append
// existing emails beyond that page (if any), skipping duplicates and // existing emails beyond that page (if any), skipping duplicates. Do not
// emails removed from the first page (e.g. deleted or moved). // append the whole previous list: drafts are saved as destroy+create, so
const merged: Email[] = [...result.emails]; // the old draft can disappear from the refreshed first page and must not
const mergedIds = new Set(result.emails.map((e: Email) => e.id)); // be reintroduced from stale local state.
const merged: Email[] = [...refreshedEmails];
const mergedIds = new Set(refreshedEmails.map((e: Email) => e.id));
const insertedCount = Math.max((result.total || 0) - previousTotal, 0);
const appendFromIndex = Math.max(refreshedEmails.length - insertedCount, 0);
for (const email of currentEmails) { for (const email of currentEmails.slice(appendFromIndex)) {
if (!mergedIds.has(email.id)) { if (!mergedIds.has(email.id)) {
merged.push(email); merged.push(email);
mergedIds.add(email.id); mergedIds.add(email.id);
@@ -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: () => { loadMockData: () => {
const mockEmails: Email[] = [ const mockEmails: Email[] = [
{ {
+13 -2
View File
@@ -333,7 +333,12 @@ export const useFileStore = create<FileState>((set, get) => ({
try { try {
if (abortController.signal.aborted) return; 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; if (abortController.signal.aborted) return;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } }); 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); 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 } }); set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } });
try { 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; if (abortController.signal.aborted) break;
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); 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); await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null);
+17
View File
@@ -353,6 +353,14 @@ export const useProTabStore = create<ProTabState>()(
const target = state.tabs.find((t) => t.id === targetTabId); const target = state.tabs.find((t) => t.id === targetTabId);
if (!dragged || !target) return; 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 next = state.tabs.filter((t) => t.id !== draggedId);
const insertAt = next.findIndex((t) => t.id === targetTabId) + (edge === 'after' ? 1 : 0); const insertAt = next.findIndex((t) => t.id === targetTabId) + (edge === 'after' ? 1 : 0);
const reassigned: ProTab = dragged.paneId === target.paneId const reassigned: ProTab = dragged.paneId === target.paneId
@@ -504,6 +512,15 @@ export const useProTabStore = create<ProTabState>()(
state.loadedTabIds = [HOME_TAB.id]; state.loadedTabIds = [HOME_TAB.id];
return; 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')) { 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; state.activeTabId = state.tabs.find((t) => t.paneId === 'main')?.id ?? HOME_TAB.id;
} }
+44 -4
View File
@@ -28,10 +28,10 @@ export type FontSize = 'small' | 'medium' | 'large';
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
/** @deprecated Use Density instead */ /** @deprecated Use Density instead */
export type ListDensity = Density; export type ListDensity = Density;
export type DeleteAction = 'trash' | 'permanent'; export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
export type ReplyMode = 'reply' | 'replyAll'; export type ReplyMode = 'reply' | 'replyAll';
export type SignaturePosition = 'above_quote' | 'below_quote'; 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 TimeFormat = '12h' | '24h';
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
export type ExternalContentPolicy = 'ask' | 'block' | 'allow'; export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
@@ -41,6 +41,7 @@ export type ToolbarPosition = 'top' | 'below-subject';
export type ArchiveMode = 'single' | 'year' | 'month'; export type ArchiveMode = 'single' | 'year' | 'month';
export type MailLayout = 'split' | 'focus' | 'horizontal'; export type MailLayout = 'split' | 'focus' | 'horizontal';
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s'; export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
export type SendDelaySeconds = 0 | 10 | 30 | 60;
export type ProtocolOpenMode = 'active-session' | 'new-tab'; export type ProtocolOpenMode = 'active-session' | 'new-tab';
/** /**
@@ -153,6 +154,7 @@ interface SettingsState {
autoSelectReplyIdentity: boolean; autoSelectReplyIdentity: boolean;
plainTextMode: boolean; // Send plain text only (no rich text editor) plainTextMode: boolean; // Send plain text only (no rich text editor)
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@") subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
sendDelaySeconds: SendDelaySeconds;
signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards
signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter
@@ -202,6 +204,7 @@ interface SettingsState {
// Unified Mailbox // Unified Mailbox
enableUnifiedMailbox: boolean; enableUnifiedMailbox: boolean;
includeGroupInUnified: boolean;
// Email Display // Email Display
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
@@ -239,6 +242,16 @@ interface SettingsState {
tourCompleted: boolean; // Interactive tour completed tourCompleted: boolean; // Interactive tour completed
showOnboardingOnNewDevices: boolean; // When true, onboarding shows again on each new device 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 // Advanced
debugMode: boolean; debugMode: boolean;
debugCategories: Record<DebugCategory, boolean>; debugCategories: Record<DebugCategory, boolean>;
@@ -289,7 +302,7 @@ const DEFAULT_SETTINGS = {
animationsEnabled: true, animationsEnabled: true,
// Language & Region // Language & Region
dateFormat: 'regional' as DateFormat, dateFormat: 'smart' as DateFormat,
timeFormat: '24h' as TimeFormat, timeFormat: '24h' as TimeFormat,
firstDayOfWeek: 1 as FirstDayOfWeek, // Monday firstDayOfWeek: 1 as FirstDayOfWeek, // Monday
@@ -316,6 +329,7 @@ const DEFAULT_SETTINGS = {
autoSelectReplyIdentity: false, autoSelectReplyIdentity: false,
plainTextMode: false, plainTextMode: false,
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
sendDelaySeconds: 0 as SendDelaySeconds,
signaturePosition: 'below_quote' as SignaturePosition, signaturePosition: 'below_quote' as SignaturePosition,
signatureSeparatorEnabled: true, signatureSeparatorEnabled: true,
@@ -365,6 +379,7 @@ const DEFAULT_SETTINGS = {
// Unified Mailbox // Unified Mailbox
enableUnifiedMailbox: false, enableUnifiedMailbox: false,
includeGroupInUnified: false,
// Email Display // Email Display
disableThreading: false, disableThreading: false,
@@ -424,6 +439,16 @@ const DEFAULT_SETTINGS = {
tourCompleted: false, tourCompleted: false,
showOnboardingOnNewDevices: 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 // Advanced
debugMode: false, debugMode: false,
debugCategories: { debugCategories: {
@@ -498,6 +523,7 @@ export const useSettingsStore = create<SettingsState>()(
autoSelectReplyIdentity: state.autoSelectReplyIdentity, autoSelectReplyIdentity: state.autoSelectReplyIdentity,
plainTextMode: state.plainTextMode, plainTextMode: state.plainTextMode,
subAddressDelimiter: state.subAddressDelimiter, subAddressDelimiter: state.subAddressDelimiter,
sendDelaySeconds: state.sendDelaySeconds,
signaturePosition: state.signaturePosition, signaturePosition: state.signaturePosition,
signatureSeparatorEnabled: state.signatureSeparatorEnabled, signatureSeparatorEnabled: state.signatureSeparatorEnabled,
sessionTimeout: state.sessionTimeout, sessionTimeout: state.sessionTimeout,
@@ -523,6 +549,7 @@ export const useSettingsStore = create<SettingsState>()(
// proInterface is intentionally omitted - it's a per-device choice // proInterface is intentionally omitted - it's a per-device choice
// (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced. // (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced.
enableUnifiedMailbox: state.enableUnifiedMailbox, enableUnifiedMailbox: state.enableUnifiedMailbox,
includeGroupInUnified: state.includeGroupInUnified,
senderFavicons: state.senderFavicons, senderFavicons: state.senderFavicons,
showAvatarsInJunk: state.showAvatarsInJunk, showAvatarsInJunk: state.showAvatarsInJunk,
colorfulSidebarIcons: state.colorfulSidebarIcons, colorfulSidebarIcons: state.colorfulSidebarIcons,
@@ -566,6 +593,10 @@ export const useSettingsStore = create<SettingsState>()(
if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) { if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) {
return; return;
} }
if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(settings[key])) {
set({ sendDelaySeconds: 0 });
return;
}
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) { if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
return; return;
} }
@@ -737,17 +768,26 @@ export const useSettingsStore = create<SettingsState>()(
}), }),
{ {
name: 'settings-storage', name: 'settings-storage',
version: 3, version: 4,
migrate: (persisted, version) => { migrate: (persisted, version) => {
const state = persisted as Record<string, unknown>; const state = persisted as Record<string, unknown>;
if (version < 2 && state.listDensity) { if (version < 2 && state.listDensity) {
state.density = state.listDensity; state.density = state.listDensity;
delete state.listDensity; delete state.listDensity;
} }
if (![0, 10, 30, 60].includes(state.sendDelaySeconds as number)) {
state.sendDelaySeconds = 0;
}
if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') { if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') {
state.protocolOpenMode = state.protocolMailtoOpenMode; state.protocolOpenMode = state.protocolMailtoOpenMode;
} }
delete 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; return state as unknown as SettingsState;
}, },
onRehydrateStorage: () => { onRehydrateStorage: () => {