Merge branch 'bulwarkmail:main' into feature/scheduled-send
This commit is contained in:
@@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# OpenID Connect issuer URL for discovery
|
||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||
|
||||
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
|
||||
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
|
||||
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
||||
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
|
||||
|
||||
# =============================================================================
|
||||
# Session & Security
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.1 (2026-05-22)
|
||||
|
||||
### Features
|
||||
|
||||
- **Admin**: Expose PWA branding fields in the admin Branding tab
|
||||
- **Pro**: Hide empty-state placeholder and collapse the viewer pane in Pro mode so the mail list fills the space
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Preserve inline images when replying (#163)
|
||||
- **Filters**: Use the canonical `INBOX` mailbox in Sieve filter paths (#313)
|
||||
- **Mail**: Resolve destination account id to the local namespace on cross-account mailbox drop
|
||||
|
||||
## 1.7.0 (2026-05-21)
|
||||
|
||||
> **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced.
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -96,7 +96,7 @@ function OAuthCallbackInner() {
|
||||
// the refresh-token cookie write for the same reason.
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/sso/complete", {
|
||||
const res = await apiFetch("/api/auth/sso/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
|
||||
@@ -57,6 +57,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
@@ -1936,17 +1937,24 @@ export default function Home() {
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.eml,message/rfc822';
|
||||
input.accept = EML_IMPORT_ACCEPT;
|
||||
input.multiple = true;
|
||||
input.onchange = async (e) => {
|
||||
const files = Array.from((e.target as HTMLInputElement).files ?? []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
let emails;
|
||||
try {
|
||||
emails = await expandImportableEmails(files);
|
||||
} catch {
|
||||
toast.error(t('notifications.import_email_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
for (const file of files) {
|
||||
for (const { blob } of emails) {
|
||||
try {
|
||||
const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' });
|
||||
await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true });
|
||||
imported++;
|
||||
} catch {
|
||||
@@ -1958,7 +1966,7 @@ export default function Home() {
|
||||
toast.success(t('notifications.import_email_success'));
|
||||
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
|
||||
}
|
||||
if (failed > 0) {
|
||||
if (failed > 0 || (imported === 0 && emails.length === 0)) {
|
||||
toast.error(t('notifications.import_email_error'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PaneSizeContext } from "@/hooks/use-pane-size";
|
||||
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
|
||||
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
|
||||
import MailPage from "@/app/(main)/[locale]/page";
|
||||
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
|
||||
@@ -172,7 +173,7 @@ export default function ProHome() {
|
||||
// enabled it. If either precondition stops holding, hand the user back
|
||||
// to the standard shell.
|
||||
if (isMobile || isTablet || !proInterface) {
|
||||
window.location.replace("/");
|
||||
window.location.replace(`${getPathPrefix()}/`);
|
||||
}
|
||||
}, [initialCheckDone, isMobile, isTablet, proInterface]);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Languages,
|
||||
Info,
|
||||
Bug,
|
||||
Download,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -59,6 +60,7 @@ import { FolderSettings } from '@/components/settings/folder-settings';
|
||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||
@@ -90,6 +92,7 @@ type Tab =
|
||||
| 'layout'
|
||||
| 'reading'
|
||||
| 'composing'
|
||||
| 'downloads'
|
||||
| 'identities'
|
||||
| 'vacation'
|
||||
| 'filters'
|
||||
@@ -126,6 +129,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
layout: LayoutGrid,
|
||||
reading: BookOpen,
|
||||
composing: PenLine,
|
||||
downloads: Download,
|
||||
identities: UserPen,
|
||||
vacation: PalmtreeIcon,
|
||||
filters: Filter,
|
||||
@@ -202,6 +206,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
'settings.email_behavior.signature_position',
|
||||
'settings.email_behavior.sub_address_delimiter',
|
||||
],
|
||||
downloads: ['settings.downloads'],
|
||||
identities: ['settings.identities'],
|
||||
vacation: ['settings.vacation'],
|
||||
filters: ['settings.filters'],
|
||||
@@ -236,6 +241,7 @@ const tabKeywords: Record<Tab, string> = {
|
||||
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
|
||||
reading: 'mark read preview thread conversation archive delete attachment open',
|
||||
composing: 'editor signature plain text reply forward draft compose',
|
||||
downloads: 'download filename template eml attachment save export',
|
||||
identities: 'from address signature email',
|
||||
vacation: 'auto reply away out of office holiday responder',
|
||||
filters: 'sieve rules block junk forward',
|
||||
@@ -580,6 +586,7 @@ export default function SettingsPage() {
|
||||
// Mail
|
||||
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' },
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||
@@ -666,6 +673,7 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
|
||||
@@ -273,6 +273,7 @@ export function AuthTab() {
|
||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
|
||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||
<Toggle label="Allow private OAuth endpoints" description="Permit discovery to resolve to RFC-1918 / loopback hosts. Enable only for split-DNS deployments where the mail server's public hostname resolves to an internal IP." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
|
||||
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
|
||||
</Section>
|
||||
|
||||
@@ -28,7 +28,7 @@ const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
|
||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
|
||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
|
||||
type State = 'bootstrap' | 'configured' | 'env-managed';
|
||||
|
||||
@@ -258,7 +258,7 @@ export default function SetupWizardPage() {
|
||||
// edge cases that swallow client-side replaces after the
|
||||
// setupComplete flag flips.
|
||||
setTimeout(() => {
|
||||
window.location.assign('/admin/login');
|
||||
window.location.assign(`${getPathPrefix()}/admin/login`);
|
||||
}, 1500);
|
||||
}}
|
||||
/>
|
||||
@@ -340,13 +340,13 @@ function CompletedScreen() {
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<a
|
||||
href="/admin/login"
|
||||
href={`${getPathPrefix()}/admin/login`}
|
||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Sign in to admin dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
href={`${getPathPrefix()}/`}
|
||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Open webmail login
|
||||
@@ -422,13 +422,13 @@ function AlreadyConfiguredScreen() {
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<a
|
||||
href="/admin/login"
|
||||
href={`${getPathPrefix()}/admin/login`}
|
||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Sign in to admin dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
href={`${getPathPrefix()}/`}
|
||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Open webmail login
|
||||
|
||||
@@ -3,9 +3,8 @@ import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { getRequiredConfig, getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
@@ -62,7 +61,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
@@ -52,9 +53,13 @@ async function tryTokenRequest(
|
||||
}
|
||||
}
|
||||
|
||||
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
|
||||
// Admin-trusted callers (matched server entry or configured JMAP server URL)
|
||||
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
|
||||
// go through the SSRF validator regardless of the setting.
|
||||
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
|
||||
// 1. Try OAuth discovery
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
|
||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||
|
||||
// 2. Try common Stalwart token endpoint paths directly
|
||||
@@ -105,14 +110,17 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
let upstreamUrl: string;
|
||||
let resolvedServerId: string | null = null;
|
||||
let adminTrusted = false;
|
||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||
|
||||
if (matchedEntry) {
|
||||
upstreamUrl = matchedEntry.url;
|
||||
resolvedServerId = matchedEntry.id;
|
||||
adminTrusted = true;
|
||||
} else if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
adminTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||
@@ -123,7 +131,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
|
||||
if (!tokenEndpoint) {
|
||||
logger.warn('TOTP token exchange: no token endpoint found');
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
|
||||
+2
-2
@@ -242,8 +242,8 @@ body {
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.email-content-text {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,11 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
const calendarName = calendar?.name || "";
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endTime = getEventEndDate(event);
|
||||
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
||||
const safeFormat = (d: Date, fmt: string) => {
|
||||
if (isNaN(d.getTime())) return "--:--";
|
||||
try { return format(d, fmt); } catch { return "--:--"; }
|
||||
};
|
||||
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
|
||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent) => {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -803,6 +805,7 @@ interface DraggableAttachmentChipProps {
|
||||
attachment: EffectiveAttachment;
|
||||
client: IJMAPClient | null;
|
||||
enabled: boolean;
|
||||
downloadName?: string;
|
||||
children: (dragProps: {
|
||||
draggable: boolean;
|
||||
onPointerEnter: () => void;
|
||||
@@ -811,9 +814,9 @@ interface DraggableAttachmentChipProps {
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) {
|
||||
function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
|
||||
const source = useMemo<AttachmentDragSource>(() => ({
|
||||
name: attachment.name || 'download',
|
||||
name: downloadName || attachment.name || 'download',
|
||||
type: attachment.type || 'application/octet-stream',
|
||||
getBlobUrl: async () => {
|
||||
if (attachment.blobId && client) {
|
||||
@@ -836,7 +839,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, children }: Drag
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}), [attachment, client]);
|
||||
}), [attachment, client, downloadName]);
|
||||
const drag = useAttachmentDrag(source, enabled);
|
||||
return <>{children(drag)}</>;
|
||||
}
|
||||
@@ -909,6 +912,26 @@ export function EmailViewer({
|
||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
|
||||
const dragOutActive = useMemo(() => isDragOutSupported(), []);
|
||||
const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
|
||||
const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_TEMPLATE;
|
||||
const filenameSpaceReplacement = useSettingsStore((state) => state.filenameSpaceReplacement);
|
||||
const filenameLowercase = useSettingsStore((state) => state.filenameLowercase);
|
||||
const filenameStripDiacritics = useSettingsStore((state) => state.filenameStripDiacritics);
|
||||
const filenameCollapseSeparators = useSettingsStore((state) => state.filenameCollapseSeparators);
|
||||
const emailFilenameOptions = useMemo(() => ({
|
||||
template: emailDownloadTemplate,
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
}), [emailDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
|
||||
const attachmentFilenameOptions = useMemo(() => ({
|
||||
template: attachmentDownloadTemplate,
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
}), [attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
|
||||
@@ -2594,6 +2617,15 @@ export function EmailViewer({
|
||||
return emailContent;
|
||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||
|
||||
const resolveAttachmentName = useCallback(
|
||||
(attachment: EffectiveAttachment) => {
|
||||
const fallback = attachment.name || 'download';
|
||||
if (!email) return fallback;
|
||||
return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentFilenameOptions) || fallback;
|
||||
},
|
||||
[email, attachmentFilenameOptions],
|
||||
);
|
||||
|
||||
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
// Blob URLs inherit our origin; script-bearing MIME types (text/html,
|
||||
@@ -2603,6 +2635,8 @@ export function EmailViewer({
|
||||
&& mailAttachmentAction === 'preview'
|
||||
&& isMimeTypeSafeForInlinePreview(attachment.type);
|
||||
|
||||
const downloadName = resolveAttachmentName(attachment);
|
||||
|
||||
const info: AttachmentInfo = {
|
||||
name: attachment.name || '',
|
||||
type: attachment.type,
|
||||
@@ -2613,7 +2647,7 @@ export function EmailViewer({
|
||||
|
||||
if (attachment.blobId && onDownloadAttachment) {
|
||||
emailHooks.onAttachmentDownload.emit(info);
|
||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
|
||||
onDownloadAttachment(attachment.blobId, downloadName, attachment.type);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2633,7 +2667,7 @@ export function EmailViewer({
|
||||
emailHooks.onAttachmentDownload.emit(info);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachment.name || 'download';
|
||||
anchor.download = downloadName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
@@ -2663,16 +2697,17 @@ export function EmailViewer({
|
||||
emailHooks.onAttachmentDownload.emit(info);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachment.name || 'download';
|
||||
anchor.download = downloadName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
}
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}, [mailAttachmentAction, onDownloadAttachment, email?.id]);
|
||||
}, [mailAttachmentAction, onDownloadAttachment, email, resolveAttachmentName]);
|
||||
|
||||
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
|
||||
const downloadName = resolveAttachmentName(attachment);
|
||||
const info: AttachmentInfo = {
|
||||
name: attachment.name || '',
|
||||
type: attachment.type,
|
||||
@@ -2682,7 +2717,7 @@ export function EmailViewer({
|
||||
};
|
||||
emailHooks.onAttachmentDownload.emit(info);
|
||||
if (attachment.blobId && onDownloadAttachment) {
|
||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
|
||||
onDownloadAttachment(attachment.blobId, downloadName, attachment.type, true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2695,7 +2730,7 @@ export function EmailViewer({
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachment.name || 'download';
|
||||
anchor.download = downloadName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
@@ -2711,12 +2746,12 @@ export function EmailViewer({
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachment.name || 'download';
|
||||
anchor.download = downloadName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}, [onDownloadAttachment, email?.id]);
|
||||
}, [onDownloadAttachment, email?.id, resolveAttachmentName]);
|
||||
|
||||
// Pre-fetch object URLs for image attachments so their actual contents can be
|
||||
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
|
||||
@@ -2832,7 +2867,9 @@ export function EmailViewer({
|
||||
// Word/Outlook HTML emails ship a <style> block but put their gutter in
|
||||
// @page margins (print-only), so they need a fallback body padding too.
|
||||
const isWordHtml = /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(effectiveEmailContent.html);
|
||||
const bodyPadding = (effectiveEmailContent.hasStyleTag && !isWordHtml) ? '0' : '1rem 1.25rem';
|
||||
const hasOwnLayout = effectiveEmailContent.hasStyleTag && !isWordHtml;
|
||||
const bodyPadding = hasOwnLayout ? '0' : '1rem 1.25rem';
|
||||
const mobileBodyPaddingX = hasOwnLayout ? '0' : '0.75rem';
|
||||
|
||||
// Word emails rely on empty <p class=MsoNormal> </p> spacers for vertical
|
||||
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
|
||||
@@ -2855,7 +2892,7 @@ export function EmailViewer({
|
||||
<style>
|
||||
html, body { overflow: hidden; }
|
||||
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
@media (max-width: 640px) { body { padding-left: 0; padding-right: 0; } }
|
||||
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
|
||||
img { max-width: 100% !important; height: auto !important; }
|
||||
a { color: #1a73e8; }
|
||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||
@@ -3089,35 +3126,58 @@ export function EmailViewer({
|
||||
const handleExportEmail = async () => {
|
||||
if (!email?.blobId || !client) return;
|
||||
try {
|
||||
const subject = (email.subject || 'email').replace(/[<>:"/\\|?*]+/g, '_').slice(0, 100);
|
||||
await client.downloadBlob(email.blobId, `${subject}.eml`, 'message/rfc822');
|
||||
await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822');
|
||||
} catch {
|
||||
toast.error(tNotifications('export_email_error'));
|
||||
return;
|
||||
}
|
||||
const action = useSettingsStore.getState().postExportAction;
|
||||
if (action === 'archive') onArchive?.();
|
||||
else if (action === 'trash') onDelete?.();
|
||||
};
|
||||
|
||||
// Import email from .eml file
|
||||
// Import email from .eml file or .zip archive containing .eml files
|
||||
const handleImportEmail = () => {
|
||||
if (!client) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.eml,message/rfc822';
|
||||
input.accept = EML_IMPORT_ACCEPT;
|
||||
input.multiple = true;
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const files = Array.from((e.target as HTMLInputElement).files ?? []);
|
||||
if (files.length === 0) return;
|
||||
const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState();
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const mailboxId = mailbox?.originalId || selectedMailbox;
|
||||
if (!mailboxId) {
|
||||
toast.error(tNotifications('import_email_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
let emails;
|
||||
try {
|
||||
const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState();
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const mailboxId = mailbox?.originalId || selectedMailbox;
|
||||
if (!mailboxId) {
|
||||
toast.error(tNotifications('import_email_error'));
|
||||
return;
|
||||
emails = await expandImportableEmails(files);
|
||||
} catch {
|
||||
toast.error(tNotifications('import_email_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
for (const { blob } of emails) {
|
||||
try {
|
||||
await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true });
|
||||
imported++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' });
|
||||
await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true });
|
||||
}
|
||||
|
||||
if (imported > 0) {
|
||||
toast.success(tNotifications('import_email_success'));
|
||||
await fetchEmails(client);
|
||||
} catch {
|
||||
}
|
||||
if (failed > 0 || emails.length === 0) {
|
||||
toast.error(tNotifications('import_email_error'));
|
||||
}
|
||||
};
|
||||
@@ -4269,7 +4329,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -4349,7 +4409,7 @@ export function EmailViewer({
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -5002,7 +5062,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -5087,7 +5147,7 @@ export function EmailViewer({
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -5145,7 +5205,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -5224,7 +5284,7 @@ export function EmailViewer({
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
|
||||
@@ -21,7 +21,7 @@ import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface NavItem {
|
||||
@@ -385,7 +385,7 @@ export function NavigationRail({
|
||||
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
href="/admin"
|
||||
href={`${getPathPrefix()}/admin`}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"transition-colors duration-150",
|
||||
@@ -572,7 +572,7 @@ export function NavigationRail({
|
||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
href="/admin"
|
||||
href={`${getPathPrefix()}/admin`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
|
||||
title={t("admin") || "Admin"}
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useUpdateStore } from '@/stores/update-store';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
@@ -123,12 +124,12 @@ export function AboutDataSettings() {
|
||||
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src="/branding/Bulwark_Logo_Color.svg"
|
||||
src={`${getPathPrefix()}/branding/Bulwark_Logo_Color.svg`}
|
||||
alt="Bulwark"
|
||||
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
<img
|
||||
src="/branding/Bulwark_Logo_White.svg"
|
||||
src={`${getPathPrefix()}/branding/Bulwark_Logo_White.svg`}
|
||||
alt="Bulwark"
|
||||
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -120,9 +120,10 @@ export function ReadingSettings() {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
value={deleteAction}
|
||||
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
|
||||
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'trash-and-read' | 'permanent')}
|
||||
options={[
|
||||
{ value: 'trash', label: t('delete_action.trash') },
|
||||
{ value: 'trash-and-read', label: t('delete_action.trash_and_read') },
|
||||
{ value: 'permanent', label: t('delete_action.permanent') },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -92,9 +92,11 @@ export function useAttachmentDrag(
|
||||
return;
|
||||
}
|
||||
|
||||
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium reads this on
|
||||
// drop and writes a real file at the destination.
|
||||
e.dataTransfer.setData("DownloadURL", `${type}:${encodeURIComponent(name)}:${url}`);
|
||||
// `DownloadURL` format: <mime>:<filename>:<url>. The filename must be
|
||||
// raw - URL-encoding it lands literally on disk (`%20` instead of a
|
||||
// space). Callers are expected to sanitise reserved chars (`:` etc.)
|
||||
// beforehand.
|
||||
e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`);
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
},
|
||||
[source.name, source.type, prefetch],
|
||||
|
||||
+202
-5
@@ -1,10 +1,21 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useCallback, DragEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
|
||||
import {
|
||||
bundleExportFilename,
|
||||
DEFAULT_BUNDLE_TEMPLATE,
|
||||
DEFAULT_EMAIL_TEMPLATE,
|
||||
emailExportFilename,
|
||||
type EmailFilenameOptions,
|
||||
} from "@/lib/download-filename";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface UseEmailDragOptions {
|
||||
email: Email;
|
||||
@@ -15,6 +26,7 @@ interface UseEmailDragOptions {
|
||||
interface UseEmailDragReturn {
|
||||
dragHandlers: {
|
||||
draggable: boolean;
|
||||
onPointerEnter?: () => void;
|
||||
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
@@ -44,10 +56,153 @@ function createDragPreview(count: number): HTMLElement {
|
||||
return preview;
|
||||
}
|
||||
|
||||
function bundleFilename(count: number, options: EmailFilenameOptions): string {
|
||||
return bundleExportFilename(count, options);
|
||||
}
|
||||
|
||||
// Shared bundle cache. The .zip is keyed by the sorted list of email IDs in
|
||||
// the selection, so two rows in the same selection reuse the same in-flight
|
||||
// build. When the selection changes, the previous bundle URL is scheduled for
|
||||
// revoke and a new build starts.
|
||||
type BundleEntry = {
|
||||
key: string;
|
||||
name: string;
|
||||
url: string | null;
|
||||
promise: Promise<string | null> | null;
|
||||
};
|
||||
|
||||
let currentBundle: BundleEntry | null = null;
|
||||
|
||||
function selectionKey(ids: string[]): string {
|
||||
return [...ids].sort().join(",");
|
||||
}
|
||||
|
||||
async function buildEmailZip(client: IJMAPClient, emails: Email[], options: EmailFilenameOptions): Promise<string | null> {
|
||||
const eligible = emails.filter((em) => !!em.blobId);
|
||||
if (eligible.length === 0) return null;
|
||||
const { default: JSZip } = await import("jszip");
|
||||
const zip = new JSZip();
|
||||
const used = new Set<string>();
|
||||
await Promise.all(
|
||||
eligible.map(async (em) => {
|
||||
const base = emailExportFilename(em, options).replace(/\.eml$/, "");
|
||||
let name = `${base}.eml`;
|
||||
while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`;
|
||||
used.add(name);
|
||||
try {
|
||||
const blob = await client.fetchBlob(em.blobId!, name, "message/rfc822");
|
||||
zip.file(name, blob);
|
||||
} catch {
|
||||
// Skip individual failures; remaining messages still bundle.
|
||||
}
|
||||
}),
|
||||
);
|
||||
const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" });
|
||||
return URL.createObjectURL(zipBlob);
|
||||
}
|
||||
|
||||
function prefetchEmailBundle(
|
||||
client: IJMAPClient,
|
||||
emails: Email[],
|
||||
emailOptions: EmailFilenameOptions,
|
||||
bundleOptions: EmailFilenameOptions,
|
||||
): void {
|
||||
const key = selectionKey(emails.map((e) => e.id));
|
||||
if (currentBundle && currentBundle.key === key) return;
|
||||
if (currentBundle?.url) {
|
||||
const old = currentBundle.url;
|
||||
setTimeout(() => URL.revokeObjectURL(old), 60_000);
|
||||
}
|
||||
const entry: BundleEntry = {
|
||||
key,
|
||||
name: bundleFilename(emails.length, bundleOptions),
|
||||
url: null,
|
||||
promise: null,
|
||||
};
|
||||
entry.promise = buildEmailZip(client, emails, emailOptions)
|
||||
.then((url) => {
|
||||
if (url && currentBundle === entry) entry.url = url;
|
||||
return url;
|
||||
})
|
||||
.catch(() => null);
|
||||
currentBundle = entry;
|
||||
}
|
||||
|
||||
function getReadyBundle(emails: Email[]): { url: string; name: string } | null {
|
||||
const key = selectionKey(emails.map((e) => e.id));
|
||||
if (currentBundle && currentBundle.key === key && currentBundle.url) {
|
||||
return { url: currentBundle.url, name: currentBundle.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
|
||||
const { selectedEmailIds, emails } = useEmailStore();
|
||||
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const client = useAuthStore((state) => state.client);
|
||||
const template = useSettingsStore((s) => s.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
|
||||
const bundleTemplate = useSettingsStore((s) => s.bundleDownloadTemplate) || DEFAULT_BUNDLE_TEMPLATE;
|
||||
const spaceReplacement = useSettingsStore((s) => s.filenameSpaceReplacement);
|
||||
const lowercase = useSettingsStore((s) => s.filenameLowercase);
|
||||
const stripDiacritics = useSettingsStore((s) => s.filenameStripDiacritics);
|
||||
const collapseSeparators = useSettingsStore((s) => s.filenameCollapseSeparators);
|
||||
const filenameOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }),
|
||||
[template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators],
|
||||
);
|
||||
const bundleOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ template: bundleTemplate, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }),
|
||||
[bundleTemplate, spaceReplacement, lowercase, stripDiacritics, collapseSeparators],
|
||||
);
|
||||
|
||||
const dragOutEnabled = !isMobile && isDragOutSupported() && !!client;
|
||||
const singleBlobUrlRef = useRef<string | null>(null);
|
||||
const inFlightRef = useRef<Promise<string | null> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (singleBlobUrlRef.current) {
|
||||
const url = singleBlobUrlRef.current;
|
||||
singleBlobUrlRef.current = null;
|
||||
// Defer revoke - Chromium asynchronously reads the blob: URL after the
|
||||
// drop completes, so revoking immediately can race the OS.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
inFlightRef.current = null;
|
||||
};
|
||||
}, [email.id]);
|
||||
|
||||
const prefetchSingle = useCallback(() => {
|
||||
if (!dragOutEnabled || !client || !email.blobId) return;
|
||||
if (singleBlobUrlRef.current || inFlightRef.current) return;
|
||||
const name = emailExportFilename(email, filenameOptions);
|
||||
inFlightRef.current = client
|
||||
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
|
||||
.then((url) => {
|
||||
if (url && !singleBlobUrlRef.current) singleBlobUrlRef.current = url;
|
||||
return url;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
inFlightRef.current = null;
|
||||
});
|
||||
}, [dragOutEnabled, client, email, filenameOptions]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
if (!dragOutEnabled || !client) return;
|
||||
const isSelected = selectedEmailIds.has(email.id);
|
||||
const isMulti = isSelected && selectedEmailIds.size > 1;
|
||||
if (isMulti) {
|
||||
const selected = emails.filter((em) => selectedEmailIds.has(em.id));
|
||||
// Only worth bundling when at least one selected email has a blobId.
|
||||
if (selected.some((em) => em.blobId)) {
|
||||
prefetchEmailBundle(client, selected, filenameOptions, bundleOptions);
|
||||
}
|
||||
} else {
|
||||
prefetchSingle();
|
||||
}
|
||||
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions, bundleOptions]);
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
// Determine which emails to drag:
|
||||
@@ -59,8 +214,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
? emails.filter(em => selectedEmailIds.has(em.id))
|
||||
: threadEmails || [email];
|
||||
|
||||
// Set data transfer
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData(
|
||||
"application/x-email-ids",
|
||||
JSON.stringify(emailsToDrag.map(em => em.id))
|
||||
@@ -70,6 +224,40 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
emailsToDrag.map(em => em.subject || "(no subject)").join(", ")
|
||||
);
|
||||
|
||||
// Drag-out to file explorer.
|
||||
if (dragOutEnabled && client) {
|
||||
if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) {
|
||||
const url = singleBlobUrlRef.current;
|
||||
if (url) {
|
||||
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
|
||||
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium expects
|
||||
// the filename raw - URL-encoding it ends up literally on disk
|
||||
// (e.g. `%20` instead of a space). The sanitiser already removed
|
||||
// `:` and other reserved chars, so embedding the name as-is is
|
||||
// safe. Firefox/Safari ignore this entry entirely.
|
||||
e.dataTransfer.setData(
|
||||
"DownloadURL",
|
||||
`message/rfc822:${name}:${url}`,
|
||||
);
|
||||
} else {
|
||||
// Not warmed up yet — kick off so the next attempt works. Don't
|
||||
// preventDefault: in-app drop still has to function.
|
||||
prefetchSingle();
|
||||
}
|
||||
} else if (emailsToDrag.length > 1) {
|
||||
const ready = getReadyBundle(emailsToDrag);
|
||||
if (ready) {
|
||||
e.dataTransfer.setData(
|
||||
"DownloadURL",
|
||||
`application/zip:${ready.name}:${ready.url}`,
|
||||
);
|
||||
} else {
|
||||
// Kick off the bundle build for the next attempt.
|
||||
prefetchEmailBundle(client, emailsToDrag, filenameOptions, bundleOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create custom drag image
|
||||
const dragPreview = createDragPreview(emailsToDrag.length);
|
||||
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
||||
@@ -80,10 +268,18 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
});
|
||||
|
||||
startDrag(emailsToDrag, sourceMailboxId);
|
||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails]);
|
||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
endDrag();
|
||||
// Defer-revoke the per-row single .eml URL. The shared bundle URL stays
|
||||
// cached until the selection changes - revoking it here would break a
|
||||
// subsequent drag of the same selection.
|
||||
if (singleBlobUrlRef.current) {
|
||||
const url = singleBlobUrlRef.current;
|
||||
singleBlobUrlRef.current = null;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
}, [endDrag]);
|
||||
|
||||
// Check if this specific email is being dragged
|
||||
@@ -94,6 +290,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} }
|
||||
: {
|
||||
draggable: true,
|
||||
onPointerEnter: dragOutEnabled ? handlePointerEnter : undefined,
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: handleDragEnd,
|
||||
},
|
||||
|
||||
@@ -146,6 +146,23 @@ describe('oauth/discovery', () => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts private/loopback endpoints when validateEndpoint is omitted (admin opted in)', async () => {
|
||||
// Split-DNS deployments: mail.example.com resolves to an RFC-1918 address
|
||||
// locally. With the SSRF validator off, discovery must succeed.
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
issuer: 'https://mail.example.com',
|
||||
authorization_endpoint: 'http://10.0.0.5/authorize',
|
||||
token_endpoint: 'http://10.0.0.5/token',
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await discoverOAuth('https://mail.example.com');
|
||||
|
||||
expect(result?.token_endpoint).toBe('http://10.0.0.5/token');
|
||||
});
|
||||
|
||||
it('caches results - second call for same server URL does not re-fetch', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -153,6 +153,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
|
||||
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' },
|
||||
oauthAllowPrivateEndpoints: { envVar: 'OAUTH_ALLOW_PRIVATE_ENDPOINTS', type: 'boolean', defaultValue: false },
|
||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
|
||||
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
|
||||
|
||||
+10
-3
@@ -24,8 +24,14 @@ export interface TimedEventLayout {
|
||||
export function getEventStartDate(
|
||||
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
|
||||
): Date {
|
||||
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
|
||||
return parseISO(source);
|
||||
// Prefer utcStart for timed events but fall back to start if utcStart is
|
||||
// missing or unparseable - a malformed utcStart used to surface as an
|
||||
// Invalid Date that crashed downstream format() calls (#316).
|
||||
if (!event.showWithoutTime && event.utcStart) {
|
||||
const utc = parseISO(event.utcStart);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
return parseISO(event.start);
|
||||
}
|
||||
|
||||
export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
|
||||
@@ -56,7 +62,8 @@ export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWe
|
||||
|
||||
export function getEventEndDate(event: CalendarEvent): Date {
|
||||
if (!event.showWithoutTime && event.utcEnd) {
|
||||
return parseISO(event.utcEnd);
|
||||
const utc = parseISO(event.utcEnd);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
|
||||
const start = getEventStartDate(event);
|
||||
|
||||
+12
-5
@@ -267,10 +267,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async moveToTrash(emailId: string, trashMailboxId: string): Promise<void> {
|
||||
async moveToTrash(emailId: string, trashMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
email.mailboxIds = { [trashMailboxId]: true };
|
||||
if (markAsRead) email.keywords.$seen = true;
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
@@ -280,10 +281,13 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
|
||||
async batchMoveEmails(emailIds: string[], toMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
for (const id of emailIds) {
|
||||
const email = this.data.emails.find(e => e.id === id);
|
||||
if (email) email.mailboxIds = { [toMailboxId]: true };
|
||||
if (email) {
|
||||
email.mailboxIds = { [toMailboxId]: true };
|
||||
if (markAsRead) email.keywords.$seen = true;
|
||||
}
|
||||
}
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
@@ -360,10 +364,13 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return count;
|
||||
}
|
||||
|
||||
async markAsSpam(emailId: string): Promise<void> {
|
||||
async markAsSpam(emailId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
|
||||
if (email && junkMb) email.mailboxIds = { [junkMb.id]: true };
|
||||
if (email && junkMb) {
|
||||
email.mailboxIds = { [junkMb.id]: true };
|
||||
if (markAsRead) email.keywords.$seen = true;
|
||||
}
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -98,9 +98,9 @@ export interface IJMAPClient {
|
||||
setKeyword(emailId: string, keyword: string): Promise<void>;
|
||||
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
||||
deleteEmail(emailId: string): Promise<void>;
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>;
|
||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
batchArchiveEmails(
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
@@ -112,7 +112,7 @@ export interface IJMAPClient {
|
||||
emptyMailbox(mailboxId: string): Promise<number>;
|
||||
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
|
||||
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
|
||||
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
||||
markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
|
||||
|
||||
// ── Threads ───────────────────────────────────────────────────
|
||||
|
||||
+19
-14
@@ -99,6 +99,8 @@ const EMAIL_LIST_PROPERTIES = [
|
||||
"subject",
|
||||
"preview",
|
||||
"hasAttachment",
|
||||
// Needed so list rows can serve drag-out to the file system as .eml.
|
||||
"blobId",
|
||||
] as const;
|
||||
|
||||
// Stalwart's default property list for Calendar/get omits shareWith, isVisible,
|
||||
@@ -1278,16 +1280,14 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void> {
|
||||
async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const patch: Record<string, unknown> = { mailboxIds: { [trashMailboxId]: true } };
|
||||
if (markAsRead) patch["keywords/$seen"] = true;
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: targetAccountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
mailboxIds: { [trashMailboxId]: true },
|
||||
},
|
||||
},
|
||||
update: { [emailId]: patch },
|
||||
}, "0"],
|
||||
]);
|
||||
}
|
||||
@@ -1303,10 +1303,15 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void> {
|
||||
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
if (emailIds.length === 0) return;
|
||||
|
||||
const updates = Object.fromEntries(emailIds.map(id => [id, { mailboxIds: { [toMailboxId]: true } }]));
|
||||
const buildPatch = () => {
|
||||
const patch: Record<string, unknown> = { mailboxIds: { [toMailboxId]: true } };
|
||||
if (markAsRead) patch["keywords/$seen"] = true;
|
||||
return patch;
|
||||
};
|
||||
const updates = Object.fromEntries(emailIds.map(id => [id, buildPatch()]));
|
||||
await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||||
]);
|
||||
@@ -1559,7 +1564,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
return totalMarked;
|
||||
}
|
||||
|
||||
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
|
||||
async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
const mailboxes = await this.getMailboxes();
|
||||
@@ -1578,14 +1583,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
? junkMailbox.originalId
|
||||
: junkMailbox.id;
|
||||
|
||||
const patch: Record<string, unknown> = { mailboxIds: { [mailboxId]: true } };
|
||||
if (markAsRead) patch["keywords/$seen"] = true;
|
||||
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: targetAccountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
mailboxIds: { [mailboxId]: true },
|
||||
},
|
||||
},
|
||||
update: { [emailId]: patch },
|
||||
}, "0"],
|
||||
]);
|
||||
}
|
||||
@@ -4279,6 +4283,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const createMap: Record<string, Partial<CalendarEvent>> = {};
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent;
|
||||
cleanRecurrenceRules(clean as unknown as Record<string, unknown>);
|
||||
createMap[`new-${i}`] = clean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
||||
import type { EndpointValidator, OAuthMetadata } from '@/lib/oauth/discovery';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
|
||||
|
||||
// SSRF guard for OAuth discovery. When `oauthAllowPrivateEndpoints` is set,
|
||||
// the admin opts in to discovery resolving to RFC-1918 / loopback hosts —
|
||||
// required for split-DNS deployments where the JMAP server's public hostname
|
||||
// resolves to an internal IP locally. The guard remains in force for any
|
||||
// caller that passes a user-supplied serverUrl (see totp-token-exchange).
|
||||
export function getDiscoveryValidator(): EndpointValidator | undefined {
|
||||
const allowPrivate = configManager.get<boolean>('oauthAllowPrivateEndpoints', false);
|
||||
return allowPrivate ? undefined : isPublicHttpUrl;
|
||||
}
|
||||
|
||||
function getGlobalClientSecret(): string {
|
||||
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
||||
if (adminSecret) return adminSecret;
|
||||
@@ -47,7 +57,7 @@ function getClientSecret(serverId?: string | null): string {
|
||||
|
||||
export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
|
||||
const { discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||
if (!metadata?.token_endpoint) {
|
||||
throw new Error('OAuth token endpoint not found');
|
||||
}
|
||||
@@ -56,7 +66,7 @@ export async function getTokenEndpoint(serverId?: string | null): Promise<string
|
||||
|
||||
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
|
||||
const { discoveryUrl } = getRequiredConfig(serverId);
|
||||
return discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
return discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||
}
|
||||
|
||||
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
// installing their own code. Verification kicks in for server-managed
|
||||
// bundles only (the `managed: true` flag on `InstalledPlugin`).
|
||||
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
let cachedPubKey: CryptoKey | null = null;
|
||||
let pubKeyPromise: Promise<CryptoKey | null> | null = null;
|
||||
|
||||
@@ -26,7 +28,7 @@ async function importEd25519PublicKey(raw: Uint8Array): Promise<CryptoKey | null
|
||||
|
||||
async function fetchPublicKey(): Promise<CryptoKey | null> {
|
||||
try {
|
||||
const res = await fetch('/api/plugin-signing-pubkey', { credentials: 'same-origin' });
|
||||
const res = await apiFetch('/api/plugin-signing-pubkey', { credentials: 'same-origin' });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { algorithm?: string; publicKey?: string };
|
||||
if (data.algorithm !== 'ed25519' || typeof data.publicKey !== 'string') return null;
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Tisk",
|
||||
"view_source": "Zobrazit zdrojový kód",
|
||||
"export_email": "Exportovat jako .eml",
|
||||
"import_email": "Importovat .eml",
|
||||
"import_email": "Importovat .eml nebo .zip",
|
||||
"keyboard_shortcuts": "Klávesové zkratky (?)",
|
||||
"email_source": "Zdrojový kód zprávy",
|
||||
"draft_banner": "Tato zpráva je koncept",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "Vzhled",
|
||||
"reading": "Čtení",
|
||||
"composing": "Psaní",
|
||||
"downloads": "Stažené",
|
||||
"content_senders": "Obsah a odesílatelé",
|
||||
"about_data": "Info a data",
|
||||
"debug": "Ladění"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"categories_description": "Přejmenovat kategorie kontaktů",
|
||||
"no_categories": "Nenalezeny žádné kategorie"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Stažené",
|
||||
"description": "Přizpůsobte si, jak se pojmenovávají stažené e-maily a přílohy.",
|
||||
"reset": "Obnovit výchozí",
|
||||
"preview": "Náhled:",
|
||||
"email_template": {
|
||||
"label": "Název souboru e-mailu (.eml)",
|
||||
"description": "Šablona použitá při exportu e-mailu nebo jeho přetažení do souborového systému. Přípona .eml se přidává automaticky."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Název souboru přílohy",
|
||||
"description": "Šablona použitá při stahování nebo přetahování přílohy. Pokud vynecháte '{filename}' a '{ext}', původní přípona se zachová."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Název .zip souboru s více e-maily",
|
||||
"description": "Šablona použitá při přetažení nebo stažení několika vybraných e-mailů jako jediného .zip archivu. Přípona .zip se přidává automaticky."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Mezery",
|
||||
"description": "Nahradit mezery ve výsledném názvu souboru jiným znakem.",
|
||||
"keep": "Zachovat mezery",
|
||||
"underscore": "Nahradit za _",
|
||||
"dash": "Nahradit za -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Malá písmena",
|
||||
"description": "Vynutit celý název souboru malými písmeny."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Odstranit diakritiku",
|
||||
"description": "Převést písmena s diakritikou na ASCII ekvivalenty (á → a, é → e). Užitečné pro nástroje, které špatně zpracovávají Unicode názvy."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Sloučit opakované oddělovače",
|
||||
"description": "Sloučit posloupnosti mezer, podtržítek nebo pomlček do jednoho znaku."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Po exportu",
|
||||
"description": "Po exportu jako .eml lze e-mail volitelně přesunout.",
|
||||
"keep": "Ponechat ve schránce",
|
||||
"archive": "Přesunout do archivu",
|
||||
"trash": "Přesunout do koše"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtry e-mailů",
|
||||
"description": "Vytvářejte pravidla pro automatické třídění, štítkování a správu příchozích e-mailů",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "Nová podsložka...",
|
||||
"new_folder": "Nová složka...",
|
||||
"rename": "Přejmenovat...",
|
||||
"import_email": "Importovat .eml...",
|
||||
"import_email": "Importovat .eml nebo .zip...",
|
||||
"empty_folder": "Vyprázdnit složku",
|
||||
"empty_folder_generic": "Vyprázdnit složku",
|
||||
"delete_folder": "Smazat složku",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Udskriv",
|
||||
"view_source": "Vis kilde",
|
||||
"export_email": "Eksportér som .eml",
|
||||
"import_email": "Importér .eml",
|
||||
"import_email": "Importér .eml eller .zip",
|
||||
"keyboard_shortcuts": "Tastaturgenveje (?)",
|
||||
"email_source": "E-mail-kilde",
|
||||
"draft_banner": "Denne besked er en kladde",
|
||||
@@ -808,6 +808,7 @@
|
||||
"layout": "Layout",
|
||||
"reading": "Læsning",
|
||||
"composing": "Skrivning",
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Indhold & afsendere",
|
||||
"about_data": "Om & data",
|
||||
"debug": "Debug"
|
||||
@@ -1535,6 +1536,50 @@
|
||||
"categories_description": "Omdøb kontaktkategorier",
|
||||
"no_categories": "Ingen kategorier fundet"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
"description": "Tilpas hvordan downloadede e-mails og vedhæftede filer navngives.",
|
||||
"reset": "Gendan standard",
|
||||
"preview": "Forhåndsvisning:",
|
||||
"email_template": {
|
||||
"label": "E-mail-filnavn (.eml)",
|
||||
"description": "Skabelon som bruges når du eksporterer en e-mail eller trækker den ud i filsystemet. Endelsen .eml tilføjes automatisk."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Filnavn på vedhæftet fil",
|
||||
"description": "Skabelon som bruges når du downloader eller trækker en vedhæftet fil ud. Hvis du udelader '{filename}' og '{ext}', bevares den oprindelige endelse."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Filnavn for .zip med flere e-mails",
|
||||
"description": "Skabelon som bruges når du trækker ud eller downloader flere valgte e-mails som ét .zip-arkiv. Endelsen .zip tilføjes automatisk."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Mellemrum",
|
||||
"description": "Erstat mellemrum i det endelige filnavn med et andet tegn.",
|
||||
"keep": "Behold mellemrum",
|
||||
"underscore": "Erstat med _",
|
||||
"dash": "Erstat med -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Små bogstaver",
|
||||
"description": "Tving hele filnavnet til små bogstaver."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Fjern diakritiske tegn",
|
||||
"description": "Konvertér bogstaver med accenter til deres ASCII-pendant (ä → a, é → e). Nyttigt for værktøjer, der ikke håndterer Unicode-navne korrekt."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Slå gentagne skilletegn sammen",
|
||||
"description": "Slå serier af mellemrum, understreger eller bindestreger sammen til ét tegn."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Efter eksport",
|
||||
"description": "Flyt eventuelt e-mailen efter den er eksporteret som .eml.",
|
||||
"keep": "Behold i postkassen",
|
||||
"archive": "Flyt til arkiv",
|
||||
"trash": "Flyt til papirkurv"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-mail-filtre",
|
||||
"description": "Opret regler til automatisk at sortere, mærke og administrere indkommende e-mails",
|
||||
@@ -1801,7 +1846,7 @@
|
||||
"new_subfolder": "Ny undermappe...",
|
||||
"new_folder": "Ny mappe...",
|
||||
"rename": "Omdøb...",
|
||||
"import_email": "Importér .eml...",
|
||||
"import_email": "Importér .eml eller .zip...",
|
||||
"empty_folder": "Tøm mappe",
|
||||
"empty_folder_generic": "Tøm mappe",
|
||||
"delete_folder": "Slet mappe",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Drucken",
|
||||
"view_source": "Quelltext anzeigen",
|
||||
"export_email": "Als .eml exportieren",
|
||||
"import_email": ".eml importieren",
|
||||
"import_email": ".eml oder .zip importieren",
|
||||
"keyboard_shortcuts": "Tastaturkürzel (?)",
|
||||
"email_source": "E-Mail-Quelltext",
|
||||
"draft_banner": "Diese Nachricht ist ein Entwurf",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "Layout",
|
||||
"reading": "Lesen",
|
||||
"composing": "Verfassen",
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Inhalte & Absender",
|
||||
"about_data": "Über & Daten",
|
||||
"debug": "Debug"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen",
|
||||
"group_by_letter_label": "Nach Anfangsbuchstaben gruppieren"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
"description": "Lege fest, wie heruntergeladene E-Mails und Anhänge benannt werden.",
|
||||
"reset": "Standard wiederherstellen",
|
||||
"preview": "Vorschau:",
|
||||
"email_template": {
|
||||
"label": "E-Mail-Dateiname (.eml)",
|
||||
"description": "Vorlage, die beim Exportieren oder Herausziehen einer E-Mail verwendet wird. Die Endung .eml wird automatisch ergänzt."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Anhang-Dateiname",
|
||||
"description": "Vorlage zum Herunterladen oder Herausziehen eines Anhangs. Wenn '{filename}' und '{ext}' fehlen, bleibt die ursprüngliche Endung erhalten."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Mehrere E-Mails als .zip",
|
||||
"description": "Vorlage, die beim Herausziehen oder Herunterladen mehrerer ausgewählter E-Mails als einzelnes .zip-Archiv verwendet wird. Die Endung .zip wird automatisch ergänzt."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Leerzeichen",
|
||||
"description": "Ersetze Leerzeichen im Dateinamen durch ein anderes Zeichen.",
|
||||
"keep": "Leerzeichen behalten",
|
||||
"underscore": "Durch _ ersetzen",
|
||||
"dash": "Durch - ersetzen"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Kleinschreibung",
|
||||
"description": "Wandle den gesamten Dateinamen in Kleinbuchstaben um."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Diakritika entfernen",
|
||||
"description": "Wandle Buchstaben mit Akzenten in ASCII-Äquivalente um (ä → a, é → e). Nützlich für Tools, die Unicode-Dateinamen verstümmeln."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Trennzeichen zusammenfassen",
|
||||
"description": "Aufeinanderfolgende Leerzeichen, Unterstriche oder Bindestriche zu einem Zeichen zusammenfassen."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Nach dem Export",
|
||||
"description": "E-Mail nach dem Export als .eml optional verschieben.",
|
||||
"keep": "Im Postfach behalten",
|
||||
"archive": "Ins Archiv verschieben",
|
||||
"trash": "In den Papierkorb verschieben"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-Mail-Filter",
|
||||
"description": "Erstellen Sie Regeln, um eingehende E-Mails automatisch zu sortieren, zu kennzeichnen und zu verwalten",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "Neuer Unterordner...",
|
||||
"new_folder": "Neuer Ordner...",
|
||||
"rename": "Umbenennen...",
|
||||
"import_email": ".eml importieren...",
|
||||
"import_email": ".eml oder .zip importieren...",
|
||||
"empty_folder": "Ordner leeren",
|
||||
"empty_folder_generic": "Ordner leeren",
|
||||
"delete_folder": "Ordner löschen",
|
||||
|
||||
+48
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Print",
|
||||
"view_source": "View source",
|
||||
"export_email": "Export as .eml",
|
||||
"import_email": "Import .eml",
|
||||
"import_email": "Import .eml or .zip",
|
||||
"keyboard_shortcuts": "Keyboard shortcuts (?)",
|
||||
"email_source": "Email Source",
|
||||
"draft_banner": "This message is a draft",
|
||||
@@ -808,6 +808,7 @@
|
||||
"layout": "Layout",
|
||||
"reading": "Reading",
|
||||
"composing": "Composing",
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Content & Senders",
|
||||
"about_data": "About & Data",
|
||||
"debug": "Debug"
|
||||
@@ -1003,6 +1004,7 @@
|
||||
"label": "Delete Action",
|
||||
"description": "What happens when you delete an email",
|
||||
"trash": "Move to Trash",
|
||||
"trash_and_read": "Move to Trash and mark as read",
|
||||
"permanent": "Delete Permanently",
|
||||
"warning": "Emails will be permanently deleted and cannot be recovered. This action is irreversible."
|
||||
},
|
||||
@@ -1535,6 +1537,50 @@
|
||||
"categories_description": "Rename contact categories",
|
||||
"no_categories": "No categories found"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
"description": "Customize how downloaded emails and attachments are named.",
|
||||
"reset": "Restore default",
|
||||
"preview": "Preview:",
|
||||
"email_template": {
|
||||
"label": "Email (.eml) filename",
|
||||
"description": "Template used when you export an email or drag one out to the file system. The .eml extension is added automatically."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Attachment filename",
|
||||
"description": "Template used when downloading or dragging out an attachment. If you omit '{filename}' and '{ext}', the original extension is preserved."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Multi-email .zip filename",
|
||||
"description": "Template used when you drag out or download several selected emails as a single .zip archive. The .zip extension is added automatically."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Spaces",
|
||||
"description": "Replace spaces in the resulting filename with another character.",
|
||||
"keep": "Keep spaces",
|
||||
"underscore": "Replace with _",
|
||||
"dash": "Replace with -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Lowercase",
|
||||
"description": "Force the entire filename to lowercase."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Strip diacritics",
|
||||
"description": "Convert accented letters to their ASCII equivalents (ä → a, é → e). Useful for tools that mangle Unicode filenames."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Collapse repeated separators",
|
||||
"description": "Collapse runs of spaces, underscores, or dashes to a single character."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "After export",
|
||||
"description": "Optionally move the email after exporting it as .eml.",
|
||||
"keep": "Keep in mailbox",
|
||||
"archive": "Move to archive",
|
||||
"trash": "Move to trash"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Email Filters",
|
||||
"description": "Create rules to automatically sort, label, and manage incoming emails",
|
||||
@@ -1801,7 +1847,7 @@
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"import_email": "Import .eml...",
|
||||
"import_email": "Import .eml or .zip...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
"delete_folder": "Delete folder",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Imprimir",
|
||||
"view_source": "Ver código fuente",
|
||||
"export_email": "Exportar como .eml",
|
||||
"import_email": "Importar .eml",
|
||||
"import_email": "Importar .eml o .zip",
|
||||
"keyboard_shortcuts": "Atajos de teclado (?)",
|
||||
"email_source": "Código Fuente del Correo",
|
||||
"draft_banner": "Este mensaje es un borrador",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "Diseño",
|
||||
"reading": "Lectura",
|
||||
"composing": "Redacción",
|
||||
"downloads": "Descargas",
|
||||
"content_senders": "Contenido y remitentes",
|
||||
"about_data": "Acerca de y datos",
|
||||
"debug": "Depuración"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos",
|
||||
"group_by_letter_label": "Agrupar por primera letra"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Descargas",
|
||||
"description": "Personaliza cómo se nombran los correos y archivos adjuntos descargados.",
|
||||
"reset": "Restaurar predeterminado",
|
||||
"preview": "Vista previa:",
|
||||
"email_template": {
|
||||
"label": "Nombre del archivo de correo (.eml)",
|
||||
"description": "Plantilla usada al exportar un correo o al arrastrarlo al sistema de archivos. La extensión .eml se añade automáticamente."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Nombre del archivo adjunto",
|
||||
"description": "Plantilla usada al descargar o arrastrar un adjunto. Si omites '{filename}' y '{ext}', se conserva la extensión original."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Nombre de archivo .zip multi-correo",
|
||||
"description": "Plantilla utilizada al arrastrar o descargar varios correos seleccionados como un único archivo .zip. La extensión .zip se añade automáticamente."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Espacios",
|
||||
"description": "Reemplaza los espacios del nombre de archivo resultante por otro carácter.",
|
||||
"keep": "Conservar espacios",
|
||||
"underscore": "Reemplazar por _",
|
||||
"dash": "Reemplazar por -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Minúsculas",
|
||||
"description": "Forzar todo el nombre del archivo a minúsculas."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Eliminar diacríticos",
|
||||
"description": "Convierte las letras acentuadas en sus equivalentes ASCII (ä → a, é → e). Útil para herramientas que estropean nombres Unicode."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Combinar separadores repetidos",
|
||||
"description": "Combina secuencias de espacios, guiones bajos o guiones en un único carácter."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Después de exportar",
|
||||
"description": "Opcionalmente, mover el correo después de exportarlo como .eml.",
|
||||
"keep": "Mantener en el buzón",
|
||||
"archive": "Mover al archivo",
|
||||
"trash": "Mover a la papelera"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtros de correo",
|
||||
"description": "Cree reglas para ordenar, etiquetar y gestionar automáticamente los correos entrantes",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "Nueva subcarpeta...",
|
||||
"new_folder": "Nueva carpeta...",
|
||||
"rename": "Renombrar...",
|
||||
"import_email": "Importar .eml...",
|
||||
"import_email": "Importar .eml o .zip...",
|
||||
"empty_folder": "Vaciar carpeta",
|
||||
"empty_folder_generic": "Vaciar carpeta",
|
||||
"delete_folder": "Eliminar carpeta",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Imprimer",
|
||||
"view_source": "Voir la source",
|
||||
"export_email": "Exporter en .eml",
|
||||
"import_email": "Importer un .eml",
|
||||
"import_email": "Importer .eml ou .zip",
|
||||
"keyboard_shortcuts": "Raccourcis clavier (?)",
|
||||
"email_source": "Source de l'email",
|
||||
"draft_banner": "Ce message est un brouillon",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "Mise en page",
|
||||
"reading": "Lecture",
|
||||
"composing": "Rédaction",
|
||||
"downloads": "Téléchargements",
|
||||
"content_senders": "Contenu et expéditeurs",
|
||||
"about_data": "À propos et données",
|
||||
"debug": "Débogage"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts",
|
||||
"group_by_letter_label": "Grouper par première lettre"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Téléchargements",
|
||||
"description": "Personnalisez la façon dont les e-mails et pièces jointes téléchargés sont nommés.",
|
||||
"reset": "Restaurer la valeur par défaut",
|
||||
"preview": "Aperçu :",
|
||||
"email_template": {
|
||||
"label": "Nom du fichier e-mail (.eml)",
|
||||
"description": "Modèle utilisé lors de l'export ou du glisser-déposer d'un e-mail vers le système de fichiers. L'extension .eml est ajoutée automatiquement."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Nom du fichier de pièce jointe",
|
||||
"description": "Modèle utilisé lors du téléchargement ou du glisser-déposer d'une pièce jointe. Si vous omettez '{filename}' et '{ext}', l'extension d'origine est conservée."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Nom du fichier .zip multi-e-mails",
|
||||
"description": "Modèle utilisé lorsque vous faites glisser ou téléchargez plusieurs e-mails sélectionnés sous forme d'archive .zip unique. L'extension .zip est ajoutée automatiquement."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Espaces",
|
||||
"description": "Remplacer les espaces dans le nom de fichier final par un autre caractère.",
|
||||
"keep": "Conserver les espaces",
|
||||
"underscore": "Remplacer par _",
|
||||
"dash": "Remplacer par -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Minuscules",
|
||||
"description": "Forcer tout le nom de fichier en minuscules."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Supprimer les diacritiques",
|
||||
"description": "Convertir les lettres accentuées en leur équivalent ASCII (ä → a, é → e). Utile pour les outils qui gèrent mal les noms Unicode."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Fusionner les séparateurs répétés",
|
||||
"description": "Fusionner les suites d’espaces, de tirets bas ou de tirets en un seul caractère."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Après l’export",
|
||||
"description": "Déplacer éventuellement l’e-mail après l’avoir exporté en .eml.",
|
||||
"keep": "Garder dans la boîte",
|
||||
"archive": "Déplacer vers les archives",
|
||||
"trash": "Déplacer vers la corbeille"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres de courrier",
|
||||
"description": "Créez des règles pour trier, étiqueter et gérer automatiquement les courriers entrants",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "Nouveau sous-dossier...",
|
||||
"new_folder": "Nouveau dossier...",
|
||||
"rename": "Renommer...",
|
||||
"import_email": "Importer un .eml...",
|
||||
"import_email": "Importer .eml ou .zip...",
|
||||
"empty_folder": "Vider le dossier",
|
||||
"empty_folder_generic": "Vider le dossier",
|
||||
"delete_folder": "Supprimer le dossier",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Stampa",
|
||||
"view_source": "Visualizza sorgente",
|
||||
"export_email": "Esporta come .eml",
|
||||
"import_email": "Importa .eml",
|
||||
"import_email": "Importa .eml o .zip",
|
||||
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
|
||||
"email_source": "Sorgente del messaggio",
|
||||
"draft_banner": "Questo messaggio è una bozza",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "Layout",
|
||||
"reading": "Lettura",
|
||||
"composing": "Composizione",
|
||||
"downloads": "Download",
|
||||
"content_senders": "Contenuto e mittenti",
|
||||
"about_data": "Informazioni e dati",
|
||||
"debug": "Debug"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti",
|
||||
"group_by_letter_label": "Raggruppa per prima lettera"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Download",
|
||||
"description": "Personalizza come vengono nominati gli email e gli allegati scaricati.",
|
||||
"reset": "Ripristina predefinito",
|
||||
"preview": "Anteprima:",
|
||||
"email_template": {
|
||||
"label": "Nome file email (.eml)",
|
||||
"description": "Modello usato quando esporti un'email o la trascini sul file system. L'estensione .eml viene aggiunta automaticamente."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Nome file dell’allegato",
|
||||
"description": "Modello usato quando scarichi o trascini un allegato. Se ometti '{filename}' e '{ext}', l'estensione originale viene mantenuta."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Nome del file .zip multi-email",
|
||||
"description": "Modello usato quando trascini o scarichi più email selezionate come un singolo archivio .zip. L'estensione .zip viene aggiunta automaticamente."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Spazi",
|
||||
"description": "Sostituisci gli spazi nel nome file risultante con un altro carattere.",
|
||||
"keep": "Mantieni gli spazi",
|
||||
"underscore": "Sostituisci con _",
|
||||
"dash": "Sostituisci con -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Minuscolo",
|
||||
"description": "Forza l'intero nome file in minuscolo."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Rimuovi i segni diacritici",
|
||||
"description": "Converti le lettere accentate nei loro equivalenti ASCII (ä → a, é → e). Utile per strumenti che gestiscono male i nomi Unicode."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Riduci i separatori ripetuti",
|
||||
"description": "Riduci sequenze di spazi, underscore o trattini a un solo carattere."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Dopo l’esportazione",
|
||||
"description": "Sposta facoltativamente l’email dopo averla esportata come .eml.",
|
||||
"keep": "Mantieni nella casella",
|
||||
"archive": "Sposta nell’archivio",
|
||||
"trash": "Sposta nel cestino"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtri email",
|
||||
"description": "Crea regole per ordinare, etichettare e gestire automaticamente le email in arrivo",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "Nuova sottocartella...",
|
||||
"new_folder": "Nuova cartella...",
|
||||
"rename": "Rinomina...",
|
||||
"import_email": "Importa .eml...",
|
||||
"import_email": "Importa .eml o .zip...",
|
||||
"empty_folder": "Svuota cartella",
|
||||
"empty_folder_generic": "Svuota cartella",
|
||||
"delete_folder": "Elimina cartella",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "印刷",
|
||||
"view_source": "ソースを表示",
|
||||
"export_email": ".emlとしてエクスポート",
|
||||
"import_email": ".emlをインポート",
|
||||
"import_email": ".eml または .zip をインポート",
|
||||
"keyboard_shortcuts": "キーボードショートカット (?)",
|
||||
"email_source": "メールソース",
|
||||
"draft_banner": "このメッセージは下書きです",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "レイアウト",
|
||||
"reading": "閲覧",
|
||||
"composing": "作成",
|
||||
"downloads": "ダウンロード",
|
||||
"content_senders": "コンテンツと送信者",
|
||||
"about_data": "情報とデータ",
|
||||
"debug": "デバッグ"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示",
|
||||
"group_by_letter_label": "頭文字でグループ化"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "ダウンロード",
|
||||
"description": "ダウンロードしたメールや添付ファイルの名前の付け方をカスタマイズします。",
|
||||
"reset": "既定に戻す",
|
||||
"preview": "プレビュー:",
|
||||
"email_template": {
|
||||
"label": "メールのファイル名 (.eml)",
|
||||
"description": "メールをエクスポートするか、ファイルシステムにドラッグしたときに使用するテンプレートです。.eml 拡張子は自動的に付加されます。"
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "添付ファイルのファイル名",
|
||||
"description": "添付ファイルをダウンロードまたはドラッグするときに使用するテンプレートです。'{filename}' と '{ext}' を省略すると、元の拡張子が保持されます。"
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "複数メールの .zip ファイル名",
|
||||
"description": "複数の選択したメールを 1 つの .zip としてドラッグまたはダウンロードするときに使用するテンプレートです。拡張子 .zip は自動的に付加されます。"
|
||||
},
|
||||
"spaces": {
|
||||
"label": "スペース",
|
||||
"description": "最終的なファイル名のスペースを別の文字に置き換えます。",
|
||||
"keep": "スペースを残す",
|
||||
"underscore": "_ に置換",
|
||||
"dash": "- に置換"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "小文字",
|
||||
"description": "ファイル名全体を小文字に強制します。"
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "ダイアクリティカルマークを除去",
|
||||
"description": "アクセント付き文字を ASCII の同等の文字に変換します (ä → a, é → e)。Unicode のファイル名を正しく扱えないツールで便利です。"
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "連続した区切り文字をまとめる",
|
||||
"description": "連続するスペース、アンダースコア、ハイフンを 1 文字にまとめます。"
|
||||
},
|
||||
"after_export": {
|
||||
"label": "エクスポート後",
|
||||
"description": "メールを .eml としてエクスポートした後、必要に応じて移動します。",
|
||||
"keep": "メールボックスに残す",
|
||||
"archive": "アーカイブへ移動",
|
||||
"trash": "ごみ箱へ移動"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "メールフィルター",
|
||||
"description": "受信メールを自動で振り分け、ラベル付け、管理するルールを作成します",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "新しいサブフォルダー...",
|
||||
"new_folder": "新しいフォルダー...",
|
||||
"rename": "名前を変更...",
|
||||
"import_email": ".eml をインポート...",
|
||||
"import_email": ".eml または .zip をインポート...",
|
||||
"empty_folder": "フォルダーを空にする",
|
||||
"empty_folder_generic": "フォルダーを空にする",
|
||||
"delete_folder": "フォルダーを削除",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "인쇄",
|
||||
"view_source": "원본 보기",
|
||||
"export_email": ".eml 파일로 내보내기",
|
||||
"import_email": ".eml 파일 가져오기",
|
||||
"import_email": ".eml 또는 .zip 가져오기",
|
||||
"keyboard_shortcuts": "단축키 (?)",
|
||||
"email_source": "메일 원본",
|
||||
"draft_banner": "작성 중인 임시보관 메일이에요",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "레이아웃",
|
||||
"reading": "읽기",
|
||||
"composing": "작성",
|
||||
"downloads": "다운로드",
|
||||
"content_senders": "콘텐츠 및 발신자",
|
||||
"about_data": "정보 및 데이터",
|
||||
"debug": "디버그"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시",
|
||||
"group_by_letter_label": "첫 글자로 그룹화"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "다운로드",
|
||||
"description": "다운로드한 이메일과 첨부 파일의 이름을 지정하는 방식을 사용자 정의합니다.",
|
||||
"reset": "기본값 복원",
|
||||
"preview": "미리 보기:",
|
||||
"email_template": {
|
||||
"label": "이메일 파일 이름 (.eml)",
|
||||
"description": "이메일을 내보내거나 파일 시스템으로 드래그할 때 사용하는 템플릿입니다. .eml 확장자는 자동으로 추가됩니다."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "첨부 파일 이름",
|
||||
"description": "첨부 파일을 다운로드하거나 드래그할 때 사용하는 템플릿입니다. '{filename}'과 '{ext}'를 생략하면 원래 확장자가 유지됩니다."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "다중 이메일 .zip 파일 이름",
|
||||
"description": "여러 선택한 이메일을 하나의 .zip 아카이브로 드래그하거나 다운로드할 때 사용하는 템플릿입니다. .zip 확장자는 자동으로 추가됩니다."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "공백",
|
||||
"description": "결과 파일 이름의 공백을 다른 문자로 바꿉니다.",
|
||||
"keep": "공백 유지",
|
||||
"underscore": "_로 바꾸기",
|
||||
"dash": "-로 바꾸기"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "소문자",
|
||||
"description": "전체 파일 이름을 소문자로 강제 변환합니다."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "발음 구별 부호 제거",
|
||||
"description": "악센트가 있는 문자를 ASCII 등가 문자로 변환합니다 (ä → a, é → e). 유니코드 파일 이름을 제대로 처리하지 못하는 도구에 유용합니다."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "반복된 구분자 결합",
|
||||
"description": "연속된 공백, 밑줄, 대시를 단일 문자로 결합합니다."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "내보낸 후",
|
||||
"description": ".eml로 내보낸 후 이메일을 선택적으로 이동합니다.",
|
||||
"keep": "메일함에 유지",
|
||||
"archive": "보관함으로 이동",
|
||||
"trash": "휴지통으로 이동"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "이메일 필터",
|
||||
"description": "새로 온 메일을 자동으로 분류하고 라벨을 붙이는 규칙을 만들어 보세요",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "새 하위 폴더...",
|
||||
"new_folder": "새 폴더...",
|
||||
"rename": "이름 바꾸기...",
|
||||
"import_email": ".eml 가져오기...",
|
||||
"import_email": ".eml 또는 .zip 가져오기...",
|
||||
"empty_folder": "폴더 비우기",
|
||||
"empty_folder_generic": "폴더 비우기",
|
||||
"delete_folder": "폴더 삭제",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Drukāt",
|
||||
"view_source": "Skatīt avota kodu",
|
||||
"export_email": "Eksportēt kā .eml",
|
||||
"import_email": "Importēt .eml",
|
||||
"import_email": "Importēt .eml vai .zip",
|
||||
"keyboard_shortcuts": "Īsinājumtaustiņi (?)",
|
||||
"email_source": "Vēstules avota kods",
|
||||
"draft_banner": "Šī vēstule ir melnraksts",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "Izkārtojums",
|
||||
"reading": "Lasīšana",
|
||||
"composing": "Rakstīšana",
|
||||
"downloads": "Lejupielādes",
|
||||
"content_senders": "Saturs un sūtītāji",
|
||||
"about_data": "Par un dati",
|
||||
"debug": "Atkļūdošana"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā",
|
||||
"group_by_letter_label": "Grupēt pēc pirmā burta"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Lejupielādes",
|
||||
"description": "Pielāgojiet, kā tiek nosaukti lejupielādētie e-pasti un pielikumi.",
|
||||
"reset": "Atjaunot noklusējumu",
|
||||
"preview": "Priekšskatījums:",
|
||||
"email_template": {
|
||||
"label": "E-pasta faila nosaukums (.eml)",
|
||||
"description": "Veidne, ko izmanto, eksportējot e-pastu vai velkot to uz failu sistēmu. Paplašinājums .eml tiek pievienots automātiski."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Pielikuma faila nosaukums",
|
||||
"description": "Veidne, ko izmanto, lejupielādējot vai velkot pielikumu. Ja izlaižat '{filename}' un '{ext}', sākotnējais paplašinājums tiek saglabāts."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Vairāku e-pastu .zip faila nosaukums",
|
||||
"description": "Veidne, ko izmanto, kad velkat vai lejupielādējat vairākus atlasītos e-pastus kā vienu .zip arhīvu. Paplašinājums .zip tiek pievienots automātiski."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Atstarpes",
|
||||
"description": "Aizstāt atstarpes izvades faila nosaukumā ar citu rakstzīmi.",
|
||||
"keep": "Saglabāt atstarpes",
|
||||
"underscore": "Aizstāt ar _",
|
||||
"dash": "Aizstāt ar -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Mazie burti",
|
||||
"description": "Piespiest visu faila nosaukumu uz mazajiem burtiem."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Noņemt diakritikas zīmes",
|
||||
"description": "Pārvērst burtus ar akcentiem to ASCII ekvivalentos (ä → a, é → e). Noderīgi rīkiem, kas slikti apstrādā Unicode failu nosaukumus."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Apvienot atkārtotus atdalītājus",
|
||||
"description": "Apvienot virknes atstarpju, pasvītrojumu vai defisu vienā rakstzīmē."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Pēc eksporta",
|
||||
"description": "Pēc e-pasta eksportēšanas kā .eml to var pārvietot.",
|
||||
"keep": "Paturēt pastkastē",
|
||||
"archive": "Pārvietot uz arhīvu",
|
||||
"trash": "Pārvietot uz miskasti"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Pasta filtri",
|
||||
"description": "Izveidojiet noteikumus automātiskai vēstuļu šķirošanai un pārvaldībai",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "Jauna apakšmape...",
|
||||
"new_folder": "Jauna mape...",
|
||||
"rename": "Pārsaukt...",
|
||||
"import_email": "Importēt .eml...",
|
||||
"import_email": "Importēt .eml vai .zip...",
|
||||
"empty_folder": "Iztukšot mapi",
|
||||
"empty_folder_generic": "Iztukšot mapi",
|
||||
"delete_folder": "Dzēst mapi",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Afdrukken",
|
||||
"view_source": "Bron bekijken",
|
||||
"export_email": "Exporteren als .eml",
|
||||
"import_email": ".eml importeren",
|
||||
"import_email": ".eml of .zip importeren",
|
||||
"keyboard_shortcuts": "Sneltoetsen (?)",
|
||||
"email_source": "E-mailbron",
|
||||
"draft_banner": "Dit bericht is een concept",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "Indeling",
|
||||
"reading": "Lezen",
|
||||
"composing": "Opstellen",
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Inhoud en afzenders",
|
||||
"about_data": "Over en gegevens",
|
||||
"debug": "Debuggen"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst",
|
||||
"group_by_letter_label": "Groeperen op eerste letter"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
"description": "Pas aan hoe gedownloade e-mails en bijlagen worden genoemd.",
|
||||
"reset": "Standaard herstellen",
|
||||
"preview": "Voorbeeld:",
|
||||
"email_template": {
|
||||
"label": "E-mailbestandsnaam (.eml)",
|
||||
"description": "Sjabloon dat wordt gebruikt bij het exporteren of slepen van een e-mail naar het bestandssysteem. De extensie .eml wordt automatisch toegevoegd."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Bestandsnaam van bijlage",
|
||||
"description": "Sjabloon bij het downloaden of slepen van een bijlage. Als je '{filename}' en '{ext}' weglaat, blijft de oorspronkelijke extensie behouden."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Bestandsnaam .zip met meerdere e-mails",
|
||||
"description": "Sjabloon dat wordt gebruikt wanneer je meerdere geselecteerde e-mails als één .zip-archief sleept of downloadt. De extensie .zip wordt automatisch toegevoegd."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Spaties",
|
||||
"description": "Vervang spaties in de uiteindelijke bestandsnaam door een ander teken.",
|
||||
"keep": "Spaties behouden",
|
||||
"underscore": "Vervangen door _",
|
||||
"dash": "Vervangen door -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Kleine letters",
|
||||
"description": "Forceer de hele bestandsnaam in kleine letters."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Diakritische tekens verwijderen",
|
||||
"description": "Zet letters met accenten om naar hun ASCII-equivalent (ä → a, é → e). Handig voor tools die Unicode-namen niet goed verwerken."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Herhaalde scheidingstekens samenvoegen",
|
||||
"description": "Voeg reeksen spaties, underscores of streepjes samen tot één teken."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Na exporteren",
|
||||
"description": "Verplaats de e-mail eventueel nadat deze als .eml is geëxporteerd.",
|
||||
"keep": "In postvak houden",
|
||||
"archive": "Naar archief verplaatsen",
|
||||
"trash": "Naar prullenbak verplaatsen"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-mailfilters",
|
||||
"description": "Maak regels om inkomende e-mails automatisch te sorteren, labelen en beheren",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "Nieuwe submap...",
|
||||
"new_folder": "Nieuwe map...",
|
||||
"rename": "Hernoemen...",
|
||||
"import_email": ".eml importeren...",
|
||||
"import_email": ".eml of .zip importeren...",
|
||||
"empty_folder": "Map leegmaken",
|
||||
"empty_folder_generic": "Map leegmaken",
|
||||
"delete_folder": "Map verwijderen",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Drukuj",
|
||||
"view_source": "Pokaż źródło",
|
||||
"export_email": "Eksportuj jako .eml",
|
||||
"import_email": "Importuj .eml",
|
||||
"import_email": "Importuj .eml lub .zip",
|
||||
"keyboard_shortcuts": "Skróty klawiszowe (?)",
|
||||
"email_source": "Źródło wiadomości",
|
||||
"draft_banner": "Ta wiadomość jest szkicem",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "Układ",
|
||||
"reading": "Czytanie",
|
||||
"composing": "Tworzenie",
|
||||
"downloads": "Pobrane",
|
||||
"content_senders": "Treść i nadawcy",
|
||||
"about_data": "O programie i dane",
|
||||
"debug": "Debugowanie"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów",
|
||||
"group_by_letter_label": "Grupuj według pierwszej litery"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Pobrane",
|
||||
"description": "Dostosuj sposób nazywania pobranych wiadomości i załączników.",
|
||||
"reset": "Przywróć domyślne",
|
||||
"preview": "Podgląd:",
|
||||
"email_template": {
|
||||
"label": "Nazwa pliku wiadomości (.eml)",
|
||||
"description": "Szablon używany przy eksporcie wiadomości lub jej przeciąganiu do systemu plików. Rozszerzenie .eml jest dodawane automatycznie."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Nazwa pliku załącznika",
|
||||
"description": "Szablon używany przy pobieraniu lub przeciąganiu załącznika. Jeśli pominiesz '{filename}' i '{ext}', oryginalne rozszerzenie zostanie zachowane."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Nazwa pliku .zip wielu wiadomości",
|
||||
"description": "Szablon używany przy przeciąganiu lub pobieraniu kilku wybranych wiadomości jako jednego archiwum .zip. Rozszerzenie .zip jest dodawane automatycznie."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Spacje",
|
||||
"description": "Zastąp spacje w nazwie pliku innym znakiem.",
|
||||
"keep": "Zachowaj spacje",
|
||||
"underscore": "Zamień na _",
|
||||
"dash": "Zamień na -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Małe litery",
|
||||
"description": "Wymuś małe litery w całej nazwie pliku."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Usuń znaki diakrytyczne",
|
||||
"description": "Zamień litery z akcentami na ich odpowiedniki ASCII (ą → a, é → e). Przydatne dla narzędzi, które źle obsługują nazwy Unicode."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Łącz powtórzone separatory",
|
||||
"description": "Połącz ciągi spacji, podkreśleń lub myślników w jeden znak."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Po eksporcie",
|
||||
"description": "Opcjonalnie przenieś wiadomość po wyeksportowaniu jako .eml.",
|
||||
"keep": "Zachowaj w skrzynce",
|
||||
"archive": "Przenieś do archiwum",
|
||||
"trash": "Przenieś do kosza"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtry wiadomości e-mail",
|
||||
"description": "Twórz reguły, aby automatycznie sortować, etykietować i zarządzać przychodzącymi wiadomościami e-mail",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "Nowy podfolder...",
|
||||
"new_folder": "Nowy folder...",
|
||||
"rename": "Zmień nazwę...",
|
||||
"import_email": "Importuj .eml...",
|
||||
"import_email": "Importuj .eml lub .zip...",
|
||||
"empty_folder": "Opróżnij folder",
|
||||
"empty_folder_generic": "Opróżnij folder",
|
||||
"delete_folder": "Usuń folder",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Imprimir",
|
||||
"view_source": "Ver código-fonte",
|
||||
"export_email": "Exportar como .eml",
|
||||
"import_email": "Importar .eml",
|
||||
"import_email": "Importar .eml ou .zip",
|
||||
"keyboard_shortcuts": "Atalhos de teclado (?)",
|
||||
"email_source": "Código-fonte do E-mail",
|
||||
"draft_banner": "Esta mensagem é um rascunho",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "Layout",
|
||||
"reading": "Leitura",
|
||||
"composing": "Composição",
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Conteúdo e remetentes",
|
||||
"about_data": "Sobre e dados",
|
||||
"debug": "Depuração"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos",
|
||||
"group_by_letter_label": "Agrupar pela primeira letra"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
"description": "Personalize como os e-mails e anexos baixados são nomeados.",
|
||||
"reset": "Restaurar padrão",
|
||||
"preview": "Pré-visualização:",
|
||||
"email_template": {
|
||||
"label": "Nome do arquivo de e-mail (.eml)",
|
||||
"description": "Modelo usado ao exportar um e-mail ou arrastá-lo para o sistema de arquivos. A extensão .eml é adicionada automaticamente."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Nome do arquivo do anexo",
|
||||
"description": "Modelo usado ao baixar ou arrastar um anexo. Se você omitir '{filename}' e '{ext}', a extensão original é preservada."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Nome do arquivo .zip multi-e-mails",
|
||||
"description": "Modelo usado ao arrastar ou baixar vários e-mails selecionados como um único arquivo .zip. A extensão .zip é adicionada automaticamente."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Espaços",
|
||||
"description": "Substitui espaços no nome de arquivo resultante por outro caractere.",
|
||||
"keep": "Manter espaços",
|
||||
"underscore": "Substituir por _",
|
||||
"dash": "Substituir por -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Minúsculas",
|
||||
"description": "Forçar todo o nome do arquivo para minúsculas."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Remover acentos",
|
||||
"description": "Converte letras acentuadas para o equivalente ASCII (ä → a, é → e). Útil para ferramentas que estragam nomes Unicode."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Combinar separadores repetidos",
|
||||
"description": "Combina sequências de espaços, sublinhados ou hifens em um único caractere."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Após exportar",
|
||||
"description": "Opcionalmente, mover o e-mail depois de exportá-lo como .eml.",
|
||||
"keep": "Manter na caixa",
|
||||
"archive": "Mover para o arquivo",
|
||||
"trash": "Mover para a lixeira"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtros de e-mail",
|
||||
"description": "Crie regras para classificar, rotular e gerenciar automaticamente os e-mails recebidos",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "Nova subpasta...",
|
||||
"new_folder": "Nova pasta...",
|
||||
"rename": "Renomear...",
|
||||
"import_email": "Importar .eml...",
|
||||
"import_email": "Importar .eml ou .zip...",
|
||||
"empty_folder": "Esvaziar pasta",
|
||||
"empty_folder_generic": "Esvaziar pasta",
|
||||
"delete_folder": "Excluir pasta",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Распечатать",
|
||||
"view_source": "Просмотреть исходный код",
|
||||
"export_email": "Экспортировать как .eml",
|
||||
"import_email": "Импортировать .eml",
|
||||
"import_email": "Импортировать .eml или .zip",
|
||||
"keyboard_shortcuts": "Сочетания клавиш (?)",
|
||||
"email_source": "Исходный код письма",
|
||||
"draft_banner": "Это письмо является черновиком",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "Макет",
|
||||
"reading": "Чтение",
|
||||
"composing": "Написание",
|
||||
"downloads": "Загрузки",
|
||||
"content_senders": "Содержимое и отправители",
|
||||
"about_data": "О программе и данные",
|
||||
"debug": "Отладка"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов",
|
||||
"group_by_letter_label": "Группировать по первой букве"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Загрузки",
|
||||
"description": "Настройте, как именуются загруженные письма и вложения.",
|
||||
"reset": "Восстановить по умолчанию",
|
||||
"preview": "Предпросмотр:",
|
||||
"email_template": {
|
||||
"label": "Имя файла письма (.eml)",
|
||||
"description": "Шаблон, используемый при экспорте письма или при перетаскивании его в файловую систему. Расширение .eml добавляется автоматически."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Имя файла вложения",
|
||||
"description": "Шаблон, используемый при загрузке или перетаскивании вложения. Если опустить '{filename}' и '{ext}', исходное расширение сохраняется."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Имя файла .zip с несколькими письмами",
|
||||
"description": "Шаблон, используемый при перетаскивании или загрузке нескольких выбранных писем одним .zip-архивом. Расширение .zip добавляется автоматически."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Пробелы",
|
||||
"description": "Заменять пробелы в итоговом имени файла другим символом.",
|
||||
"keep": "Сохранять пробелы",
|
||||
"underscore": "Заменять на _",
|
||||
"dash": "Заменять на -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Нижний регистр",
|
||||
"description": "Принудительно перевести всё имя файла в нижний регистр."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Удалять диакритику",
|
||||
"description": "Преобразовать буквы с акцентами в их ASCII-эквиваленты (ä → a, é → e). Полезно для инструментов, плохо работающих с Unicode-именами."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Сжимать повторяющиеся разделители",
|
||||
"description": "Сжимать последовательности пробелов, подчёркиваний или дефисов до одного символа."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "После экспорта",
|
||||
"description": "При необходимости переместить письмо после экспорта в .eml.",
|
||||
"keep": "Оставить в ящике",
|
||||
"archive": "Переместить в архив",
|
||||
"trash": "Переместить в корзину"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Фильтры почты",
|
||||
"description": "Создавайте правила для автоматической сортировки, маркировки и управления входящими письмами",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "Новая вложенная папка...",
|
||||
"new_folder": "Новая папка...",
|
||||
"rename": "Переименовать...",
|
||||
"import_email": "Импортировать .eml...",
|
||||
"import_email": "Импортировать .eml или .zip...",
|
||||
"empty_folder": "Очистить папку",
|
||||
"empty_folder_generic": "Очистить папку",
|
||||
"delete_folder": "Удалить папку",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Yazdır",
|
||||
"view_source": "Kaynağı görüntüle",
|
||||
"export_email": ".eml olarak dışa aktar",
|
||||
"import_email": ".eml içe aktar",
|
||||
"import_email": ".eml veya .zip içe aktar",
|
||||
"keyboard_shortcuts": "Klavye kısayolları (?)",
|
||||
"email_source": "E-posta Kaynağı",
|
||||
"draft_banner": "Bu ileti bir taslaktır",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "Düzen",
|
||||
"reading": "Okuma",
|
||||
"composing": "Yazma",
|
||||
"downloads": "İndirilenler",
|
||||
"content_senders": "İçerik ve Göndericiler",
|
||||
"about_data": "Hakkında ve Veriler",
|
||||
"debug": "Hata Ayıklama"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"categories_description": "Kişi kategorilerini yeniden adlandırın",
|
||||
"no_categories": "Kategori bulunamadı"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "İndirilenler",
|
||||
"description": "İndirilen e-postaların ve eklerin nasıl adlandırılacağını özelleştirin.",
|
||||
"reset": "Varsayılana sıfırla",
|
||||
"preview": "Önizleme:",
|
||||
"email_template": {
|
||||
"label": "E-posta dosya adı (.eml)",
|
||||
"description": "Bir e-postayı dışa aktarırken veya dosya sistemine sürüklediğinizde kullanılan şablon. .eml uzantısı otomatik olarak eklenir."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Ek dosyası adı",
|
||||
"description": "Bir eki indirirken veya sürüklerken kullanılan şablon. '{filename}' ve '{ext}' yazılmazsa orijinal uzantı korunur."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Çoklu e-posta .zip dosya adı",
|
||||
"description": "Birden çok seçili e-postayı tek bir .zip arşivi olarak sürüklediğinizde veya indirdiğinizde kullanılan şablon. .zip uzantısı otomatik olarak eklenir."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Boşluklar",
|
||||
"description": "Sonuçtaki dosya adındaki boşlukları başka bir karakterle değiştirin.",
|
||||
"keep": "Boşlukları koru",
|
||||
"underscore": "_ ile değiştir",
|
||||
"dash": "- ile değiştir"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Küçük harf",
|
||||
"description": "Tüm dosya adını küçük harfe zorla."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Aksanları kaldır",
|
||||
"description": "Aksanlı harfleri ASCII karşılıklarına dönüştür (ä → a, é → e). Unicode dosya adlarıyla sorun yaşayan araçlar için yararlıdır."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Tekrarlanan ayırıcıları birleştir",
|
||||
"description": "Art arda gelen boşluk, alt çizgi veya tireleri tek bir karaktere indir."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Dışa aktarmadan sonra",
|
||||
"description": "E-postayı .eml olarak dışa aktardıktan sonra isteğe bağlı olarak taşı.",
|
||||
"keep": "Posta kutusunda tut",
|
||||
"archive": "Arşive taşı",
|
||||
"trash": "Çöp kutusuna taşı"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-posta Filtreleri",
|
||||
"description": "Gelen e-postaları otomatik olarak sıralamak, etiketlemek ve yönetmek için kurallar oluşturun",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "Yeni alt klasör...",
|
||||
"new_folder": "Yeni klasör...",
|
||||
"rename": "Yeniden adlandır...",
|
||||
"import_email": ".eml içe aktar...",
|
||||
"import_email": ".eml veya .zip içe aktar...",
|
||||
"empty_folder": "Klasörü boşalt",
|
||||
"empty_folder_generic": "Klasörü boşalt",
|
||||
"delete_folder": "Klasörü sil",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "Роздрукувати",
|
||||
"view_source": "Переглянути джерело",
|
||||
"export_email": "Експортувати як .eml",
|
||||
"import_email": "Імпорт .eml",
|
||||
"import_email": "Імпорт .eml або .zip",
|
||||
"keyboard_shortcuts": "Комбінації клавіш (?)",
|
||||
"email_source": "Джерело електронної пошти",
|
||||
"draft_banner": "Це повідомлення є чернеткою",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "Макет",
|
||||
"reading": "Читання",
|
||||
"composing": "Написання",
|
||||
"downloads": "Завантаження",
|
||||
"content_senders": "Вміст і відправники",
|
||||
"about_data": "Про програму та дані",
|
||||
"debug": "Налагодження"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів",
|
||||
"group_by_letter_label": "Групувати за першою літерою"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Завантаження",
|
||||
"description": "Налаштуйте, як називаються завантажені листи та вкладення.",
|
||||
"reset": "Відновити стандартне",
|
||||
"preview": "Попередній перегляд:",
|
||||
"email_template": {
|
||||
"label": "Ім'я файлу листа (.eml)",
|
||||
"description": "Шаблон, що використовується при експорті листа або перетягуванні його у файлову систему. Розширення .eml додається автоматично."
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "Ім'я файлу вкладення",
|
||||
"description": "Шаблон, що використовується при завантаженні або перетягуванні вкладення. Якщо пропустити '{filename}' та '{ext}', оригінальне розширення зберігається."
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "Ім'я файлу .zip з кількома листами",
|
||||
"description": "Шаблон, що використовується при перетягуванні або завантаженні кількох вибраних листів одним .zip-архівом. Розширення .zip додається автоматично."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Пробіли",
|
||||
"description": "Замінити пробіли в підсумковому імені файлу іншим символом.",
|
||||
"keep": "Залишити пробіли",
|
||||
"underscore": "Замінити на _",
|
||||
"dash": "Замінити на -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Нижній регістр",
|
||||
"description": "Примусово перетворити все ім'я файлу на нижній регістр."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Видалити діакритику",
|
||||
"description": "Перетворити літери з акцентами на ASCII-еквіваленти (ä → a, é → e). Корисно для інструментів, що погано обробляють Unicode."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Об'єднати повторювані роздільники",
|
||||
"description": "Об'єднати послідовності пробілів, підкреслень або дефісів в один символ."
|
||||
},
|
||||
"after_export": {
|
||||
"label": "Після експорту",
|
||||
"description": "За потреби перемістити лист після експорту як .eml.",
|
||||
"keep": "Залишити у скриньці",
|
||||
"archive": "Перемістити в архів",
|
||||
"trash": "Перемістити у кошик"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Фільтри електронної пошти",
|
||||
"description": "Створіть правила для автоматичного сортування, позначення та керування вхідними електронними листами",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "Нова вкладена папка...",
|
||||
"new_folder": "Нова папка...",
|
||||
"rename": "Перейменувати...",
|
||||
"import_email": "Імпортувати .eml...",
|
||||
"import_email": "Імпортувати .eml або .zip...",
|
||||
"empty_folder": "Очистити папку",
|
||||
"empty_folder_generic": "Очистити папку",
|
||||
"delete_folder": "Видалити папку",
|
||||
|
||||
+47
-2
@@ -280,7 +280,7 @@
|
||||
"print": "打印",
|
||||
"view_source": "查看源码",
|
||||
"export_email": "导出为 .eml",
|
||||
"import_email": "导入 .eml",
|
||||
"import_email": "导入 .eml 或 .zip",
|
||||
"keyboard_shortcuts": "键盘快捷键(?)",
|
||||
"email_source": "邮件源码",
|
||||
"draft_banner": "这是一封草稿邮件",
|
||||
@@ -807,6 +807,7 @@
|
||||
"layout": "布局",
|
||||
"reading": "阅读",
|
||||
"composing": "撰写",
|
||||
"downloads": "下载",
|
||||
"content_senders": "内容和发件人",
|
||||
"about_data": "关于和数据",
|
||||
"debug": "调试"
|
||||
@@ -1534,6 +1535,50 @@
|
||||
"group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题",
|
||||
"group_by_letter_label": "按首字母分组"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "下载",
|
||||
"description": "自定义下载的邮件和附件的命名方式。",
|
||||
"reset": "恢复默认",
|
||||
"preview": "预览:",
|
||||
"email_template": {
|
||||
"label": "邮件文件名 (.eml)",
|
||||
"description": "导出邮件或将其拖到文件系统时使用的模板。扩展名 .eml 会自动添加。"
|
||||
},
|
||||
"attachment_template": {
|
||||
"label": "附件文件名",
|
||||
"description": "下载或拖出附件时使用的模板。如果省略 '{filename}' 和 '{ext}',将保留原始扩展名。"
|
||||
},
|
||||
"bundle_template": {
|
||||
"label": "多邮件 .zip 文件名",
|
||||
"description": "将多个选中的邮件作为单个 .zip 拖出或下载时使用的模板。扩展名 .zip 会自动添加。"
|
||||
},
|
||||
"spaces": {
|
||||
"label": "空格",
|
||||
"description": "用其他字符替换最终文件名中的空格。",
|
||||
"keep": "保留空格",
|
||||
"underscore": "替换为 _",
|
||||
"dash": "替换为 -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "小写",
|
||||
"description": "将整个文件名强制转换为小写。"
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "去除变音符号",
|
||||
"description": "将带重音的字母转换为对应的 ASCII 字符(ä → a, é → e)。对处理 Unicode 文件名有问题的工具很有用。"
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "合并重复的分隔符",
|
||||
"description": "将连续的空格、下划线或短横线合并为单个字符。"
|
||||
},
|
||||
"after_export": {
|
||||
"label": "导出后",
|
||||
"description": "将邮件导出为 .eml 后可选择移动到其他位置。",
|
||||
"keep": "保留在邮箱中",
|
||||
"archive": "移动到归档",
|
||||
"trash": "移动到回收站"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "邮件过滤器",
|
||||
"description": "创建规则,自动整理、标记和管理收到的邮件",
|
||||
@@ -1803,7 +1848,7 @@
|
||||
"new_subfolder": "新建子文件夹...",
|
||||
"new_folder": "新建文件夹...",
|
||||
"rename": "重命名...",
|
||||
"import_email": "导入 .eml...",
|
||||
"import_email": "导入 .eml 或 .zip...",
|
||||
"empty_folder": "清空文件夹",
|
||||
"empty_folder_generic": "清空文件夹",
|
||||
"delete_folder": "删除文件夹",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.1",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.1",
|
||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||
import { parseDuration } from '@/components/calendar/event-card';
|
||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
||||
@@ -302,8 +303,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
after: start,
|
||||
before: end,
|
||||
});
|
||||
// Filter out malformed events missing required 'start' field
|
||||
const validEvents = rawEvents.filter(e => typeof e.start === 'string' && e.start);
|
||||
// Filter out malformed events missing required 'start' field, or
|
||||
// whose start string fails to parse (would otherwise crash format()
|
||||
// calls in the rendering path - #316).
|
||||
const validEvents = rawEvents.filter(e =>
|
||||
typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())
|
||||
);
|
||||
const droppedEvents = rawEvents.length - validEvents.length;
|
||||
// Expand recurring events client-side (Stalwart doesn't support
|
||||
// mutations on synthetic IDs from server-side expandRecurrences)
|
||||
@@ -366,7 +371,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
accounts.map(async ({ client, localAccountId }) => {
|
||||
try {
|
||||
const raw = await client.queryAllCalendarEvents({ after: start, before: end });
|
||||
const valid = raw.filter(e => typeof e.start === 'string' && e.start);
|
||||
const valid = raw.filter(e =>
|
||||
typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())
|
||||
);
|
||||
const expanded = expandRecurringEvents(valid, start, end);
|
||||
return prefixEventsWithLocalAccount(
|
||||
expanded,
|
||||
|
||||
+20
-9
@@ -872,14 +872,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
forceDelete = true;
|
||||
}
|
||||
|
||||
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox
|
||||
if (deleteAction === 'trash' && !forceDelete) {
|
||||
// If deleteAction is 'trash' or 'trash-and-read' and not forced permanent delete, try to move to trash mailbox
|
||||
if ((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete) {
|
||||
const trashMailbox = findTrashMailbox(mailboxes, { accountId });
|
||||
const alsoMarkRead = deleteAction === 'trash-and-read' && isUnread;
|
||||
|
||||
if (trashMailbox) {
|
||||
// Use originalId for shared mailboxes if available
|
||||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||||
await effectiveClient.moveToTrash(emailId, trashId, accountId);
|
||||
await effectiveClient.moveToTrash(emailId, trashId, accountId, alsoMarkRead);
|
||||
|
||||
// After marking read in the same request, the email arrives in trash as read.
|
||||
const arrivesUnread = isUnread && !alsoMarkRead;
|
||||
|
||||
// Remove from local state (email moved to trash, not in current view)
|
||||
set((state) => {
|
||||
@@ -902,9 +906,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: mailbox.totalEmails + 1,
|
||||
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
|
||||
unreadEmails: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
|
||||
totalThreads: mailbox.totalThreads + 1,
|
||||
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
|
||||
unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
|
||||
};
|
||||
}
|
||||
return mailbox;
|
||||
@@ -1613,6 +1617,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
|
||||
const isInJunk = currentMailbox?.role === 'junk';
|
||||
const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk);
|
||||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
|
||||
|
||||
// Group emails by accountId (handles unified view and search results spanning accounts).
|
||||
const emailsByAccount = new Map<string, string[]>();
|
||||
@@ -1653,7 +1658,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||||
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId);
|
||||
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId, alsoMarkRead);
|
||||
ids.forEach(id => movedEmailIds.add(id));
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
@@ -1844,7 +1849,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
});
|
||||
|
||||
try {
|
||||
await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread;
|
||||
await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId, alsoMarkRead);
|
||||
|
||||
set(state => ({
|
||||
emails: state.emails.filter(e => e.id !== emailId),
|
||||
@@ -1899,16 +1906,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
|
||||
batchMarkAsSpam: async (client, emailIds) => {
|
||||
const { selectedMailbox } = get();
|
||||
const { selectedMailbox, emails } = get();
|
||||
const mailboxes = resolveActionMailboxes();
|
||||
const effectiveClient = resolveActionClient(client);
|
||||
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
if (!currentMailbox) return;
|
||||
|
||||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
|
||||
|
||||
try {
|
||||
for (const emailId of emailIds) {
|
||||
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId);
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
const markRead = alsoMarkRead && !!email && !email.keywords?.$seen;
|
||||
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId, markRead);
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
|
||||
@@ -28,7 +28,7 @@ export type FontSize = 'small' | 'medium' | 'large';
|
||||
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
||||
/** @deprecated Use Density instead */
|
||||
export type ListDensity = Density;
|
||||
export type DeleteAction = 'trash' | 'permanent';
|
||||
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
|
||||
export type ReplyMode = 'reply' | 'replyAll';
|
||||
export type SignaturePosition = 'above_quote' | 'below_quote';
|
||||
export type DateFormat = 'regional' | 'iso' | 'custom';
|
||||
@@ -241,6 +241,16 @@ interface SettingsState {
|
||||
tourCompleted: boolean; // Interactive tour completed
|
||||
showOnboardingOnNewDevices: boolean; // When true, onboarding shows again on each new device
|
||||
|
||||
// Downloads
|
||||
emailDownloadTemplate: string;
|
||||
attachmentDownloadTemplate: string;
|
||||
bundleDownloadTemplate: string;
|
||||
filenameSpaceReplacement: 'keep' | 'underscore' | 'dash';
|
||||
filenameLowercase: boolean;
|
||||
filenameStripDiacritics: boolean;
|
||||
filenameCollapseSeparators: boolean;
|
||||
postExportAction: 'keep' | 'archive' | 'trash';
|
||||
|
||||
// Advanced
|
||||
debugMode: boolean;
|
||||
debugCategories: Record<DebugCategory, boolean>;
|
||||
@@ -427,6 +437,16 @@ const DEFAULT_SETTINGS = {
|
||||
tourCompleted: false,
|
||||
showOnboardingOnNewDevices: false,
|
||||
|
||||
// Downloads
|
||||
emailDownloadTemplate: '{date} ({from}-{to}) {subject}',
|
||||
attachmentDownloadTemplate: '{filename}',
|
||||
bundleDownloadTemplate: 'emails-{count}',
|
||||
filenameSpaceReplacement: 'keep' as 'keep' | 'underscore' | 'dash',
|
||||
filenameLowercase: false,
|
||||
filenameStripDiacritics: false,
|
||||
filenameCollapseSeparators: true,
|
||||
postExportAction: 'keep' as 'keep' | 'archive' | 'trash',
|
||||
|
||||
// Advanced
|
||||
debugMode: false,
|
||||
debugCategories: {
|
||||
|
||||
Reference in New Issue
Block a user