Merge branch 'bulwarkmail:main' into feature/scheduled-send

This commit is contained in:
Lucas Gaitzsch
2026-05-23 06:39:35 +02:00
committed by GitHub
54 changed files with 1891 additions and 152 deletions
+5
View File
@@ -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
# =============================================================================
+13
View File
@@ -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.
+1 -1
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.7.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
+1 -1
View File
@@ -1 +1 @@
1.7.0
1.7.1
+2 -2
View File
@@ -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",
+12 -4
View File
@@ -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'));
}
};
+2 -1
View File
@@ -16,6 +16,7 @@ import { PaneSizeContext } from "@/hooks/use-pane-size";
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
import { 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]);
+8
View File
@@ -33,6 +33,7 @@ import {
Languages,
Info,
Bug,
Download,
X,
type LucideIcon,
} from 'lucide-react';
@@ -59,6 +60,7 @@ import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { DownloadsSettings } from '@/components/settings/downloads-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
@@ -90,6 +92,7 @@ type Tab =
| 'layout'
| 'reading'
| 'composing'
| 'downloads'
| 'identities'
| 'vacation'
| 'filters'
@@ -126,6 +129,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
layout: LayoutGrid,
reading: BookOpen,
composing: PenLine,
downloads: Download,
identities: UserPen,
vacation: PalmtreeIcon,
filters: Filter,
@@ -202,6 +206,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.email_behavior.signature_position',
'settings.email_behavior.sub_address_delimiter',
],
downloads: ['settings.downloads'],
identities: ['settings.identities'],
vacation: ['settings.vacation'],
filters: ['settings.filters'],
@@ -236,6 +241,7 @@ const tabKeywords: Record<Tab, string> = {
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
reading: 'mark read preview thread conversation archive delete attachment open',
composing: 'editor signature plain text reply forward draft compose',
downloads: 'download filename template eml attachment save export',
identities: 'from address signature email',
vacation: 'auto reply away out of office holiday responder',
filters: 'sieve rules block junk forward',
@@ -580,6 +586,7 @@ export default function SettingsPage() {
// Mail
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' },
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
@@ -666,6 +673,7 @@ export default function SettingsPage() {
{effectiveActiveTab === 'layout' && <LayoutSettings />}
{effectiveActiveTab === 'reading' && <ReadingSettings />}
{effectiveActiveTab === 'composing' && <ComposingSettings />}
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
{effectiveActiveTab === 'identities' && <IdentitySettings />}
{effectiveActiveTab === 'vacation' && <VacationSettings />}
{effectiveActiveTab === 'filters' && <FilterSettings />}
+1
View File
@@ -273,6 +273,7 @@ export function AuthTab() {
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client 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>
+1 -1
View File
@@ -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' },
+6 -6
View File
@@ -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
+2 -3
View File
@@ -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 });
+11 -3
View File
@@ -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
View File
@@ -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;
}
}
+5 -1
View File
@@ -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) => {
+94 -34
View File
@@ -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>&nbsp;</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"
+3 -3
View File
@@ -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"}
>
+3 -2
View File
@@ -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"
/>
+266
View File
@@ -0,0 +1,266 @@
"use client";
import { useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { SettingsSection, SettingItem, Select, ToggleSwitch } from "./settings-section";
import { RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import {
DEFAULT_ATTACHMENT_TEMPLATE,
DEFAULT_BUNDLE_TEMPLATE,
DEFAULT_EMAIL_TEMPLATE,
EMAIL_TOKENS,
ATTACHMENT_TOKENS,
BUNDLE_TOKENS,
bundleExportFilename,
emailExportFilename,
attachmentDownloadFilename,
buildSampleEmail,
type EmailFilenameOptions,
} from "@/lib/download-filename";
function insertTokenAtCursor(
input: HTMLInputElement,
token: string,
current: string,
onChange: (next: string) => void,
): void {
const start = input.selectionStart ?? current.length;
const end = input.selectionEnd ?? current.length;
const before = current.slice(0, start);
const after = current.slice(end);
const insertion = `{${token}}`;
const next = `${before}${insertion}${after}`;
onChange(next);
// Restore focus and place caret after the inserted token.
requestAnimationFrame(() => {
input.focus();
const caret = before.length + insertion.length;
input.setSelectionRange(caret, caret);
});
}
interface TemplateEditorProps {
label: string;
description: string;
value: string;
defaultValue: string;
tokens: { token: string; description: string }[];
preview: string;
onChange: (next: string) => void;
resetLabel: string;
previewLabel: string;
placeholder?: string;
}
function TemplateEditor({
label,
description,
value,
defaultValue,
tokens,
preview,
onChange,
resetLabel,
previewLabel,
placeholder,
}: TemplateEditorProps) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div data-search-label={label} className="space-y-3 py-3 border-b border-border last:border-0">
<div>
<label className="text-sm font-medium text-foreground">{label}</label>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-stretch gap-2">
<input
ref={inputRef}
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
className="flex-1 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground font-mono focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150"
/>
<button
type="button"
onClick={() => onChange(defaultValue)}
disabled={value === defaultValue}
title={resetLabel}
className={cn(
"px-2 rounded-md border border-border text-foreground transition-colors duration-150",
value === defaultValue
? "opacity-40 cursor-not-allowed"
: "hover:bg-muted cursor-pointer",
)}
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{tokens.map((t) => (
<button
key={t.token}
type="button"
title={t.description}
onClick={() => {
const input = inputRef.current;
if (!input) {
onChange(`${value}{${t.token}}`);
return;
}
insertTokenAtCursor(input, t.token, value, onChange);
}}
className="px-2 py-0.5 text-xs font-mono rounded bg-muted hover:bg-accent border border-border text-foreground transition-colors duration-150 cursor-pointer"
>
{`{${t.token}}`}
</button>
))}
</div>
<div className="text-xs text-muted-foreground">
<span className="opacity-70">{previewLabel} </span>
<span className="font-mono text-foreground/90 break-all">{preview}</span>
</div>
</div>
</div>
);
}
export function DownloadsSettings() {
const t = useTranslations("settings.downloads");
const {
emailDownloadTemplate,
attachmentDownloadTemplate,
bundleDownloadTemplate,
filenameSpaceReplacement,
filenameLowercase,
filenameStripDiacritics,
filenameCollapseSeparators,
postExportAction,
updateSetting,
} = useSettingsStore();
const sampleEmail = useMemo(() => buildSampleEmail(), []);
const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []);
const transform = useMemo(
() => ({
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
}),
[filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators],
);
const emailOptions: EmailFilenameOptions = useMemo(
() => ({ ...transform, template: emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE }),
[transform, emailDownloadTemplate],
);
const attachmentOptions: EmailFilenameOptions = useMemo(
() => ({ ...transform, template: attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE }),
[transform, attachmentDownloadTemplate],
);
const bundleOptions: EmailFilenameOptions = useMemo(
() => ({ ...transform, template: bundleDownloadTemplate || DEFAULT_BUNDLE_TEMPLATE }),
[transform, bundleDownloadTemplate],
);
const emlPreview = useMemo(
() => emailExportFilename(sampleEmail, emailOptions),
[sampleEmail, emailOptions],
);
const attachmentPreview = useMemo(
() => attachmentDownloadFilename(sampleEmail, sampleAttachment, attachmentOptions),
[sampleEmail, sampleAttachment, attachmentOptions],
);
const bundlePreview = useMemo(
// Render with the email's fixed sample date so the preview is stable as the
// user types in the template field.
() => bundleExportFilename(3, bundleOptions, sampleEmail.receivedAt ?? undefined),
[bundleOptions, sampleEmail],
);
return (
<SettingsSection title={t("title")} description={t("description")}>
<TemplateEditor
label={t("email_template.label")}
description={t("email_template.description")}
value={emailDownloadTemplate}
defaultValue={DEFAULT_EMAIL_TEMPLATE}
tokens={EMAIL_TOKENS}
preview={emlPreview}
onChange={(next) => updateSetting("emailDownloadTemplate", next)}
resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_EMAIL_TEMPLATE}
/>
<TemplateEditor
label={t("attachment_template.label")}
description={t("attachment_template.description")}
value={attachmentDownloadTemplate}
defaultValue={DEFAULT_ATTACHMENT_TEMPLATE}
tokens={ATTACHMENT_TOKENS}
preview={attachmentPreview}
onChange={(next) => updateSetting("attachmentDownloadTemplate", next)}
resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_ATTACHMENT_TEMPLATE}
/>
<TemplateEditor
label={t("bundle_template.label")}
description={t("bundle_template.description")}
value={bundleDownloadTemplate}
defaultValue={DEFAULT_BUNDLE_TEMPLATE}
tokens={BUNDLE_TOKENS}
preview={bundlePreview}
onChange={(next) => updateSetting("bundleDownloadTemplate", next)}
resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_BUNDLE_TEMPLATE}
/>
<SettingItem label={t("spaces.label")} description={t("spaces.description")}>
<Select
value={filenameSpaceReplacement}
onChange={(value) => updateSetting("filenameSpaceReplacement", value as "keep" | "underscore" | "dash")}
options={[
{ value: "keep", label: t("spaces.keep") },
{ value: "underscore", label: t("spaces.underscore") },
{ value: "dash", label: t("spaces.dash") },
]}
/>
</SettingItem>
<SettingItem label={t("lowercase.label")} description={t("lowercase.description")}>
<ToggleSwitch
checked={filenameLowercase}
onChange={(checked) => updateSetting("filenameLowercase", checked)}
/>
</SettingItem>
<SettingItem label={t("strip_diacritics.label")} description={t("strip_diacritics.description")}>
<ToggleSwitch
checked={filenameStripDiacritics}
onChange={(checked) => updateSetting("filenameStripDiacritics", checked)}
/>
</SettingItem>
<SettingItem label={t("collapse_separators.label")} description={t("collapse_separators.description")}>
<ToggleSwitch
checked={filenameCollapseSeparators}
onChange={(checked) => updateSetting("filenameCollapseSeparators", checked)}
/>
</SettingItem>
<SettingItem label={t("after_export.label")} description={t("after_export.description")}>
<Select
value={postExportAction}
onChange={(value) => updateSetting("postExportAction", value as "keep" | "archive" | "trash")}
options={[
{ value: "keep", label: t("after_export.keep") },
{ value: "archive", label: t("after_export.archive") },
{ value: "trash", label: t("after_export.trash") },
]}
/>
</SettingItem>
</SettingsSection>
);
}
+2 -1
View File
@@ -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') },
]}
/>
+5 -3
View File
@@ -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
View File
@@ -1,10 +1,21 @@
"use client";
"use client";
import { useCallback, DragEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react";
import { Email } from "@/lib/jmap/types";
import { 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,
},
+17
View File
@@ -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,
+1
View File
@@ -153,6 +153,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
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
View File
@@ -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
View File
@@ -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();
}
+264
View File
@@ -0,0 +1,264 @@
import type { Email } from "@/lib/jmap/types";
// Allow any Unicode letter or digit (so umlauts, accents, CJK survive) plus a
// small set of safe punctuation. Everything else - emojis, RTL/zero-width
// marks, control chars, and the filesystem-reserved `<>:"/\|?*` - collapses
// to `_`. Keeps filenames usable across Windows/macOS/Linux without flattening
// non-ASCII scripts.
const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu;
export type SpaceReplacement = "keep" | "underscore" | "dash";
export interface FilenameTransformOptions {
spaceReplacement?: SpaceReplacement;
lowercase?: boolean;
stripDiacritics?: boolean;
collapseSeparators?: boolean;
}
export interface EmailFilenameOptions extends FilenameTransformOptions {
template?: string;
}
export const DEFAULT_TRANSFORM: Required<FilenameTransformOptions> = {
spaceReplacement: "keep",
lowercase: false,
stripDiacritics: false,
collapseSeparators: true,
};
export const DEFAULT_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}";
export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}";
export const DEFAULT_BUNDLE_TEMPLATE = "emails-{count}";
export const EMAIL_TOKENS: { token: string; description: string }[] = [
{ token: "date", description: "Full date and time, e.g. 2026-05-22 14.05.33" },
{ token: "date_short", description: "Date only, e.g. 2026-05-22" },
{ token: "time", description: "Time only, e.g. 14.05.33" },
{ token: "year", description: "4-digit year" },
{ token: "month", description: "2-digit month" },
{ token: "day", description: "2-digit day" },
{ token: "from", description: "Sender display name (falls back to email user part)" },
{ token: "from_email", description: "Sender full email address" },
{ token: "from_name", description: "Sender name only" },
{ token: "to", description: "First recipient display name" },
{ token: "to_email", description: "First recipient full email address" },
{ token: "to_name", description: "First recipient name only" },
{ token: "subject", description: "Email subject" },
];
export const ATTACHMENT_TOKENS: { token: string; description: string }[] = [
...EMAIL_TOKENS,
{ token: "filename", description: "Original attachment filename including extension" },
{ token: "name", description: "Attachment filename without extension" },
{ token: "ext", description: "Attachment file extension without leading dot" },
];
export const BUNDLE_TOKENS: { token: string; description: string }[] = [
{ token: "count", description: "Number of emails in the bundle" },
{ token: "date", description: "Current date and time, e.g. 2026-05-22 14.05.33" },
{ token: "date_short", description: "Current date, e.g. 2026-05-22" },
{ token: "time", description: "Current time, e.g. 14.05.33" },
{ token: "year", description: "4-digit year" },
{ token: "month", description: "2-digit month" },
{ token: "day", description: "2-digit day" },
];
function sanitizePart(input: string, maxLen = 80): string {
const cleaned = input
.replace(SAFE_CHARS, "_")
.replace(/_+/g, "_")
.replace(/\s+/g, " ")
.trim()
.replace(/^[._-]+|[._-]+$/g, "");
return cleaned.slice(0, maxLen);
}
function applyTransforms(input: string, opts: FilenameTransformOptions): string {
let s = input;
if (opts.stripDiacritics) {
// NFD splits "ä" into "a" + U+0308 (combining diaeresis); stripping all
// combining marks then leaves plain ASCII letters. `ß` has no
// decomposition so it survives as-is.
s = s.normalize("NFD").replace(/\p{M}+/gu, "");
}
const repl = opts.spaceReplacement ?? "keep";
if (repl === "underscore") s = s.replace(/ +/g, "_");
else if (repl === "dash") s = s.replace(/ +/g, "-");
if (opts.collapseSeparators ?? true) {
s = s.replace(/_+/g, "_").replace(/-+/g, "-").replace(/ +/g, " ");
}
if (opts.lowercase) s = s.toLocaleLowerCase();
return s.replace(/^[._\- ]+|[._\- ]+$/g, "");
}
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function dateParts(iso: string | null | undefined) {
const d = iso ? new Date(iso) : new Date();
if (Number.isNaN(d.getTime())) {
return {
date: "0000-00-00 00.00.00",
date_short: "0000-00-00",
time: "00.00.00",
year: "0000",
month: "00",
day: "00",
};
}
const year = String(d.getFullYear());
const month = pad2(d.getMonth() + 1);
const day = pad2(d.getDate());
const time = `${pad2(d.getHours())}.${pad2(d.getMinutes())}.${pad2(d.getSeconds())}`;
return {
date: `${year}-${month}-${day} ${time}`,
date_short: `${year}-${month}-${day}`,
time,
year,
month,
day,
};
}
function addrLabel(addr: { name?: string | null; email: string } | undefined): {
name: string;
email: string;
label: string;
} {
if (!addr) return { name: "", email: "", label: "unknown" };
const name = (addr.name && addr.name.trim()) || "";
const email = addr.email || "";
const label = name || email.split("@")[0] || email || "unknown";
return { name, email, label };
}
export function emailVars(email: Email): Record<string, string> {
const dp = dateParts(email.receivedAt || email.sentAt);
const from = addrLabel(email.from?.[0]);
const to = addrLabel(email.to?.[0]);
return {
...dp,
from: from.label,
from_email: from.email,
from_name: from.name,
to: to.label,
to_email: to.email,
to_name: to.name,
subject: email.subject || "no subject",
};
}
export interface AttachmentLike {
name?: string | null;
type?: string | null;
}
export function attachmentVars(email: Email, attachment: AttachmentLike): Record<string, string> {
const filename = (attachment.name || "attachment").trim();
const dot = filename.lastIndexOf(".");
const hasExt = dot > 0 && dot < filename.length - 1;
const name = hasExt ? filename.slice(0, dot) : filename;
const ext = hasExt ? filename.slice(dot + 1) : "";
return {
...emailVars(email),
filename,
name,
ext,
};
}
function renderRaw(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key];
if (value === undefined) return "";
return sanitizePart(value);
});
}
export function emailExportFilename(
email: Email,
options: EmailFilenameOptions | string = {},
): string {
const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE;
const rendered = renderRaw(template, emailVars(email));
const cleaned = sanitizePart(rendered, 200);
const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || "email";
return `${stem}.eml`;
}
export function attachmentDownloadFilename(
email: Email | null | undefined,
attachment: AttachmentLike,
options: EmailFilenameOptions | string = {},
): string {
const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
if (!email) {
const filename = (attachment.name || "attachment").trim();
const cleaned = sanitizePart(filename, 200) || "attachment";
return applyTransforms(cleaned, opts) || cleaned;
}
const vars = attachmentVars(email, attachment);
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key];
if (value === undefined) return "";
// Preserve dots in {filename} so the original extension survives the
// sanitiser (it strips trailing dots otherwise).
return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value);
});
const templateMentionsExt = /\{(ext|filename)\}/.test(template);
const cleaned = sanitizePart(rendered, 200) || "attachment";
if (templateMentionsExt) {
return applyTransforms(cleaned, opts) || cleaned;
}
const transformedStem = applyTransforms(cleaned, opts) || cleaned;
const ext = vars.ext;
if (!ext) return transformedStem;
const transformedExt = opts.lowercase ? ext.toLocaleLowerCase() : ext;
return `${transformedStem}.${transformedExt}`;
}
export function bundleVars(count: number, iso?: string): Record<string, string> {
const dp = dateParts(iso ?? new Date().toISOString());
return { ...dp, count: String(count) };
}
export function bundleExportFilename(
count: number,
options: EmailFilenameOptions | string = {},
iso?: string,
): string {
const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_BUNDLE_TEMPLATE;
const rendered = renderRaw(template, bundleVars(count, iso));
const cleaned = sanitizePart(rendered, 200);
const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || "emails";
return `${stem}.zip`;
}
// Build a synthetic email for previewing templates in the settings UI.
export function buildSampleEmail(): Email {
// Use a fixed date so the preview doesn't churn as the user types.
const iso = "2026-05-22T14:05:33Z";
return {
id: "sample-1",
threadId: "sample-thread-1",
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 12345,
receivedAt: iso,
sentAt: iso,
from: [{ name: "Alice Sender", email: "alice@example.com" }],
to: [{ name: "Bob Recipient", email: "bob@example.com" }],
cc: [],
subject: "Benachrichtigung von Ihrem Gerät",
preview: "",
hasAttachment: true,
blobId: "sample-blob",
};
}
+48
View File
@@ -0,0 +1,48 @@
export interface ImportableEmail {
name: string;
blob: Blob;
}
const EMAIL_MIME = "message/rfc822";
function isEmlName(name: string): boolean {
return /\.eml$/i.test(name);
}
function isZipName(name: string): boolean {
return /\.zip$/i.test(name);
}
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
const { default: JSZip } = await import("jszip");
const zip = await JSZip.loadAsync(await file.arrayBuffer());
const out: ImportableEmail[] = [];
const entries = Object.values(zip.files);
for (const entry of entries) {
if (entry.dir) continue;
if (!isEmlName(entry.name)) continue;
const data = await entry.async("arraybuffer");
out.push({
name: entry.name.split(/[\\/]/).pop() || entry.name,
blob: new Blob([data], { type: EMAIL_MIME }),
});
}
return out;
}
export async function expandImportableEmails(
files: File[],
): Promise<ImportableEmail[]> {
const out: ImportableEmail[] = [];
for (const file of files) {
if (isZipName(file.name) || file.type === "application/zip") {
out.push(...(await extractEmlsFromZip(file)));
continue;
}
const blob = new Blob([await file.arrayBuffer()], { type: EMAIL_MIME });
out.push({ name: file.name, blob });
}
return out;
}
export const EML_IMPORT_ACCEPT = ".eml,.zip,message/rfc822,application/zip";
+3 -3
View File
@@ -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
View File
@@ -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;
}
+13 -3
View File
@@ -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 {
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 despaces, de tirets bas ou de tirets en un seul caractère."
},
"after_export": {
"label": "Après lexport",
"description": "Déplacer éventuellement le-mail après lavoir exporté en .eml.",
"keep": "Garder dans la boîte",
"archive": "Déplacer vers les archives",
"trash": "Déplacer vers la corbeille"
}
},
"filters": {
"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
View File
@@ -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 dellallegato",
"description": "Modello usato quando scarichi o trascini un allegato. Se ometti '{filename}' e '{ext}', l'estensione originale viene mantenuta."
},
"bundle_template": {
"label": "Nome del file .zip multi-email",
"description": "Modello usato quando trascini o scarichi più email selezionate come un singolo archivio .zip. L'estensione .zip viene aggiunta automaticamente."
},
"spaces": {
"label": "Spazi",
"description": "Sostituisci gli spazi nel nome file risultante con un altro carattere.",
"keep": "Mantieni gli spazi",
"underscore": "Sostituisci con _",
"dash": "Sostituisci con -"
},
"lowercase": {
"label": "Minuscolo",
"description": "Forza l'intero nome file in minuscolo."
},
"strip_diacritics": {
"label": "Rimuovi i segni diacritici",
"description": "Converti le lettere accentate nei loro equivalenti ASCII (ä → a, é → e). Utile per strumenti che gestiscono male i nomi Unicode."
},
"collapse_separators": {
"label": "Riduci i separatori ripetuti",
"description": "Riduci sequenze di spazi, underscore o trattini a un solo carattere."
},
"after_export": {
"label": "Dopo lesportazione",
"description": "Sposta facoltativamente lemail dopo averla esportata come .eml.",
"keep": "Mantieni nella casella",
"archive": "Sposta nellarchivio",
"trash": "Sposta nel cestino"
}
},
"filters": {
"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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "删除文件夹",
+2 -2
View File
@@ -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
View File
@@ -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",
+10 -3
View File
@@ -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
View File
@@ -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 => ({
+21 -1
View File
@@ -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: {