Merge branch 'main' into HEAD
# Conflicts: # app/(main)/layout.tsx # locales/cs/common.json # locales/da/common.json # locales/de/common.json # locales/en/common.json # locales/es/common.json # locales/fr/common.json # locales/it/common.json # locales/ja/common.json # locales/ko/common.json # locales/lv/common.json # locales/nl/common.json # locales/pl/common.json # locales/pt/common.json # locales/ru/common.json # locales/tr/common.json # locales/uk/common.json # locales/zh/common.json
This commit is contained in:
@@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
|
||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -41,6 +42,7 @@ export default async function LocaleLayout({
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<PluginConsentDialog />
|
||||
<PWAInstallPrompt />
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
|
||||
@@ -55,7 +55,7 @@ import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailb
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { appendHtmlSignature, 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";
|
||||
@@ -72,6 +72,7 @@ import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||
|
||||
@@ -81,6 +82,7 @@ const SCHEDULED_MAILBOX_ID = '__scheduled__';
|
||||
export default function Home() {
|
||||
const t = useTranslations();
|
||||
const tCommon = useTranslations('common');
|
||||
const tQuote = useTranslations('quote_header');
|
||||
const { appName } = useConfig();
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
@@ -751,9 +753,9 @@ export default function Home() {
|
||||
let title = t('email_composer.new_message');
|
||||
if (baseSubject) {
|
||||
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
|
||||
title = baseSubject.startsWith('Re:') ? baseSubject : `Re: ${baseSubject}`;
|
||||
title = buildReplySubject(baseSubject, t('email_composer.prefix.reply'));
|
||||
} else if (effectiveMode === 'forward') {
|
||||
title = baseSubject.startsWith('Fwd:') ? baseSubject : `Fwd: ${baseSubject}`;
|
||||
title = buildForwardSubject(baseSubject, t('email_composer.prefix.forward'));
|
||||
} else {
|
||||
title = baseSubject;
|
||||
}
|
||||
@@ -1129,6 +1131,7 @@ export default function Home() {
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
delayedUntil?: string;
|
||||
requestReadReceipt?: boolean;
|
||||
}) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -1136,7 +1139,7 @@ export default function Home() {
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
const originalEmailId = selectedEmail?.id;
|
||||
|
||||
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom);
|
||||
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt });
|
||||
setShowComposer(false);
|
||||
if (result.scheduled) {
|
||||
await refreshScheduledMetadata(client);
|
||||
@@ -1212,13 +1215,20 @@ export default function Home() {
|
||||
locale: useLocaleStore.getState().locale,
|
||||
timeFormat: useSettingsStore.getState().timeFormat,
|
||||
unknownLabel: tCommon('unknown'),
|
||||
labels: {
|
||||
formatReplyLine: (vars) => tQuote('reply_line', vars),
|
||||
forwardedSeparator: tQuote('forwarded_separator'),
|
||||
fromLabel: tQuote('from_label'),
|
||||
dateLabel: tQuote('date_label'),
|
||||
subjectLabel: tQuote('subject_label'),
|
||||
},
|
||||
});
|
||||
setComposerQuoteHeader(header);
|
||||
} catch (err) {
|
||||
console.warn('[quote-header] plugin transform failed; using default', err);
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
}, [tCommon]);
|
||||
}, [tCommon, tQuote]);
|
||||
|
||||
// Force a clean composer remount on every fresh entry point so prior
|
||||
// compose state can't bleed into the new session (#329 C). The composer is
|
||||
@@ -2090,9 +2100,21 @@ export default function Home() {
|
||||
|
||||
// Append signature from the sending identity (fall back to primary
|
||||
// when the reply-from lives on the same identity but a different alias).
|
||||
const finalBody = appendPlainTextSignature(body, sendingIdentity, {
|
||||
separator: useSettingsStore.getState().signatureSeparatorEnabled,
|
||||
});
|
||||
const separator = useSettingsStore.getState().signatureSeparatorEnabled;
|
||||
const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator });
|
||||
|
||||
// When the identity has an HTML signature, send a matching HTML body so the
|
||||
// signature keeps its formatting; appendPlainTextSignature would otherwise
|
||||
// flatten it to plain text. Text-only identities keep the plain-text-only
|
||||
// behavior (htmlBody stays undefined).
|
||||
const escapedBody = body
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
const finalHtmlBody = sendingIdentity?.htmlSignature?.trim()
|
||||
? appendHtmlSignature(`<div>${escapedBody}</div>`, sendingIdentity, { separator })
|
||||
: undefined;
|
||||
|
||||
const originalEmailId = selectedEmail.id;
|
||||
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
|
||||
@@ -2116,7 +2138,7 @@ export default function Home() {
|
||||
const result = await sendEmail(
|
||||
client,
|
||||
[sender.email],
|
||||
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
||||
buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')),
|
||||
finalBody,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -2124,7 +2146,7 @@ export default function Home() {
|
||||
headerFromEmail,
|
||||
undefined,
|
||||
headerFromName,
|
||||
undefined,
|
||||
finalHtmlBody,
|
||||
undefined,
|
||||
threading?.inReplyTo,
|
||||
threading?.references,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
import {
|
||||
BRANDING_OVERRIDE_KEYS,
|
||||
parseDomainBranding,
|
||||
@@ -33,6 +33,8 @@ const TEXT_FIELDS = [
|
||||
|
||||
const PWA_IMAGE_FIELDS = [
|
||||
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' },
|
||||
{ key: 'pwaScreenshotDesktopUrl', label: 'PWA Screenshot (Desktop)', accept: '.png,.jpg,.webp' },
|
||||
] as const;
|
||||
|
||||
const PWA_TEXT_FIELDS = [
|
||||
@@ -528,7 +530,7 @@ export function BrandingTab() {
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={currentValue(field.key)}
|
||||
src={withBasePath(currentValue(field.key))}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
@@ -606,7 +608,7 @@ export function BrandingTab() {
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={currentValue(field.key)}
|
||||
src={withBasePath(currentValue(field.key))}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
@@ -111,7 +110,6 @@ export default async function RootLayout({
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
{children}
|
||||
<PWAInstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import '../globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
// The plugin sandbox iframe runs with an opaque origin (the `sandbox`
|
||||
// attribute in production excludes `allow-same-origin` for isolation). Any
|
||||
// asset request from this layout - bundled fonts, globals.css, etc. - is then
|
||||
// cross-origin from the "null" origin to the host origin and gets blocked
|
||||
// (fonts in particular require CORS). So this layout is intentionally minimal:
|
||||
// no font imports, no CSS imports. Plugins ship their own styles, and both the
|
||||
// plugin bundle and all host API calls travel over the postMessage RPC bridge,
|
||||
// so the sandbox never fetches same-origin assets itself.
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Plugin sandbox',
|
||||
@@ -21,10 +18,7 @@ export const metadata: Metadata = {
|
||||
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
style={{ margin: 0, padding: 0, background: 'transparent' }}
|
||||
>
|
||||
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -26,14 +26,18 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/vnd.microsoft.icon',
|
||||
]);
|
||||
|
||||
type UploadSlot = BrandingOverrideKey;
|
||||
|
||||
/** Slots that correspond to branding config keys */
|
||||
const VALID_SLOTS = new Set<BrandingOverrideKey>([
|
||||
const VALID_SLOTS = new Set<UploadSlot>([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'pwaScreenshotMobileUrl',
|
||||
'pwaScreenshotDesktopUrl',
|
||||
]);
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
@@ -131,7 +135,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!VALID_SLOTS.has(slot as BrandingOverrideKey)) {
|
||||
if (!VALID_SLOTS.has(slot as UploadSlot)) {
|
||||
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -226,7 +230,7 @@ export async function DELETE(request: NextRequest) {
|
||||
const slot = body.slot;
|
||||
const rawHost = body.host ?? '';
|
||||
|
||||
if (!slot || !VALID_SLOTS.has(slot as BrandingOverrideKey)) {
|
||||
if (!slot || !VALID_SLOTS.has(slot as UploadSlot)) {
|
||||
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,9 @@ export async function POST(request: NextRequest) {
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(manifest.locales && typeof manifest.locales === 'object'
|
||||
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
|
||||
@@ -723,7 +723,18 @@ const emails: MockEmail[] = [
|
||||
// Identities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IDENTITIES = [
|
||||
type MockIdentity = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo: Array<{ name?: string; email: string }> | null;
|
||||
bcc: Array<{ name?: string; email: string }> | null;
|
||||
textSignature: string | null;
|
||||
htmlSignature: string | null;
|
||||
mayDelete: boolean;
|
||||
};
|
||||
|
||||
const IDENTITIES: MockIdentity[] = [
|
||||
{
|
||||
id: 'identity-001',
|
||||
name: 'Dev User',
|
||||
@@ -1562,13 +1573,55 @@ function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult {
|
||||
|
||||
function handleIdentitySet(args: MethodArgs, callId: string): MethodResult {
|
||||
const created: Record<string, { id: string }> = {};
|
||||
const create = args.create as Record<string, unknown> | undefined;
|
||||
const updated: Record<string, null> = {};
|
||||
const destroyed: string[] = [];
|
||||
|
||||
const create = args.create as Record<string, Record<string, unknown>> | undefined;
|
||||
if (create) {
|
||||
for (const key of Object.keys(create)) {
|
||||
created[key] = { id: `identity-new-${Date.now()}-${key}` };
|
||||
for (const [key, data] of Object.entries(create)) {
|
||||
const newId = `identity-${Date.now()}-${key}`;
|
||||
IDENTITIES.push({
|
||||
id: newId,
|
||||
name: (data.name as string) || '',
|
||||
email: (data.email as string) || '',
|
||||
replyTo: (data.replyTo as MockIdentity['replyTo']) ?? null,
|
||||
bcc: (data.bcc as MockIdentity['bcc']) ?? null,
|
||||
textSignature: (data.textSignature as string | null) ?? null,
|
||||
htmlSignature: (data.htmlSignature as string | null) ?? null,
|
||||
mayDelete: true,
|
||||
});
|
||||
created[key] = { id: newId };
|
||||
}
|
||||
}
|
||||
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId];
|
||||
|
||||
const update = args.update as Record<string, Record<string, unknown>> | undefined;
|
||||
if (update) {
|
||||
for (const [id, changes] of Object.entries(update)) {
|
||||
const identity = IDENTITIES.find((i) => i.id === id);
|
||||
if (identity) {
|
||||
// Email is immutable per the identity form, so it's never in `changes`.
|
||||
if (changes.name !== undefined) identity.name = changes.name as string;
|
||||
if (changes.replyTo !== undefined) identity.replyTo = changes.replyTo as MockIdentity['replyTo'];
|
||||
if (changes.bcc !== undefined) identity.bcc = changes.bcc as MockIdentity['bcc'];
|
||||
if (changes.textSignature !== undefined) identity.textSignature = changes.textSignature as string | null;
|
||||
if (changes.htmlSignature !== undefined) identity.htmlSignature = changes.htmlSignature as string | null;
|
||||
updated[id] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const destroy = args.destroy as string[] | undefined;
|
||||
if (destroy) {
|
||||
for (const id of destroy) {
|
||||
const idx = IDENTITIES.findIndex((i) => i.id === id);
|
||||
if (idx !== -1) {
|
||||
IDENTITIES.splice(idx, 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId];
|
||||
}
|
||||
|
||||
function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
|
||||
|
||||
@@ -57,6 +57,9 @@ export async function GET() {
|
||||
// Per-user settings schema, captured from the manifest at upload/load
|
||||
// time so the client can render the settings UI without re-parsing.
|
||||
settingsSchema: p.settingsSchema,
|
||||
// Plugin-declared i18n tables, so the sandbox can localize plugin
|
||||
// strings via api.i18n.t().
|
||||
locales: p.locales,
|
||||
}));
|
||||
|
||||
// Only serve enabled themes
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
/**
|
||||
* Variant → target output size + admin config key.
|
||||
* Matches the sizes declared in app/manifest.ts so the rendered PNG fits
|
||||
* the slot the manifest tells the browser about.
|
||||
*/
|
||||
const VARIANTS = {
|
||||
mobile: { width: 540, height: 720, configKey: 'pwaScreenshotMobileUrl' as const },
|
||||
desktop: { width: 1280, height: 720, configKey: 'pwaScreenshotDesktopUrl' as const },
|
||||
} as const;
|
||||
|
||||
type Variant = keyof typeof VARIANTS;
|
||||
|
||||
// Cache resized images keyed by (variant, source URL).
|
||||
const cache = new Map<string, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||
const res = await fetch(iconUrl);
|
||||
if (!res.ok) throw new Error(`Failed to fetch PWA screenshot: ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
|
||||
// but stored on disk under getConfigDir()/branding/.
|
||||
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
|
||||
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
|
||||
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
|
||||
return readFile(path.join(getConfigDir(), 'branding', filename));
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ variant: string }> },
|
||||
) {
|
||||
const { variant: variantParam } = await params;
|
||||
if (!(variantParam in VARIANTS)) {
|
||||
return new NextResponse('Invalid variant. Allowed: mobile, desktop', { status: 400 });
|
||||
}
|
||||
const { width, height, configKey } = VARIANTS[variantParam as Variant];
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const host = pickRequestHost(req);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||
);
|
||||
const sources = configManager.getAllWithSources();
|
||||
const sourceEntry = sources[configKey];
|
||||
const screenshotUrl =
|
||||
domainOverrides[configKey] ||
|
||||
(sourceEntry?.source !== 'default' ? (sourceEntry?.value as string | undefined) : undefined);
|
||||
if (!screenshotUrl) {
|
||||
return new NextResponse('No PWA screenshot configured', { status: 404 });
|
||||
}
|
||||
|
||||
const pngHeaders = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
Vary: 'Host, X-Forwarded-Host',
|
||||
};
|
||||
const cacheKey = `${variantParam}|${screenshotUrl}`;
|
||||
|
||||
try {
|
||||
if (cache.has(cacheKey)) {
|
||||
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(screenshotUrl);
|
||||
// 'cover' fills the target box without letterboxing - screenshots benefit
|
||||
// more from cropping than from a transparent frame around them. Users get
|
||||
// a hint about the recommended aspect ratio in the admin UI.
|
||||
const resized = await sharp(sourceBuffer)
|
||||
.resize(width, height, { fit: 'cover', position: 'center' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(cacheKey, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
console.error('Failed to generate PWA screenshot:', err);
|
||||
return new NextResponse('Failed to generate screenshot', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,13 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
|
||||
const VALID_SLOTS = new Set([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'pwaScreenshotMobileUrl',
|
||||
'pwaScreenshotDesktopUrl',
|
||||
]);
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
|
||||
+19
-4
@@ -95,10 +95,25 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
background_color: backgroundColor,
|
||||
icons,
|
||||
categories: ["productivity"],
|
||||
screenshots: [
|
||||
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||
],
|
||||
// Use admin-uploaded screenshots when configured (per-domain override,
|
||||
// admin/env global; resized on the fly via /api/pwa-screenshot/[variant]);
|
||||
// otherwise fall back to the built-in Bulwark screenshots from public/.
|
||||
screenshots: (() => {
|
||||
const hasMobile =
|
||||
!!domainOverrides.pwaScreenshotMobileUrl ||
|
||||
sources.pwaScreenshotMobileUrl?.source !== "default";
|
||||
const hasDesktop =
|
||||
!!domainOverrides.pwaScreenshotDesktopUrl ||
|
||||
sources.pwaScreenshotDesktopUrl?.source !== "default";
|
||||
return [
|
||||
hasMobile
|
||||
? { src: withBase("/api/pwa-screenshot/mobile"), sizes: "540x720", type: "image/png" }
|
||||
: { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||
hasDesktop
|
||||
? { src: withBase("/api/pwa-screenshot/desktop"), sizes: "1280x720", type: "image/png" }
|
||||
: { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||
];
|
||||
})(),
|
||||
protocol_handlers: [
|
||||
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
||||
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
||||
|
||||
@@ -5,11 +5,12 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeSignatureHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -89,6 +90,7 @@ interface EmailComposerProps {
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
delayedUntil?: string;
|
||||
requestReadReceipt?: boolean;
|
||||
}) => void | Promise<void>;
|
||||
onScheduledSendCreated?: () => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
@@ -196,6 +198,7 @@ export function EmailComposer({
|
||||
}: EmailComposerProps) {
|
||||
const t = useTranslations('email_composer');
|
||||
const tCommon = useTranslations('common');
|
||||
const tQuote = useTranslations('quote_header');
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||
@@ -205,6 +208,7 @@ export function EmailComposer({
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||
const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault);
|
||||
const activeIdentities = useIdentityStore((s) => s.identities);
|
||||
// Pro shell: surface identities from every connected account, grouped
|
||||
// for the From dropdown's <optgroup>s. Outside Pro this collapses to
|
||||
@@ -265,11 +269,9 @@ export function EmailComposer({
|
||||
const getInitialSubject = () => {
|
||||
if (!replyTo?.subject) return "";
|
||||
if (mode === 'forward') {
|
||||
const fwdPrefix = t('prefix.forward');
|
||||
return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`;
|
||||
return buildForwardSubject(replyTo.subject, t('prefix.forward'));
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
const rePrefix = t('prefix.reply');
|
||||
return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
|
||||
return buildReplySubject(replyTo.subject, t('prefix.reply'));
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -291,6 +293,13 @@ export function EmailComposer({
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
// Forward "From:" shows the full sender incl. address; reply line keeps
|
||||
// the bare name (reads naturally in the localized "On … wrote:" line).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
: (from.email || from.name || tCommon('unknown')))
|
||||
: tCommon('unknown');
|
||||
|
||||
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
|
||||
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
|
||||
@@ -311,9 +320,9 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
@@ -336,6 +345,11 @@ export function EmailComposer({
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
: (from.email || from.name || tCommon('unknown')))
|
||||
: tCommon('unknown');
|
||||
|
||||
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: shouldEmbedSignatureAboveQuote,
|
||||
@@ -359,8 +373,8 @@ export function EmailComposer({
|
||||
// Build quoted content as HTML
|
||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
||||
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>`
|
||||
: `${tQuote('reply_line', { date, from: fromStr })}<br>`;
|
||||
// cid: image refs are rewritten so they render in the editor (browsers
|
||||
// can't fetch cid: URLs); see useEffect below for the data-URL backfill.
|
||||
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody);
|
||||
@@ -370,9 +384,9 @@ export function EmailComposer({
|
||||
if (replyTo.body) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date, from: fromStr })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
@@ -385,6 +399,7 @@ export function EmailComposer({
|
||||
const [body, setBody] = useState(initialData?.body ?? getInitialBody());
|
||||
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
|
||||
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
||||
const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault);
|
||||
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
|
||||
// Mirror of draftId for synchronous reads inside chained saves; React's
|
||||
// setDraftId is async, so a queued saveDraft would otherwise see the old
|
||||
@@ -1657,6 +1672,7 @@ export function EmailComposer({
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||
inReplyTo: threadingHeaders?.inReplyTo,
|
||||
references: threadingHeaders?.references,
|
||||
requestReadReceipt,
|
||||
delayedUntil: effectiveDelayedUntil,
|
||||
});
|
||||
|
||||
@@ -2218,7 +2234,7 @@ export function EmailComposer({
|
||||
)}
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(env(safe-area-inset-bottom)/2)]">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(0.625rem+env(safe-area-inset-bottom)/2)]">
|
||||
{/* Left side actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
@@ -2281,6 +2297,21 @@ export function EmailComposer({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Read-receipt request toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setRequestReadReceipt(v => !v)}
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
requestReadReceipt && "bg-green-600 text-white hover:bg-green-600 hover:text-white dark:bg-green-600 dark:hover:bg-green-600"
|
||||
)}
|
||||
title={requestReadReceipt ? t('read_receipt_on') : t('read_receipt_off')}
|
||||
aria-pressed={requestReadReceipt}
|
||||
>
|
||||
<MailCheck className="w-4 h-4" />
|
||||
</Button>
|
||||
<PluginSlot name="composer-toolbar" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -83,6 +83,8 @@ import { useThemeStore } from "@/stores/theme-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
import { ReadReceiptBanner } from "./read-receipt-banner";
|
||||
import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||
@@ -911,6 +913,7 @@ export function EmailViewer({
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||
const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse);
|
||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
|
||||
const dragOutActive = useMemo(() => isDragOutSupported(), []);
|
||||
@@ -2193,6 +2196,9 @@ export function EmailViewer({
|
||||
// Hide inline cid-referenced images when the user has opted to keep them
|
||||
// out of the attachment list (default on): these are embedded in the body.
|
||||
.filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||
// Hide machine-readable report parts (MDN read-receipts, DSN bounce
|
||||
// reports). These are required MIME parts, not real user attachments.
|
||||
.filter(att => att.type !== 'message/disposition-notification' && att.type !== 'message/delivery-status')
|
||||
.map((attachment, index) => ({
|
||||
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
||||
name: attachment.name || null,
|
||||
@@ -3252,6 +3258,103 @@ export function EmailViewer({
|
||||
? calendarInvitationParsingEnabled && !!findCalendarAttachment(email)
|
||||
: false;
|
||||
|
||||
// ── Read receipt (MDN, RFC 8098) ──────────────────────────────
|
||||
// Detect a Disposition-Notification-To request on the open message. The
|
||||
// header is parsed into email.headers by the client; look it up
|
||||
// case-insensitively and extract the bare address.
|
||||
const readReceiptRequestedBy = useMemo(() => {
|
||||
const headers = email?.headers as Record<string, string | string[]> | undefined;
|
||||
if (!headers) return null;
|
||||
let raw: string | undefined;
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === 'disposition-notification-to') {
|
||||
const v = headers[key];
|
||||
raw = Array.isArray(v) ? v[0] : v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!raw) return null;
|
||||
const m = raw.match(/<([^>]+)>/);
|
||||
const addr = (m ? m[1] : raw).trim();
|
||||
return addr || null;
|
||||
}, [email?.headers]);
|
||||
|
||||
// The identity whose address received the original message — the MDN is sent
|
||||
// "from" that address. Falls back to the primary identity.
|
||||
const receiptIdentity = useMemo(() => {
|
||||
if (!identities?.length) return null;
|
||||
const recipients = [...(email?.to || []), ...(email?.cc || [])]
|
||||
.map(r => r.email?.toLowerCase())
|
||||
.filter(Boolean);
|
||||
return identities.find(i => recipients.includes(i.email?.toLowerCase())) || identities[0];
|
||||
}, [identities, email?.to, email?.cc]);
|
||||
|
||||
const mdnAlreadyHandled = email?.keywords?.['$mdnsent'] === true;
|
||||
const [mdnHandledLocally, setMdnHandledLocally] = useState(false);
|
||||
useEffect(() => { setMdnHandledLocally(false); }, [email?.id]);
|
||||
|
||||
// Only offer the receipt for mail you're actually reading in a "received"
|
||||
// location. Suppress your own copies (sent/drafts), discarded mail (trash),
|
||||
// and spam (junk) - never confirm your address to spammers. Inbox, Archive
|
||||
// and user folders all qualify.
|
||||
const inReceiptEligibleFolder = !['sent', 'drafts', 'trash', 'junk'].includes(currentMailboxRole || '');
|
||||
|
||||
const shouldOfferReadReceipt =
|
||||
!!readReceiptRequestedBy &&
|
||||
!mdnAlreadyHandled &&
|
||||
!mdnHandledLocally &&
|
||||
readReceiptResponse !== 'never' &&
|
||||
inReceiptEligibleFolder &&
|
||||
!isDraft &&
|
||||
!!receiptIdentity;
|
||||
|
||||
const sendReadReceiptNow = useCallback(async (automatic: boolean) => {
|
||||
if (!client || !email || !readReceiptRequestedBy || !receiptIdentity) return;
|
||||
const { rawId } = stripCrossAccountIdentityPrefix(receiptIdentity.id);
|
||||
try {
|
||||
await client.sendReadReceipt({
|
||||
to: readReceiptRequestedBy,
|
||||
fromEmail: receiptIdentity.email,
|
||||
fromName: receiptIdentity.name,
|
||||
identityId: rawId ?? receiptIdentity.id,
|
||||
originalMessageId: email.messageId,
|
||||
originalSubject: email.subject,
|
||||
originalRecipient: receiptIdentity.email,
|
||||
automatic,
|
||||
subject: t('read_receipt.mdn_subject', { subject: email.subject || '' }),
|
||||
humanText: t('read_receipt.mdn_body', { recipient: receiptIdentity.email }),
|
||||
});
|
||||
await client.setKeyword(email.id, '$mdnsent');
|
||||
} catch (err) {
|
||||
// Surface the failure instead of silently resetting the banner so we can
|
||||
// see which step (upload / import / submission) failed.
|
||||
console.error('Read-receipt (MDN) send failed:', err);
|
||||
toast.error(t('read_receipt.send_failed'), {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}, [client, email, readReceiptRequestedBy, receiptIdentity, t]);
|
||||
|
||||
const ignoreReadReceipt = useCallback(async () => {
|
||||
setMdnHandledLocally(true);
|
||||
if (client && email) {
|
||||
// $MDNSent is the RFC 3503 flag every IMAP/JMAP client honours, so the
|
||||
// request is suppressed everywhere - not just locally.
|
||||
try { await client.setKeyword(email.id, '$mdnsent'); } catch { /* best effort */ }
|
||||
}
|
||||
}, [client, email]);
|
||||
|
||||
// "always" mode: auto-send the MDN once when the message is opened.
|
||||
const autoMdnRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (readReceiptResponse !== 'always') return;
|
||||
if (!shouldOfferReadReceipt || !email) return;
|
||||
if (autoMdnRef.current === email.id) return;
|
||||
autoMdnRef.current = email.id;
|
||||
sendReadReceiptNow(true).catch(() => { autoMdnRef.current = null; });
|
||||
}, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]);
|
||||
|
||||
// Show loading skeleton while email is being fetched
|
||||
if (isLoading && !email) {
|
||||
return (
|
||||
@@ -4946,9 +5049,10 @@ export function EmailViewer({
|
||||
error={smimeUnlockError}
|
||||
/>
|
||||
|
||||
{/* Unified Notification Banner - External Content + Calendar Invitation */}
|
||||
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
|
||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
hasCalendarInvitation) && (
|
||||
hasCalendarInvitation ||
|
||||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
|
||||
<div className="border-b border-border bg-muted/30 isolate">
|
||||
<div className="px-6 py-1.5">
|
||||
<div className="flex flex-col gap-3 isolate">
|
||||
@@ -5003,6 +5107,17 @@ export function EmailViewer({
|
||||
|
||||
|
||||
|
||||
{/* Read-receipt (MDN) request banner — only in "ask" mode */}
|
||||
{readReceiptResponse === 'ask' && shouldOfferReadReceipt && readReceiptRequestedBy && (
|
||||
<div className="py-1">
|
||||
<ReadReceiptBanner
|
||||
requestedBy={readReceiptRequestedBy}
|
||||
onSend={() => sendReadReceiptNow(false)}
|
||||
onIgnore={ignoreReadReceipt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendar Invitation Banner */}
|
||||
{hasCalendarInvitation && (
|
||||
<div className="py-1">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { MailCheck, Loader2, CheckCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface ReadReceiptBannerProps {
|
||||
/** Address that requested the receipt (Disposition-Notification-To). */
|
||||
requestedBy: string;
|
||||
/** Sends the MDN. Should resolve when the receipt has been submitted. */
|
||||
onSend: () => Promise<void>;
|
||||
/** Suppresses the request without sending (sets $MDNSent server-side). */
|
||||
onIgnore: () => void;
|
||||
}
|
||||
|
||||
export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceiptBannerProps) {
|
||||
const t = useTranslations('email_viewer.read_receipt');
|
||||
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||
|
||||
if (state === 'sent') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
||||
<span>{t('sent')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm dark:border-amber-700/50 dark:bg-amber-950/30">
|
||||
<MailCheck className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-foreground">{t('prompt')}</span>
|
||||
<span className="break-all text-muted-foreground">{requestedBy}</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setState('sending');
|
||||
try {
|
||||
await onSend();
|
||||
setState('sent');
|
||||
} catch {
|
||||
setState('idle');
|
||||
}
|
||||
}}
|
||||
disabled={state === 'sending'}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{state === 'sending' && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{t('send')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onIgnore}
|
||||
className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700"
|
||||
>
|
||||
{t('ignore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ interface FilterRuleModalProps {
|
||||
}
|
||||
|
||||
const ALL_FIELDS: FilterConditionField[] = [
|
||||
"from", "to", "cc", "subject", "header", "size", "body",
|
||||
"from", "to", "cc", "subject", "header", "size", "body", "attachment",
|
||||
];
|
||||
|
||||
const TEXT_COMPARATORS: FilterComparator[] = [
|
||||
@@ -36,6 +36,14 @@ const TEXT_COMPARATORS: FilterComparator[] = [
|
||||
|
||||
const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"];
|
||||
|
||||
const ATTACHMENT_COMPARATORS: FilterComparator[] = ["has_any", "has_type"];
|
||||
|
||||
function comparatorsFor(field: FilterConditionField): FilterComparator[] {
|
||||
if (field === "size") return SIZE_COMPARATORS;
|
||||
if (field === "attachment") return ATTACHMENT_COMPARATORS;
|
||||
return TEXT_COMPARATORS;
|
||||
}
|
||||
|
||||
const ALL_ACTION_TYPES: FilterActionType[] = [
|
||||
"move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop",
|
||||
];
|
||||
@@ -47,6 +55,27 @@ function makeEmptyCondition(): FilterCondition {
|
||||
return { field: "from", comparator: "contains", value: "" };
|
||||
}
|
||||
|
||||
// Multi-value handling: conditions are stored as string | string[]. The UI
|
||||
// presents them as a single comma-separated text input — the user types
|
||||
// "a, b, c" and the saved value becomes ["a","b","c"]. Single entries stay
|
||||
// strings so existing single-value rules don't change shape.
|
||||
function valueToInputString(v: string | string[]): string {
|
||||
if (Array.isArray(v)) return v.join(", ");
|
||||
return v;
|
||||
}
|
||||
|
||||
function inputStringToValue(s: string): string | string[] {
|
||||
const parts = s.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
||||
if (parts.length === 0) return "";
|
||||
if (parts.length === 1) return parts[0];
|
||||
return parts;
|
||||
}
|
||||
|
||||
function isConditionValueEmpty(v: string | string[]): boolean {
|
||||
if (Array.isArray(v)) return v.length === 0 || v.every((x) => !x.trim());
|
||||
return !v.trim();
|
||||
}
|
||||
|
||||
function makeEmptyAction(): FilterAction {
|
||||
return { type: "move", value: "" };
|
||||
}
|
||||
@@ -97,9 +126,23 @@ export function FilterRuleModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const validConditions = conditions.filter(
|
||||
(c) => c.value.trim()
|
||||
);
|
||||
// While editing, condition.value is always the raw string typed into the
|
||||
// input (commas not yet split). Convert to array form here on save so a
|
||||
// user typing "a, b, c" actually persists as ["a","b","c"]. This is the
|
||||
// moment we know editing is finished - splitting earlier would eat any
|
||||
// comma the user just typed mid-edit.
|
||||
const validConditions = conditions
|
||||
.filter((c) => {
|
||||
if (c.field === "attachment" && c.comparator === "has_any") return true;
|
||||
return !isConditionValueEmpty(c.value);
|
||||
})
|
||||
.map((c) => {
|
||||
if (c.field === "attachment" && c.comparator === "has_any") return c;
|
||||
if (c.field === "size") return c; // numeric, single-value only
|
||||
if (typeof c.value !== "string") return c; // already structured
|
||||
const parsed = inputStringToValue(c.value);
|
||||
return { ...c, value: parsed };
|
||||
});
|
||||
if (validConditions.length === 0) {
|
||||
toast.error(t("validation_empty_conditions"));
|
||||
return;
|
||||
@@ -129,15 +172,27 @@ export function FilterRuleModal({
|
||||
prev.map((c, i) => {
|
||||
if (i !== index) return c;
|
||||
const updated = { ...c, ...updates };
|
||||
if (updates.field === "size" && !SIZE_COMPARATORS.includes(c.comparator)) {
|
||||
updated.comparator = "greater_than";
|
||||
}
|
||||
if (updates.field && updates.field !== "size" && SIZE_COMPARATORS.includes(c.comparator)) {
|
||||
updated.comparator = "contains";
|
||||
// Reconcile the comparator when the field changes so we never end up
|
||||
// with e.g. (field=attachment, comparator=contains) — invalid for the
|
||||
// Sieve generator. Each field has its own valid comparator set.
|
||||
if (updates.field && updates.field !== c.field) {
|
||||
const allowed = comparatorsFor(updates.field);
|
||||
if (!allowed.includes(c.comparator)) {
|
||||
updated.comparator = allowed[0];
|
||||
}
|
||||
}
|
||||
if (updates.field && updates.field !== "header") {
|
||||
delete updated.headerName;
|
||||
}
|
||||
// has_any takes no value; clear it so we don't leak old text into
|
||||
// the generated Sieve.
|
||||
if (updated.field === "attachment" && updated.comparator === "has_any") {
|
||||
updated.value = "";
|
||||
}
|
||||
// Size is numeric, single value only - collapse any list to scalar.
|
||||
if (updated.field === "size" && Array.isArray(updated.value)) {
|
||||
updated.value = updated.value[0] ?? "";
|
||||
}
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
@@ -281,24 +336,58 @@ export function FilterRuleModal({
|
||||
className={selectClass}
|
||||
aria-label={t("comparators.contains")}
|
||||
>
|
||||
{(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map(
|
||||
(c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`comparators.${c}`)}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
{comparatorsFor(condition.field).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`comparators.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Input
|
||||
value={condition.value}
|
||||
onChange={(e) => updateCondition(index, { value: e.target.value })}
|
||||
placeholder={
|
||||
condition.field === "size" ? t("size_placeholder") : t("header_placeholder")
|
||||
}
|
||||
className="flex-1 min-w-[120px]"
|
||||
type={condition.field === "size" ? "number" : "text"}
|
||||
/>
|
||||
{/* has_any takes no value; render a stub so the row layout
|
||||
stays consistent but no input is editable. */}
|
||||
{condition.field === "attachment" && condition.comparator === "has_any" ? (
|
||||
<div className="flex-1 min-w-[120px]" />
|
||||
) : (
|
||||
<Input
|
||||
value={valueToInputString(condition.value)}
|
||||
onChange={(e) =>
|
||||
// Store the raw input string while typing. Splitting
|
||||
// commas into an array on every keystroke would eat
|
||||
// the comma the moment it's typed.
|
||||
updateCondition(index, { value: e.target.value })
|
||||
}
|
||||
onBlur={(e) => {
|
||||
// On blur: normalise comma-separated input into an
|
||||
// array (or single string when only one item). Size
|
||||
// stays numeric/single-value; attachment-has_any has
|
||||
// no value at all.
|
||||
if (condition.field === "size") return;
|
||||
if (
|
||||
condition.field === "attachment" &&
|
||||
condition.comparator === "has_any"
|
||||
)
|
||||
return;
|
||||
const parsed = inputStringToValue(e.target.value);
|
||||
// Only update if the normalised shape actually
|
||||
// differs - avoids triggering a no-op re-render and
|
||||
// resetting the user's cursor on every blur.
|
||||
if (
|
||||
JSON.stringify(parsed) !== JSON.stringify(condition.value)
|
||||
) {
|
||||
updateCondition(index, { value: parsed });
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
condition.field === "size"
|
||||
? t("size_placeholder")
|
||||
: condition.field === "attachment"
|
||||
? t("attachment_type_placeholder")
|
||||
: t("value_placeholder_multi")
|
||||
}
|
||||
className="flex-1 min-w-[120px]"
|
||||
type={condition.field === "size" ? "number" : "text"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,6 +7,19 @@
|
||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
|
||||
|
||||
// Lightweight **bold** support in plugin dialog messages. Everything else is
|
||||
// rendered literally (newlines come from the parent's white-space: pre-wrap).
|
||||
// Splitting on the ** delimiter yields alternating plain/bold segments (odd
|
||||
// indices are bold). Plugins control these strings, so the delimiters balance.
|
||||
function renderMessage(message?: string): React.ReactNode {
|
||||
if (!message) return null;
|
||||
return message.split('**').map((seg, i) =>
|
||||
i % 2 === 1
|
||||
? <strong key={i}>{seg}</strong>
|
||||
: <React.Fragment key={i}>{seg}</React.Fragment>,
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginDialogHost(): React.JSX.Element | null {
|
||||
const current = useSyncExternalStore(subscribe, head, () => null);
|
||||
|
||||
@@ -50,9 +63,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--background, #fff)',
|
||||
color: 'var(--foreground, #0f172a)',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
background: 'var(--color-popover, #fff)',
|
||||
color: 'var(--color-popover-foreground, #0f172a)',
|
||||
border: '1px solid var(--color-border, #e2e8f0)',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
maxWidth: 480,
|
||||
@@ -63,8 +76,8 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
|
||||
{current.title}
|
||||
</h2>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--muted-foreground, #64748b)', whiteSpace: 'pre-wrap' }}>
|
||||
{current.message}
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||
{renderMessage(current.message)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{current.kind === 'confirm' && (
|
||||
@@ -78,7 +91,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
border: '1px solid var(--color-border, #e2e8f0)',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
}}
|
||||
@@ -97,14 +110,14 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
background: current.danger ? '#dc2626' : '#3b82f6',
|
||||
color: '#fff',
|
||||
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
|
||||
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--color-muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
From plugin: {current.pluginId}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -104,7 +105,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
initialDraftText: draftText,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
@@ -116,7 +117,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
mode: 'replyAll',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
@@ -128,7 +129,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
mode: 'forward',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Fwd: ${email.subject || t('email_composer.new_message')}`,
|
||||
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Download } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
@@ -17,6 +18,7 @@ export function PWAInstallPrompt() {
|
||||
useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const t = useTranslations("pwa_install");
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(DISMISSED_KEY)) return;
|
||||
@@ -84,17 +86,17 @@ export function PWAInstallPrompt() {
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
|
||||
Install {appName}
|
||||
{t("title", { appName })}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
Install our app for quick access and offline support.
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
||||
aria-label="Dismiss install prompt"
|
||||
aria-label={t("dismiss_aria")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -105,20 +107,20 @@ export function PWAInstallPrompt() {
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
Not now
|
||||
{t("not_now")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Install
|
||||
{t("install")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismissForever}
|
||||
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
|
||||
>
|
||||
Don't remind me again
|
||||
{t("dont_remind")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,8 @@ export function ComposingSettings() {
|
||||
subAddressDelimiter,
|
||||
signaturePosition,
|
||||
signatureSeparatorEnabled,
|
||||
requestReadReceiptDefault,
|
||||
readReceiptResponse,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { client } = useAuthStore();
|
||||
@@ -78,6 +80,25 @@ export function ComposingSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('request_read_receipt.label')} description={t('request_read_receipt.description')}>
|
||||
<ToggleSwitch
|
||||
checked={requestReadReceiptDefault}
|
||||
onChange={(checked) => updateSetting('requestReadReceiptDefault', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('read_receipt_response.label')} description={t('read_receipt_response.description')}>
|
||||
<Select
|
||||
value={readReceiptResponse}
|
||||
onChange={(value) => updateSetting('readReceiptResponse', value as 'ask' | 'always' | 'never')}
|
||||
options={[
|
||||
{ value: 'ask', label: t('read_receipt_response.ask') },
|
||||
{ value: 'always', label: t('read_receipt_response.always') },
|
||||
{ value: 'never', label: t('read_receipt_response.never') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('sub_address_delimiter.label')}
|
||||
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
||||
|
||||
@@ -36,7 +36,17 @@ function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||
const conditions = rule.conditions.slice(0, 2).map((c) => {
|
||||
const field = t(`condition_fields.${c.field}`);
|
||||
const comparator = t(`comparators.${c.comparator}`);
|
||||
return `${field} ${comparator} "${c.value}"`;
|
||||
// has_any is a no-value test ("attachment is present"); appending
|
||||
// `""` would look broken in the summary line.
|
||||
if (c.field === "attachment" && c.comparator === "has_any") {
|
||||
return `${field} ${comparator}`;
|
||||
}
|
||||
// Multi-value conditions render as "a" / "b" / "c" with the locale's
|
||||
// OR-glue between items so the line still reads as natural language.
|
||||
const valueStr = Array.isArray(c.value)
|
||||
? c.value.map((v) => `"${v}"`).join(` ${t("or")} `)
|
||||
: `"${c.value}"`;
|
||||
return `${field} ${comparator} ${valueStr}`;
|
||||
});
|
||||
|
||||
const joiner = rule.matchType === "all" ? t("and") : t("or");
|
||||
@@ -97,7 +107,22 @@ function VisualRuleSummary({ rule }: { rule: FilterRule }) {
|
||||
<span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">{field}</span>
|
||||
<span className="text-muted-foreground">{comparator}</span>
|
||||
<span className="text-foreground">“{c.value}”</span>
|
||||
{!(c.field === "attachment" && c.comparator === "has_any") && (
|
||||
<span className="text-foreground">
|
||||
{Array.isArray(c.value)
|
||||
? c.value.map((v, k) => (
|
||||
<span key={k}>
|
||||
{k > 0 && (
|
||||
<span className="text-muted-foreground/70 italic mx-0.5">
|
||||
{t("or")}
|
||||
</span>
|
||||
)}
|
||||
“{v}”
|
||||
</span>
|
||||
))
|
||||
: <>“{c.value}”</>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
appendHtmlSignature,
|
||||
appendPlainTextSignature,
|
||||
getPlainTextSignature,
|
||||
hasMeaningfulHtmlBody,
|
||||
@@ -27,6 +28,27 @@ describe('signature-utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendHtmlSignature', () => {
|
||||
it('appends a sanitized html signature, preserving formatting', () => {
|
||||
expect(appendHtmlSignature('<div>Hello</div>', { htmlSignature: '<strong>Alice</strong>' }))
|
||||
.toBe('<div>Hello</div><br><br>-- <br><strong>Alice</strong>');
|
||||
});
|
||||
|
||||
it('escapes and appends a text signature when no html signature exists', () => {
|
||||
expect(appendHtmlSignature('<div>Hello</div>', { textSignature: 'Alice\nEng' }))
|
||||
.toBe('<div>Hello</div><br><br>-- <br>Alice<br>Eng');
|
||||
});
|
||||
|
||||
it('omits the separator marker when disabled', () => {
|
||||
expect(appendHtmlSignature('<div>Hi</div>', { htmlSignature: '<strong>A</strong>' }, { separator: false }))
|
||||
.toBe('<div>Hi</div><br><br><strong>A</strong>');
|
||||
});
|
||||
|
||||
it('leaves the body untouched when no signature exists', () => {
|
||||
expect(appendHtmlSignature('<div>Hi</div>', {})).toBe('<div>Hi</div>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasMeaningfulHtmlBody', () => {
|
||||
it('prefers html bodies that preserve signature formatting', () => {
|
||||
expect(hasMeaningfulHtmlBody('<div>Hello</div><br><p>Alice</p>')).toBe(true);
|
||||
|
||||
@@ -15,6 +15,8 @@ export const BRANDING_OVERRIDE_KEYS = [
|
||||
'appDescription',
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'pwaScreenshotMobileUrl',
|
||||
'pwaScreenshotDesktopUrl',
|
||||
'pwaThemeColor',
|
||||
'pwaBackgroundColor',
|
||||
'appLogoLightUrl',
|
||||
@@ -42,6 +44,8 @@ export interface DomainBrandingEntry {
|
||||
appDescription?: string;
|
||||
faviconUrl?: string;
|
||||
pwaIconUrl?: string;
|
||||
pwaScreenshotMobileUrl?: string;
|
||||
pwaScreenshotDesktopUrl?: string;
|
||||
pwaThemeColor?: string;
|
||||
pwaBackgroundColor?: string;
|
||||
appLogoLightUrl?: string;
|
||||
|
||||
@@ -53,6 +53,13 @@ export interface ServerPlugin {
|
||||
forceEnabled?: boolean;
|
||||
configSchema?: Record<string, PluginConfigField>;
|
||||
settingsSchema?: Record<string, PluginSettingsField>;
|
||||
/**
|
||||
* Optional per-locale translation tables (locale -> key -> string) declared
|
||||
* in the plugin manifest. Surfaced to the sandbox so plugin code can call
|
||||
* `api.i18n.t(key)`; without it a plugin's strings stay in its hardcoded
|
||||
* default language.
|
||||
*/
|
||||
locales?: Record<string, Record<string, string>>;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
/**
|
||||
|
||||
@@ -142,6 +142,8 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
|
||||
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
|
||||
pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' },
|
||||
pwaScreenshotMobileUrl: { envVar: 'PWA_SCREENSHOT_MOBILE_URL', type: 'url', defaultValue: '' },
|
||||
pwaScreenshotDesktopUrl: { envVar: 'PWA_SCREENSHOT_DESKTOP_URL', type: 'url', defaultValue: '' },
|
||||
pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||||
pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||||
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
|
||||
|
||||
@@ -530,6 +530,14 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return { blobId, size: file.size, type: file.type };
|
||||
}
|
||||
|
||||
async importEmail(): Promise<string | null> {
|
||||
return generateDemoId('email');
|
||||
}
|
||||
|
||||
async sendReadReceipt(): Promise<void> {
|
||||
// Demo mode: no real network send.
|
||||
}
|
||||
|
||||
getBlobDownloadUrl(blobId: string): string {
|
||||
return `data:application/octet-stream;demo-blob=${blobId}`;
|
||||
}
|
||||
|
||||
@@ -150,8 +150,30 @@ export interface IJMAPClient {
|
||||
references?: string[],
|
||||
delayedUntil?: string,
|
||||
envelopeMailFrom?: string,
|
||||
options?: { requestReadReceipt?: boolean },
|
||||
): Promise<SendEmailResult>;
|
||||
|
||||
importEmail(
|
||||
blobId: string,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
keywords?: Record<string, boolean>,
|
||||
accountId?: string,
|
||||
): Promise<string | null>;
|
||||
|
||||
sendReadReceipt(params: {
|
||||
to: string;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
identityId: string;
|
||||
originalMessageId?: string | string[];
|
||||
originalSubject?: string;
|
||||
originalRecipient?: string;
|
||||
automatic?: boolean;
|
||||
accountId?: string;
|
||||
subject?: string;
|
||||
humanText?: string;
|
||||
}): Promise<void>;
|
||||
|
||||
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
|
||||
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
|
||||
cancelEmailSubmission(submissionId: string): Promise<void>;
|
||||
|
||||
+111
-1
@@ -2149,7 +2149,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
inReplyTo?: string[],
|
||||
references?: string[],
|
||||
delayedUntil?: string,
|
||||
envelopeMailFrom?: string
|
||||
envelopeMailFrom?: string,
|
||||
options?: { requestReadReceipt?: boolean }
|
||||
): Promise<SendEmailResult> {
|
||||
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||||
const emailId = `send-${Date.now()}`;
|
||||
@@ -2219,6 +2220,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
mailboxIds: { [draftsMailbox.id]: true },
|
||||
};
|
||||
|
||||
if (options?.requestReadReceipt) {
|
||||
// RFC 8098: ask the recipient's client to return a Message Disposition
|
||||
// Notification to our address. JMAP lets us set the raw header on create
|
||||
// via the "header:<Name>:asText" property form.
|
||||
emailCreate["header:Disposition-Notification-To:asText"] = fromEmail || this.username;
|
||||
}
|
||||
|
||||
if (htmlBody) {
|
||||
// Send as multipart/alternative with both text and HTML
|
||||
emailCreate.bodyValues = {
|
||||
@@ -3007,6 +3015,108 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error('Invalid upload response: blobId not found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a raw RFC822 message (referenced by a previously-uploaded blob) into
|
||||
* one or more mailboxes. Returns the new email id. Used for sending MDNs,
|
||||
* where the exact MIME bytes must be preserved (Email/set can't express a
|
||||
* multipart/report report-type parameter reliably).
|
||||
*/
|
||||
async importEmail(
|
||||
blobId: string,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
keywords?: Record<string, boolean>,
|
||||
accountId?: string
|
||||
): Promise<string | null> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const creationId = `imp-${Date.now()}`;
|
||||
const response = await this.request([
|
||||
["Email/import", {
|
||||
accountId: targetAccountId,
|
||||
emails: {
|
||||
[creationId]: { blobId, mailboxIds, keywords: keywords || { "$seen": true } },
|
||||
},
|
||||
}, "0"],
|
||||
]);
|
||||
const res = response.methodResponses?.[0];
|
||||
if (res?.[0] !== "Email/import") {
|
||||
console.error('Email/import: unexpected response', res);
|
||||
return null;
|
||||
}
|
||||
const payload = res[1] as {
|
||||
created?: Record<string, { id: string }>;
|
||||
notCreated?: Record<string, { type?: string; description?: string }>;
|
||||
};
|
||||
const created = payload?.created?.[creationId];
|
||||
if (!created) {
|
||||
const reason = payload?.notCreated?.[creationId];
|
||||
console.error('Email/import failed:', reason || payload);
|
||||
throw new Error(`Email/import: ${reason?.description || reason?.type || 'unknown error'}`);
|
||||
}
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an RFC 8098 Message Disposition Notification (read receipt) in reply
|
||||
* to a message that carried a Disposition-Notification-To header. Builds the
|
||||
* multipart/report, uploads it as a blob, imports it into Sent, then submits
|
||||
* it with an explicit envelope (MAIL FROM = our identity, RCPT TO = the
|
||||
* requesting address).
|
||||
*/
|
||||
async sendReadReceipt(params: {
|
||||
to: string;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
identityId: string;
|
||||
originalMessageId?: string | string[];
|
||||
originalSubject?: string;
|
||||
originalRecipient?: string;
|
||||
automatic?: boolean;
|
||||
accountId?: string;
|
||||
subject?: string;
|
||||
humanText?: string;
|
||||
}): Promise<void> {
|
||||
const targetAccountId = params.accountId || this.accountId;
|
||||
const { buildMdnMessage } = await import("@/lib/mdn");
|
||||
const raw = buildMdnMessage(params);
|
||||
|
||||
const file = new File([raw], "receipt.eml", { type: "message/rfc822" });
|
||||
const { blobId } = await this.uploadBlob(file);
|
||||
|
||||
const mailboxes = await this.getMailboxes();
|
||||
const targetMailbox = mailboxes.find(mb => mb.role === 'sent') || mailboxes[0];
|
||||
if (!targetMailbox) throw new Error('No mailbox available for MDN import');
|
||||
|
||||
const emailId = await this.importEmail(
|
||||
blobId,
|
||||
{ [targetMailbox.id]: true },
|
||||
{ "$seen": true },
|
||||
targetAccountId
|
||||
);
|
||||
if (!emailId) throw new Error('MDN import failed');
|
||||
|
||||
const subId = `mdnsub-${Date.now()}`;
|
||||
const response = await this.request([
|
||||
["EmailSubmission/set", {
|
||||
accountId: targetAccountId,
|
||||
create: {
|
||||
[subId]: {
|
||||
emailId,
|
||||
identityId: params.identityId,
|
||||
envelope: {
|
||||
mailFrom: { email: params.fromEmail },
|
||||
rcptTo: [{ email: params.to }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "0"],
|
||||
]);
|
||||
const subRes = response.methodResponses?.[0];
|
||||
const notCreated = (subRes?.[1] as { notCreated?: Record<string, { type?: string; description?: string }> })?.notCreated?.[subId];
|
||||
if (notCreated) {
|
||||
throw new Error(`MDN submission failed: ${notCreated.description || notCreated.type || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
|
||||
if (!this.downloadUrl) {
|
||||
throw new Error('Download URL not available. Please reconnect.');
|
||||
|
||||
+19
-3
@@ -13,14 +13,21 @@ export interface SieveCapabilities {
|
||||
externalLists: string[];
|
||||
}
|
||||
|
||||
export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body';
|
||||
export type FilterConditionField =
|
||||
| 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'
|
||||
| 'attachment';
|
||||
|
||||
export type FilterComparator =
|
||||
| 'contains' | 'not_contains'
|
||||
| 'is' | 'not_is'
|
||||
| 'starts_with' | 'ends_with'
|
||||
| 'matches'
|
||||
| 'greater_than' | 'less_than';
|
||||
| 'greater_than' | 'less_than'
|
||||
// For field === 'attachment':
|
||||
// has_any → message has any attachment (Content-Disposition: attachment)
|
||||
// has_type → message has an attachment whose Content-Type matches `value`
|
||||
// (substring match, e.g. "application/pdf" or "image/")
|
||||
| 'has_any' | 'has_type';
|
||||
|
||||
export type FilterActionType =
|
||||
| 'move' | 'copy' | 'forward'
|
||||
@@ -30,7 +37,16 @@ export type FilterActionType =
|
||||
export interface FilterCondition {
|
||||
field: FilterConditionField;
|
||||
comparator: FilterComparator;
|
||||
value: string;
|
||||
/**
|
||||
* Match value. Use a string array for OR-within-condition semantics
|
||||
* (e.g. `["@domain1.com", "@domain2.com"]` matches mail from either).
|
||||
* Sieve emits the array as a list literal which the implementation
|
||||
* treats as "matches any item". Use a plain string for single-value
|
||||
* conditions; existing single-value rules continue to work unchanged.
|
||||
*
|
||||
* Not supported for: size (numeric), has_any (no value).
|
||||
*/
|
||||
value: string | string[];
|
||||
headerName?: string;
|
||||
}
|
||||
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
// Builds an RFC 8098 Message Disposition Notification (MDN) as a raw RFC 5322
|
||||
// message string. JMAP/Stalwart has no native MDN support, so the client
|
||||
// constructs the multipart/report itself and sends it via
|
||||
// blob-upload -> Email/import -> EmailSubmission/set (see client.sendReadReceipt).
|
||||
//
|
||||
// The message has two parts:
|
||||
// 1. text/plain — human-readable explanation (English, ASCII; rarely shown)
|
||||
// 2. message/disposition-notification — the machine-readable fields
|
||||
// The optional third part (original message/headers) is omitted; RFC 8098 §3.1
|
||||
// permits a two-part report.
|
||||
|
||||
export interface MdnOptions {
|
||||
/** Address that requested the receipt (Disposition-Notification-To) — the MDN recipient. */
|
||||
to: string;
|
||||
/** Our identity address (sender of the MDN). */
|
||||
fromEmail: string;
|
||||
/** Optional display name for the From header. */
|
||||
fromName?: string;
|
||||
/** Original Message-ID. JMAP may hand this back as a string[]
|
||||
* (header:Message-ID:asMessageIds), so accept both. */
|
||||
originalMessageId?: string | string[];
|
||||
/** Original Subject (used to build the MDN subject). */
|
||||
originalSubject?: string;
|
||||
/**
|
||||
* The address the original message was delivered to (our address/alias).
|
||||
* Used for Final-Recipient/Original-Recipient. Falls back to fromEmail.
|
||||
*/
|
||||
originalRecipient?: string;
|
||||
/** true => automatic-action (setting "always"); false => manual-action (user clicked send). */
|
||||
automatic?: boolean;
|
||||
/** Reporting-UA value, e.g. "mail.dornig.de; Bulwark Webmail". */
|
||||
reportingUa?: string;
|
||||
/** Localized full Subject line. Defaults to "Read: <originalSubject>". */
|
||||
subject?: string;
|
||||
/** Localized human-readable explanation (first report part). Defaults to English. */
|
||||
humanText?: string;
|
||||
}
|
||||
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/** RFC 5322 date in UTC, e.g. "Thu, 28 May 2026 14:23:00 +0000". */
|
||||
function rfc5322Date(d: Date = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${DAYS[d.getUTCDay()]}, ${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` +
|
||||
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`;
|
||||
}
|
||||
|
||||
/** UTF-8 string -> base64, without the deprecated unescape(). */
|
||||
function utf8ToBase64(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** UTF-8 base64 body, wrapped at 76 chars per RFC 2045. */
|
||||
function base64Body(text: string): string {
|
||||
return (utf8ToBase64(text).match(/.{1,76}/g) || []).join("\r\n");
|
||||
}
|
||||
|
||||
/** RFC 2047 encoded-word for header values that contain non-ASCII characters. */
|
||||
function encodeHeaderWord(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!/[^\x00-\x7F]/.test(value)) return value;
|
||||
return `=?UTF-8?B?${utf8ToBase64(value)}?=`;
|
||||
}
|
||||
|
||||
function ensureAngles(messageId: string | string[] | undefined): string {
|
||||
// JMAP often returns Message-ID as a string[] (header:...:asMessageIds), so
|
||||
// normalize string | string[] | undefined down to a single bracketed id.
|
||||
const raw = Array.isArray(messageId) ? messageId[0] : messageId;
|
||||
if (typeof raw !== "string") return "";
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "";
|
||||
return trimmed.startsWith("<") ? trimmed : `<${trimmed}>`;
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
const rnd = Math.random().toString(36).slice(2);
|
||||
return `${Date.now().toString(36)}.${rnd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the raw RFC 5322 MDN message. Lines are CRLF-terminated as required
|
||||
* by the MIME standard so the bytes import/transmit verbatim.
|
||||
*/
|
||||
export function buildMdnMessage(opts: MdnOptions): string {
|
||||
const finalRecipient = opts.originalRecipient || opts.fromEmail;
|
||||
const domain = (opts.fromEmail.split("@")[1] || "localhost").trim();
|
||||
const messageId = `<mdn.${randomToken()}@${domain}>`;
|
||||
const boundary = `----=_MDN_${randomToken()}`;
|
||||
const origMsgId = ensureAngles(opts.originalMessageId); // normalized "<...>" or ""
|
||||
|
||||
const fromHeader = opts.fromName
|
||||
? `${encodeHeaderWord(opts.fromName)} <${opts.fromEmail}>`
|
||||
: opts.fromEmail;
|
||||
|
||||
const subject = encodeHeaderWord(
|
||||
opts.subject ?? `Read: ${opts.originalSubject || ""}`.trim()
|
||||
);
|
||||
|
||||
const disposition = opts.automatic
|
||||
? "automatic-action/MDN-sent-automatically; displayed"
|
||||
: "manual-action/MDN-sent-manually; displayed";
|
||||
|
||||
const reportingUa = opts.reportingUa || `${domain}; Bulwark Webmail`;
|
||||
|
||||
// Human-readable part. Caller passes a localized humanText; fall back to
|
||||
// English. Encoded as UTF-8/base64 below so any language survives.
|
||||
const humanText = opts.humanText ?? [
|
||||
`This is a return receipt for the message you sent to ${finalRecipient}.`,
|
||||
``,
|
||||
`Note: This receipt only acknowledges that the message was displayed on the`,
|
||||
`recipient's computer. There is no guarantee that the recipient has read or`,
|
||||
`understood the message contents.`,
|
||||
].join("\r\n");
|
||||
|
||||
// Machine-readable disposition-notification part (pure ASCII tokens).
|
||||
const mdnFields = [
|
||||
`Reporting-UA: ${reportingUa}`,
|
||||
`Final-Recipient: rfc822;${finalRecipient}`,
|
||||
...(opts.originalRecipient ? [`Original-Recipient: rfc822;${opts.originalRecipient}`] : []),
|
||||
...(origMsgId ? [`Original-Message-ID: ${origMsgId}`] : []),
|
||||
`Disposition: ${disposition}`,
|
||||
].join("\r\n");
|
||||
|
||||
return [
|
||||
`Date: ${rfc5322Date()}`,
|
||||
`From: ${fromHeader}`,
|
||||
`To: ${opts.to}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
...(origMsgId ? [`In-Reply-To: ${origMsgId}`] : []),
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: multipart/report; report-type=disposition-notification;`,
|
||||
`\tboundary="${boundary}"`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
`Content-Transfer-Encoding: base64`,
|
||||
``,
|
||||
base64Body(humanText),
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: message/disposition-notification`,
|
||||
`Content-Transfer-Encoding: 7bit`,
|
||||
``,
|
||||
mdnFields,
|
||||
``,
|
||||
`--${boundary}--`,
|
||||
``,
|
||||
].join("\r\n");
|
||||
}
|
||||
+12
-19
@@ -10,32 +10,25 @@ import {
|
||||
activateAllSandboxed,
|
||||
deactivateAllSandboxed,
|
||||
setSandboxStoreAccessor,
|
||||
setSandboxLocale,
|
||||
setupSandboxAutoDisable,
|
||||
} from './plugin-sandbox/loader';
|
||||
import { all as allActive, get as getActive } from './plugin-sandbox/registry';
|
||||
|
||||
// Re-export so the plugin store can keep the sandbox locale in step via this
|
||||
// facade, instead of importing lib/plugin-sandbox/loader directly (which would
|
||||
// also pull the hook buses into consumers' module graphs).
|
||||
export { setSandboxLocale } from './plugin-sandbox/loader';
|
||||
|
||||
/**
|
||||
* Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__`
|
||||
* so blob-imported plugin code could resolve `react`. With the sandbox model
|
||||
* plugins receive React injected as a function argument inside their iframe
|
||||
* runtime - there is nothing to expose on the host window.
|
||||
*
|
||||
* Kept as a no-op for callers that still invoke it during app bootstrap.
|
||||
* Historically re-published React/ReactDOM on `globalThis` for the blob-import
|
||||
* loader, and later also bootstrapped plugin locale sync. Both are obsolete:
|
||||
* the sandbox injects React per-iframe, and locale sync now lives where plugin
|
||||
* activation is orchestrated (stores/plugin-store -> initializePlugins, via
|
||||
* setSandboxLocale). Kept as a no-op for the legacy activateAllPlugins()
|
||||
* wrapper and its test.
|
||||
*/
|
||||
export function exposePluginExternals(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
// Initialise the locale sync once. Importing the store lazily avoids the
|
||||
// circular module graph we used to fight before the sandbox refactor.
|
||||
void import('@/stores/locale-store').then(({ useLocaleStore }) => {
|
||||
setSandboxLocale(useLocaleStore.getState().locale);
|
||||
useLocaleStore.subscribe((state) => setSandboxLocale(state.locale));
|
||||
// Mirror on a global so the slot-iframe component can read it at spawn.
|
||||
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = useLocaleStore.getState().locale;
|
||||
useLocaleStore.subscribe((state) => {
|
||||
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = state.locale;
|
||||
});
|
||||
}).catch(() => { /* locale sync is best-effort */ });
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
// ─── Store accessor (status updates) ──────────────────────────
|
||||
|
||||
@@ -140,7 +140,7 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown):
|
||||
headers['Authorization'] = client.getAuthHeader();
|
||||
headers['X-JMAP-Username'] = client.getUsername();
|
||||
}
|
||||
const res = await fetch(url.pathname + url.search, {
|
||||
const res = await apiFetch(url.pathname + url.search, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { InstalledPlugin, SlotName } from '../plugin-types';
|
||||
import { dispatchApiCall } from './host-api';
|
||||
import { SANDBOX_PATH } from './protocol';
|
||||
import { withBasePath } from '../browser-navigation';
|
||||
import type {
|
||||
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
|
||||
} from './protocol';
|
||||
@@ -143,7 +144,10 @@ export class SandboxInstance {
|
||||
this.iframe.style.width = '100%';
|
||||
this.iframe.style.height = '0px';
|
||||
}
|
||||
this.iframe.src = SANDBOX_PATH;
|
||||
// Prefix with the mount path so the sandbox route resolves under a
|
||||
// subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare
|
||||
// "/plugin-sandbox" would hit the origin root and 404, breaking plugins.
|
||||
this.iframe.src = withBasePath(SANDBOX_PATH);
|
||||
|
||||
this.listener = (ev) => this.onMessage(ev);
|
||||
window.addEventListener('message', this.listener);
|
||||
|
||||
@@ -41,11 +41,16 @@ export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor
|
||||
|
||||
let currentLocale = 'en';
|
||||
export function setSandboxLocale(locale: string): void {
|
||||
// Ignore empty/falsy values so a not-yet-seeded locale store can't clobber a
|
||||
// good locale back to '' - the initial 'en' default stands until the real
|
||||
// locale arrives via the store subscription.
|
||||
if (!locale) return;
|
||||
currentLocale = locale;
|
||||
// Push to all active background instances.
|
||||
// Slot iframes inherit locale at spawn time; they're short-lived.
|
||||
// (We don't import the registry here to avoid a circular import; the
|
||||
// PluginIframeSlot subscribes to locale changes on its own.)
|
||||
// Background instances read `currentLocale` at load time; the slot-iframe
|
||||
// component reads this global at spawn time (plugin-iframe-slot.tsx). Keep
|
||||
// both in step from one place. Already-running instances are not re-pushed,
|
||||
// so a locale switch only affects plugins/slots loaded afterwards.
|
||||
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale;
|
||||
}
|
||||
|
||||
// ─── Bundle fetch ─────────────────────────────────────────────
|
||||
|
||||
@@ -73,18 +73,26 @@ function uid(): string {
|
||||
|
||||
// ─── Sandboxed API facade (calls flow to host via postMessage) ─
|
||||
|
||||
function callApi(method: string, args: unknown[]): Promise<unknown> {
|
||||
const DEFAULT_API_TIMEOUT_MS = 30_000;
|
||||
|
||||
function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise<unknown> {
|
||||
const id = uid();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingApi.set(id, { resolve, reject });
|
||||
sendToHost({ type: 'api-request', id, method, args });
|
||||
// Reject after 30s to prevent unbounded promise leaks if the host hangs.
|
||||
setTimeout(() => {
|
||||
const entry = pendingApi.get(id);
|
||||
if (!entry) return;
|
||||
pendingApi.delete(id);
|
||||
entry.reject(new Error(`API call ${method} timed out after 30s`));
|
||||
}, 30_000);
|
||||
// Bounded so a hung host can't leak the promise forever. Interactive UI
|
||||
// dialogs (ui.confirm/ui.alert) pass timeoutMs <= 0 to opt out: they wait
|
||||
// for human input, the host always resolves them on confirm/cancel/close,
|
||||
// and any still-pending call dies with the iframe on teardown - so there's
|
||||
// nothing to leak, and a thinking user must not trip a 30s timeout.
|
||||
if (timeoutMs > 0 && Number.isFinite(timeoutMs)) {
|
||||
setTimeout(() => {
|
||||
const entry = pendingApi.get(id);
|
||||
if (!entry) return;
|
||||
pendingApi.delete(id);
|
||||
entry.reject(new Error(`API call ${method} timed out after ${Math.round(timeoutMs / 1000)}s`));
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,12 +159,13 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
warning: (m: string) => { void callApi('toast.warning', [m]); },
|
||||
},
|
||||
ui: {
|
||||
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. */
|
||||
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise.
|
||||
* No timeout - it waits for the user's choice. */
|
||||
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
|
||||
callApi('ui.confirm', [opts]) as Promise<boolean>,
|
||||
/** Opens a host-rendered alert (one button). Resolves once dismissed. */
|
||||
callApi('ui.confirm', [opts], 0) as Promise<boolean>,
|
||||
/** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
|
||||
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
|
||||
callApi('ui.alert', [opts]) as Promise<void>,
|
||||
callApi('ui.alert', [opts], 0) as Promise<void>,
|
||||
/** Opens an http/https URL in a new tab via host `window.open`. */
|
||||
openExternalUrl: (url: string, target?: string) =>
|
||||
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
||||
@@ -173,6 +182,26 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
|
||||
error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a),
|
||||
},
|
||||
// Localization for plugins. The host pushes the active locale (init +
|
||||
// 'locale-change'); `t` resolves a key against the plugin's declared
|
||||
// `locales` map (manifest.locales), falling back to English then the key
|
||||
// itself, with optional {placeholder} interpolation.
|
||||
i18n: {
|
||||
get locale(): string {
|
||||
return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
|
||||
},
|
||||
t(key: string, vars?: Record<string, string | number>): string {
|
||||
const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
|
||||
const tables = manifest.locales || {};
|
||||
let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
out = out.split('{' + k + '}').join(String(v));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -344,6 +373,9 @@ async function handleInit(payload: InitPayload): Promise<void> {
|
||||
if (bootDone) return;
|
||||
bootDone = true;
|
||||
mode = payload.mode;
|
||||
// Make the active locale available to plugin code (api.i18n) right away -
|
||||
// not only after the first 'locale-change' push.
|
||||
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale;
|
||||
try {
|
||||
if (payload.mode === 'background') {
|
||||
await bootBackground(payload);
|
||||
|
||||
+38
-4
@@ -10,6 +10,17 @@ import { formatDateTime } from "@/lib/utils";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types";
|
||||
|
||||
// Localized label set the caller passes in. Labels live on the client where
|
||||
// useTranslations is available; this module stays framework-agnostic.
|
||||
export interface QuoteHeaderLabels {
|
||||
/** ICU-formatted reply line, e.g. "On {date}, {from} wrote:" with placeholders already substituted. */
|
||||
formatReplyLine: (vars: { date: string; from: string }) => string;
|
||||
forwardedSeparator: string;
|
||||
fromLabel: string;
|
||||
dateLabel: string;
|
||||
subjectLabel: string;
|
||||
}
|
||||
|
||||
interface BuildArgs {
|
||||
mode: "reply" | "replyAll" | "forward";
|
||||
email: {
|
||||
@@ -24,10 +35,24 @@ interface BuildArgs {
|
||||
locale: string;
|
||||
timeFormat: "12h" | "24h";
|
||||
unknownLabel: string;
|
||||
/**
|
||||
* Localized labels. Optional for backward compatibility; falls back to
|
||||
* English (matching the original hardcoded behaviour) when not supplied.
|
||||
*/
|
||||
labels?: QuoteHeaderLabels;
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS: QuoteHeaderLabels = {
|
||||
formatReplyLine: ({ date, from }) => `On ${date}, ${from} wrote:`,
|
||||
forwardedSeparator: "---------- Forwarded message ----------",
|
||||
fromLabel: "From",
|
||||
dateLabel: "Date",
|
||||
subjectLabel: "Subject",
|
||||
};
|
||||
|
||||
function defaultHeader(args: BuildArgs): QuoteHeader {
|
||||
const { mode, email, timeFormat, unknownLabel } = args;
|
||||
const labels = args.labels ?? DEFAULT_LABELS;
|
||||
const date = email.receivedAt
|
||||
? formatDateTime(email.receivedAt, timeFormat, {
|
||||
weekday: "short",
|
||||
@@ -38,16 +63,25 @@ function defaultHeader(args: BuildArgs): QuoteHeader {
|
||||
: "";
|
||||
const from = email.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : unknownLabel;
|
||||
// Forward header "From:" shows the full sender incl. address ("Name
|
||||
// <email>"), like every mail client. The reply line keeps the bare name
|
||||
// (reads more naturally in "On … wrote:").
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
: (from.email || from.name || unknownLabel))
|
||||
: unknownLabel;
|
||||
const subject = email.subject || "";
|
||||
|
||||
if (mode === "forward") {
|
||||
const text = `---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${subject}\n`;
|
||||
const html = `<div>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${subject}<br><br></div>`;
|
||||
const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`;
|
||||
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${fromStrFull}<br>${labels.dateLabel}: ${date}<br>${labels.subjectLabel}: ${subject}<br><br></div>`;
|
||||
return { html, text, wrapInBlockquote: false };
|
||||
}
|
||||
|
||||
const text = `On ${date}, ${fromStr} wrote:\n`;
|
||||
const html = `<div>On ${date}, ${fromStr} wrote:<br></div>`;
|
||||
const replyLine = labels.formatReplyLine({ date, from: fromStr });
|
||||
const text = `${replyLine}\n`;
|
||||
const html = `<div>${replyLine}<br></div>`;
|
||||
return { html, text, wrapInBlockquote: true };
|
||||
}
|
||||
|
||||
|
||||
+53
-12
@@ -12,42 +12,82 @@ function escapeString(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
// Normalise the condition value to a non-empty string array. Single-value
|
||||
// conditions stay one-element; arrays are filtered for empty strings.
|
||||
function toValueList(value: string | string[]): string[] {
|
||||
const arr = Array.isArray(value) ? value : [value];
|
||||
return arr.map((v) => (v ?? '').toString()).filter((v) => v.length > 0);
|
||||
}
|
||||
|
||||
// Render one or many strings as a Sieve string-literal-or-list. Sieve treats
|
||||
// `header :contains "From" ["a", "b"]` as "any of a, b" (built-in OR within
|
||||
// the condition); the single-string form is emitted unchanged when len === 1
|
||||
// so existing scripts and tests stay byte-identical.
|
||||
function formatStringArg(values: string[], transform: (s: string) => string = (s) => s): string {
|
||||
if (values.length === 1) {
|
||||
return `"${escapeString(transform(values[0]))}"`;
|
||||
}
|
||||
return `[${values.map((v) => `"${escapeString(transform(v))}"`).join(', ')}]`;
|
||||
}
|
||||
|
||||
function generateCondition(condition: FilterCondition): string {
|
||||
const { field, comparator, value } = condition;
|
||||
|
||||
if (field === 'size') {
|
||||
// Size is numeric, single value only.
|
||||
const sizeValue = Array.isArray(value) ? value[0] : value;
|
||||
const op = comparator === 'greater_than' ? ':over' : ':under';
|
||||
return `size ${op} ${value}`;
|
||||
return `size ${op} ${sizeValue}`;
|
||||
}
|
||||
|
||||
const values = toValueList(value);
|
||||
|
||||
if (field === 'body') {
|
||||
const matchType = comparator === 'is' ? ':is' : ':contains';
|
||||
return `body ${matchType} "${escapeString(value)}"`;
|
||||
return `body ${matchType} ${formatStringArg(values)}`;
|
||||
}
|
||||
|
||||
if (field === 'attachment') {
|
||||
// RFC 5703: :mime :anychild matches against headers of any MIME part.
|
||||
// has_any tests Content-Disposition for "attachment"; has_type matches
|
||||
// the file extension against the filename across BOTH Content-Disposition
|
||||
// (filename= parameter) and Content-Type (name= parameter) - many older
|
||||
// senders (Microsoft SMTPSVC, PrintToMail, etc.) put the filename only
|
||||
// in Content-Type and leave Content-Disposition without a filename.
|
||||
// RFC 5228 §5.7 allows a string-list for header names; the test passes
|
||||
// if any listed header matches. Wildcard "*.<ext>*" catches quoted,
|
||||
// unquoted, and RFC-2231-encoded forms alike since ".<ext>" appears as
|
||||
// a literal substring in all of them.
|
||||
// Multiple extensions become a Sieve value-list ["*.pdf*", "*.xml*"]
|
||||
// = OR within the condition (any item matches → test passes).
|
||||
if (comparator === 'has_any') {
|
||||
return `header :mime :anychild :contains "Content-Disposition" "attachment"`;
|
||||
}
|
||||
const normalised = values.map((v) => v.replace(/^[.*]+/, '').trim()).filter(Boolean);
|
||||
return `header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ${formatStringArg(normalised, (ext) => `*.${ext}*`)}`;
|
||||
}
|
||||
|
||||
const headerName = field === 'header'
|
||||
? (condition.headerName || 'X-Unknown')
|
||||
: HEADER_MAP[field];
|
||||
|
||||
const escaped = escapeString(value);
|
||||
|
||||
switch (comparator) {
|
||||
case 'contains':
|
||||
return `header :contains "${headerName}" "${escaped}"`;
|
||||
return `header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||
case 'not_contains':
|
||||
return `not header :contains "${headerName}" "${escaped}"`;
|
||||
return `not header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||
case 'is':
|
||||
return `header :is "${headerName}" "${escaped}"`;
|
||||
return `header :is "${headerName}" ${formatStringArg(values)}`;
|
||||
case 'not_is':
|
||||
return `not header :is "${headerName}" "${escaped}"`;
|
||||
return `not header :is "${headerName}" ${formatStringArg(values)}`;
|
||||
case 'starts_with':
|
||||
return `header :matches "${headerName}" "${escaped}*"`;
|
||||
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `${v}*`)}`;
|
||||
case 'ends_with':
|
||||
return `header :matches "${headerName}" "*${escaped}"`;
|
||||
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `*${v}`)}`;
|
||||
case 'matches':
|
||||
return `header :matches "${headerName}" "${escaped}"`;
|
||||
return `header :matches "${headerName}" ${formatStringArg(values)}`;
|
||||
default:
|
||||
return `header :contains "${headerName}" "${escaped}"`;
|
||||
return `header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +129,7 @@ function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): s
|
||||
for (const rule of enabledRules) {
|
||||
for (const condition of rule.conditions) {
|
||||
if (condition.field === 'body') extensions.add('body');
|
||||
if (condition.field === 'attachment') extensions.add('mime');
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
switch (action.type) {
|
||||
|
||||
+133
-22
@@ -36,7 +36,11 @@ const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
|
||||
function isValidCondition(c: unknown): boolean {
|
||||
if (!c || typeof c !== 'object') return false;
|
||||
const cond = c as Record<string, unknown>;
|
||||
return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string';
|
||||
if (typeof cond.field !== 'string' || typeof cond.comparator !== 'string') return false;
|
||||
// value may be a string OR a non-empty array of strings (Patch 11
|
||||
// multi-value semantics). Accept both.
|
||||
if (typeof cond.value === 'string') return true;
|
||||
return Array.isArray(cond.value) && cond.value.every((v) => typeof v === 'string');
|
||||
}
|
||||
|
||||
function isValidAction(a: unknown): boolean {
|
||||
@@ -334,43 +338,150 @@ function parseAtom(raw: string): FilterCondition | null {
|
||||
}
|
||||
}
|
||||
|
||||
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
// Parse the value-tail of a header/body test: either a single quoted
|
||||
// string or a Sieve list literal ["a", "b", ...]. Returns the unwrapped
|
||||
// value(s), preserving the array shape when present so the caller can
|
||||
// detect multi-value conditions.
|
||||
const parseValueTail = (raw: string): string | string[] | null => {
|
||||
const trimmed = raw.trim();
|
||||
// List form
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
const inner = trimmed.slice(1, -1);
|
||||
const items: string[] = [];
|
||||
const re = /"((?:[^"\\]|\\.)*)"/g;
|
||||
let mm: RegExpExecArray | null;
|
||||
let cursor = 0;
|
||||
while ((mm = re.exec(inner)) !== null) {
|
||||
// Ensure only whitespace and commas appear between items
|
||||
if (inner.slice(cursor, mm.index).replace(/[\s,]/g, '') !== '') return null;
|
||||
items.push(unescapeSieveString(mm[1]));
|
||||
cursor = mm.index + mm[0].length;
|
||||
}
|
||||
if (inner.slice(cursor).replace(/[\s,]/g, '') !== '') return null;
|
||||
if (items.length === 0) return null;
|
||||
return items.length === 1 ? items[0] : items;
|
||||
}
|
||||
// Single string form
|
||||
const single = /^"((?:[^"\\]|\\.)*)"$/.exec(trimmed);
|
||||
if (single) return unescapeSieveString(single[1]);
|
||||
return null;
|
||||
};
|
||||
|
||||
// Classify a :matches value (or values) into starts_with / ends_with /
|
||||
// matches by inspecting wildcard positions. For multi-value, all items
|
||||
// must share the same shape; otherwise we fall back to 'matches' and
|
||||
// keep the wildcards verbatim.
|
||||
const classifyMatches = (
|
||||
values: string | string[],
|
||||
): { comparator: 'starts_with' | 'ends_with' | 'matches'; stripped: string | string[] } => {
|
||||
const arr = Array.isArray(values) ? values : [values];
|
||||
const isTrailing = (v: string) => {
|
||||
const stars = [...v].filter((c) => c === '*').length;
|
||||
return stars === 1 && v.endsWith('*');
|
||||
};
|
||||
const isLeading = (v: string) => {
|
||||
const stars = [...v].filter((c) => c === '*').length;
|
||||
return stars === 1 && v.startsWith('*');
|
||||
};
|
||||
if (arr.every(isTrailing)) {
|
||||
const stripped = arr.map((v) => v.slice(0, -1));
|
||||
return { comparator: 'starts_with', stripped: Array.isArray(values) ? stripped : stripped[0] };
|
||||
}
|
||||
if (arr.every(isLeading)) {
|
||||
const stripped = arr.map((v) => v.slice(1));
|
||||
return { comparator: 'ends_with', stripped: Array.isArray(values) ? stripped : stripped[0] };
|
||||
}
|
||||
return { comparator: 'matches', stripped: values };
|
||||
};
|
||||
|
||||
// Match attachment-aware :mime :anychild tests before the generic header
|
||||
// pattern - emitted by our own generator for field === 'attachment'.
|
||||
// has_any: ":contains Content-Disposition attachment"
|
||||
let m = /^header\s+:mime\s+:anychild\s+:contains\s+"Content-Disposition"\s+"attachment"$/.exec(s);
|
||||
if (m) {
|
||||
const [, tag, headerName, rawValue] = m;
|
||||
const value = unescapeSieveString(rawValue);
|
||||
return { field: 'attachment', comparator: 'has_any', value: '' };
|
||||
}
|
||||
// has_type: ":matches <headers> <value-tail>"
|
||||
// - Current emit form uses a header-list ["Content-Disposition", "Content-Type"]
|
||||
// to catch senders who put the filename only in Content-Type's name= param
|
||||
// (Microsoft SMTPSVC, PrintToMail.net, etc.).
|
||||
// - Legacy emit form used a single "Content-Disposition" header - still
|
||||
// recognised here so rules saved before the fix remain editable.
|
||||
// Each value item must be a "*.<ext>*" wildcard pattern.
|
||||
const tryHasType = (rawHeaders: string, rawValue: string): FilterCondition | null => {
|
||||
// Header part: accept either a single quoted string or a 2-element list
|
||||
// containing exactly Content-Disposition + Content-Type (in any order).
|
||||
const single = /^"Content-Disposition"$/.exec(rawHeaders.trim());
|
||||
const listForm = /^\[\s*((?:"(?:[^"\\]|\\.)*"\s*,?\s*)+)\]$/.exec(rawHeaders.trim());
|
||||
let headersOk = false;
|
||||
if (single) {
|
||||
headersOk = true;
|
||||
} else if (listForm) {
|
||||
const inner = listForm[1];
|
||||
const items: string[] = [];
|
||||
const re = /"((?:[^"\\]|\\.)*)"/g;
|
||||
let mm: RegExpExecArray | null;
|
||||
while ((mm = re.exec(inner)) !== null) items.push(unescapeSieveString(mm[1]));
|
||||
const expected = new Set(['Content-Disposition', 'Content-Type']);
|
||||
const got = new Set(items);
|
||||
headersOk =
|
||||
items.length === expected.size &&
|
||||
[...expected].every((h) => got.has(h));
|
||||
}
|
||||
if (!headersOk) return null;
|
||||
const tail = parseValueTail(rawValue);
|
||||
if (tail === null) return null;
|
||||
const arr = Array.isArray(tail) ? tail : [tail];
|
||||
const exts: string[] = [];
|
||||
for (const item of arr) {
|
||||
const em = /^\*\.((?:[^*\\]|\\.)+)\*$/.exec(item);
|
||||
if (!em) return null;
|
||||
exts.push(unescapeSieveString(em[1]));
|
||||
}
|
||||
return { field: 'attachment', comparator: 'has_type', value: exts.length === 1 ? exts[0] : exts };
|
||||
};
|
||||
m = /^header\s+:mime\s+:anychild\s+:matches\s+(\[[\s\S]+?\]|"[^"]+")\s+([\s\S]+)$/.exec(s);
|
||||
if (m) {
|
||||
const result = tryHasType(m[1], m[2]);
|
||||
if (result) return result;
|
||||
}
|
||||
// Unknown :mime :anychild pattern (e.g. from external scripts) - bail to
|
||||
// opaque rendering so we don't silently misrepresent the script.
|
||||
if (/^header\s+:mime\s+:anychild\b/.test(s)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
|
||||
if (m) {
|
||||
const [, tag, headerName, rawTail] = m;
|
||||
const value = parseValueTail(rawTail);
|
||||
if (value === null) return null;
|
||||
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
|
||||
|
||||
let comparator: FilterComparator;
|
||||
let finalValue: string | string[];
|
||||
if (tag === 'contains') {
|
||||
comparator = negated ? 'not_contains' : 'contains';
|
||||
finalValue = value;
|
||||
} else if (tag === 'is') {
|
||||
comparator = negated ? 'not_is' : 'is';
|
||||
finalValue = value;
|
||||
} else {
|
||||
// :matches - distinguish starts_with / ends_with / matches
|
||||
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
|
||||
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
|
||||
comparator = 'starts_with';
|
||||
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
if (starPositions.length === 1 && starPositions[0] === 0) {
|
||||
comparator = 'ends_with';
|
||||
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
comparator = 'matches';
|
||||
const classified = classifyMatches(value);
|
||||
comparator = classified.comparator;
|
||||
finalValue = classified.stripped;
|
||||
}
|
||||
|
||||
const cond: FilterCondition = { field, comparator, value };
|
||||
const cond: FilterCondition = { field, comparator, value: finalValue };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
|
||||
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
m = /^body\s+:(contains|is)\s+([\s\S]+)$/.exec(s);
|
||||
if (m) {
|
||||
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) };
|
||||
const value = parseValueTail(m[2]);
|
||||
if (value === null) return null;
|
||||
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value };
|
||||
}
|
||||
|
||||
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
|
||||
|
||||
@@ -124,6 +124,35 @@ export function appendPlainTextSignature(
|
||||
return `${body}${sep}${plainTextSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a signature to an HTML body, preserving rich formatting. Used by the
|
||||
* quick-reply path so an HTML signature keeps its markup instead of being
|
||||
* flattened to plain text. Mirrors the composer's send-time signature block
|
||||
* (`buildSignatureHtml` in email-composer.tsx).
|
||||
*/
|
||||
export function appendHtmlSignature(
|
||||
htmlBody: string,
|
||||
signature?: SignatureSource | null,
|
||||
options: { separator?: boolean } = {},
|
||||
): string {
|
||||
const sep = options.separator === false ? '<br><br>' : '<br><br>-- <br>';
|
||||
|
||||
if (signature?.htmlSignature?.trim()) {
|
||||
return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`;
|
||||
}
|
||||
|
||||
if (signature?.textSignature?.trim()) {
|
||||
const escaped = signature.textSignature
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `${htmlBody}${sep}${escaped}`;
|
||||
}
|
||||
|
||||
return htmlBody;
|
||||
}
|
||||
|
||||
export function hasMeaningfulHtmlBody(html: string): boolean {
|
||||
if (!html.trim()) return false;
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Reply / forward subject prefix handling.
|
||||
//
|
||||
// Real-world email subjects accumulate prefixes across clients and languages:
|
||||
// "Re: AW: WG: Fwd: Re: foo". The deduplication regex needs to know ALL
|
||||
// commonly-used reply/forward markers - not just the current locale's, since
|
||||
// inbound messages may come from any locale. Failing to strip a foreign-locale
|
||||
// prefix means the user's locale prefix gets *added on top* and the subject
|
||||
// chain keeps growing.
|
||||
//
|
||||
// Sources: de-facto conventions in Outlook / Thunderbird / Apple Mail per
|
||||
// language. Includes a handful of legacy short-forms (R:, Fw:) that some
|
||||
// mobile clients still emit.
|
||||
|
||||
const REPLY_TOKENS = [
|
||||
"Re", // English, Italian, French (also generic ISO)
|
||||
"RE", // Outlook variant
|
||||
"AW", // German (Antwort)
|
||||
"Antw", // German verbose
|
||||
"Sv", // Danish / Swedish / Norwegian (Svar)
|
||||
"Yn", // Turkish (Yanit)
|
||||
"Yanit", // Turkish verbose
|
||||
"Odp", // Polish (Odpowiedz)
|
||||
"Ответ", // Russian
|
||||
"Resp", // Spanish/Portuguese variant
|
||||
"Vá", // Hungarian
|
||||
"回复", // Chinese
|
||||
"回覆", // Chinese traditional
|
||||
"답장", // Korean
|
||||
// NB: deliberately no bare "R" token — a single letter would strip the first
|
||||
// word of legitimate subjects like "R: budget 2024". The full "Re" covers
|
||||
// the common Italian/English case anyway.
|
||||
];
|
||||
|
||||
const FORWARD_TOKENS = [
|
||||
"Fwd", // English standard
|
||||
"Fw", // English short / Polish / German short
|
||||
"WG", // German (Weitergeleitet)
|
||||
"Tr", // French (Transfert)
|
||||
"Vs", // Danish (Videresend)
|
||||
"Enc", // Portuguese (Encaminhar)
|
||||
"ENC", // Portuguese caps
|
||||
"Rv", // Spanish (Reenviar)
|
||||
"RV", // Spanish caps
|
||||
"Rvf", // Spanish variant
|
||||
"Inol", // Italian (Inoltro)
|
||||
// NB: deliberately no bare "I" token — see the REPLY_TOKENS note above.
|
||||
"PD", // Polish (Przekazane Dalej)
|
||||
"PR", // Czech (Preposlat)
|
||||
"İlt", // Turkish (Ilet)
|
||||
"Ilt", // Turkish ASCII
|
||||
"Пересл", // Russian (Peresylka)
|
||||
"Пер", // Russian short
|
||||
"转发", // Chinese
|
||||
"轉寄", // Chinese traditional
|
||||
"전달", // Korean
|
||||
];
|
||||
|
||||
// Match a single prefix token + optional [N] counter (Outlook) or *N (Eudora)
|
||||
// + colon + whitespace. Case-insensitive. The non-capturing groups keep the
|
||||
// regex composable for stripping multiple prefixes in a row.
|
||||
function buildPrefixRegex(tokens: string[]): RegExp {
|
||||
// Escape regex specials in tokens (none currently, but be defensive)
|
||||
const escaped = tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
// Sort by length DESC so longer tokens (e.g. "Пересл") win over their
|
||||
// shorter prefixes (e.g. "Пер") during alternation matching.
|
||||
escaped.sort((a, b) => b.length - a.length);
|
||||
return new RegExp(
|
||||
`^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*:\\s*`,
|
||||
"i",
|
||||
);
|
||||
}
|
||||
|
||||
const ANY_PREFIX_RE = buildPrefixRegex([...REPLY_TOKENS, ...FORWARD_TOKENS]);
|
||||
|
||||
/**
|
||||
* Strip any leading sequence of reply/forward prefixes (across languages) from
|
||||
* a subject line. Idempotent and safe for empty input.
|
||||
*
|
||||
* Examples:
|
||||
* stripSubjectPrefixes("Re: AW: WG: foo") === "foo"
|
||||
* stripSubjectPrefixes("Re[2]: foo") === "foo"
|
||||
* stripSubjectPrefixes("RE: Re: foo") === "foo"
|
||||
* stripSubjectPrefixes("foo") === "foo"
|
||||
* stripSubjectPrefixes("") === ""
|
||||
*/
|
||||
export function stripSubjectPrefixes(subject: string | undefined | null): string {
|
||||
if (!subject) return "";
|
||||
let s = subject;
|
||||
// Bounded loop: in practice you never see more than ~10 prefixes; the bound
|
||||
// protects against pathological input. Each iteration must consume input.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const next = s.replace(ANY_PREFIX_RE, "");
|
||||
if (next === s) break;
|
||||
s = next;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a reply subject with the given locale-aware prefix. Strips any
|
||||
* pre-existing prefixes (in any language) first so chains don't accumulate.
|
||||
*
|
||||
* buildReplySubject("AW: WG: foo", "Re:") === "Re: foo"
|
||||
* buildReplySubject("foo", "AW:") === "AW: foo"
|
||||
* buildReplySubject("", "AW:") === "AW:"
|
||||
*/
|
||||
export function buildReplySubject(subject: string | undefined | null, prefix: string): string {
|
||||
const stripped = stripSubjectPrefixes(subject);
|
||||
return stripped ? `${prefix} ${stripped}` : prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a forward subject. Same logic as buildReplySubject but conceptually
|
||||
* separate for clarity at the call site.
|
||||
*/
|
||||
export function buildForwardSubject(subject: string | undefined | null, prefix: string): string {
|
||||
const stripped = stripSubjectPrefixes(subject);
|
||||
return stripped ? `${prefix} ${stripped}` : prefix;
|
||||
}
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "Odesílatel žádá o potvrzení o přečtení:",
|
||||
"send": "Odeslat potvrzení",
|
||||
"ignore": "Ignorovat",
|
||||
"sent": "Potvrzení o přečtení odesláno.",
|
||||
"send_failed": "Potvrzení o přečtení se nepodařilo odeslat",
|
||||
"mdn_subject": "Přečteno: {subject}",
|
||||
"mdn_body": "Toto je potvrzení o přečtení zprávy, kterou jste odeslali na adresu {recipient}.\n\nPoznámka: Toto potvrzení pouze potvrzuje, že zpráva byla zobrazena na počítači příjemce. Nezaručuje, že příjemce obsah přečetl nebo mu porozuměl."
|
||||
},
|
||||
"no_email_selected": "Není vybrána žádná zpráva",
|
||||
"no_email_description": "Vyberte zprávu ze seznamu pro její zobrazení",
|
||||
"no_conversation_selected": "Není vybrána žádná konverzace",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Vrátit odeslání"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Vyžádáno potvrzení o přečtení (kliknutím vypnete)",
|
||||
"read_receipt_off": "Vyžádat potvrzení o přečtení",
|
||||
"new_message": "Nová zpráva",
|
||||
"reply": "Odpovědět",
|
||||
"reply_all": "Odpovědět všem",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "Odeslat",
|
||||
"cancel": "Zrušit",
|
||||
"attach": "Připojit",
|
||||
"attach_photos": "Fotky a videa",
|
||||
"attach_files": "Soubory",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Zahodit",
|
||||
"discard_draft_title": "Zahodit koncept?",
|
||||
"discard_draft_confirm": "Máte neuložené změny. Chcete tento koncept zahodit?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "Naplánované odeslání není pro tento účet podporováno.",
|
||||
"schedule_send_cleanup_warning": "Naplánované odeslání bylo vytvořeno, ale vyčištění konceptu selhalo.",
|
||||
"send_delay_unsupported": "Prodleva odeslání není pro tento účet podporována.",
|
||||
"send_delay_unsupported_confirm": "Tento účet nepodporuje prodlevu odeslání. Odeslat ihned?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "Tento účet nepodporuje prodlevu odeslání. Odeslat ihned?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Potvrdit",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Standardně vyžadovat potvrzení o přečtení",
|
||||
"description": "Při psaní nové zprávy předem zapnout žádost o potvrzení o přečtení."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Reagovat na žádosti o potvrzení o přečtení",
|
||||
"description": "Co dělat, když příchozí zpráva žádá o potvrzení o přečtení.",
|
||||
"ask": "Vždy se zeptat",
|
||||
"always": "Vždy odeslat",
|
||||
"never": "Nikdy neodesílat"
|
||||
},
|
||||
"title": "Chování e-mailu",
|
||||
"description": "Nakonfigurujte způsob zpracování e-mailů",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Poznámky",
|
||||
"title": "Složky",
|
||||
"description": "Správa složek pošty a přiřazení standardních rolí",
|
||||
"folder_list": "Vaše složky",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "Akce",
|
||||
"add_action": "Přidat akci",
|
||||
"stop_processing": "Zastavit zpracování dalších pravidel",
|
||||
"attachment_type_placeholder": "např. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Hodnota (více oddělených čárkami)",
|
||||
"condition_fields": {
|
||||
"attachment": "Příloha",
|
||||
"from": "Od",
|
||||
"to": "Komu",
|
||||
"cc": "Kopie",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "Tělo zprávy"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "je přítomna",
|
||||
"has_type": "typu",
|
||||
"contains": "obsahuje",
|
||||
"not_contains": "neobsahuje",
|
||||
"is": "je přesně",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "Správce",
|
||||
"custom": "Vlastní"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "Dne {date} napsal(a) {from}:",
|
||||
"forwarded_separator": "---------- Přeposlaná zpráva ----------",
|
||||
"from_label": "Od",
|
||||
"date_label": "Datum",
|
||||
"subject_label": "Předmět"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "Nainstalovat {appName}",
|
||||
"description": "Nainstalujte si naši aplikaci pro rychlý přístup a offline podporu.",
|
||||
"not_now": "Teď ne",
|
||||
"install": "Nainstalovat",
|
||||
"dont_remind": "Už mi to nepřipomínat",
|
||||
"dismiss_aria": "Zavřít výzvu k instalaci"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "Afsenderen anmoder om en læsekvittering:",
|
||||
"send": "Send kvittering",
|
||||
"ignore": "Ignorér",
|
||||
"sent": "Læsekvittering sendt.",
|
||||
"send_failed": "Læsekvittering kunne ikke sendes",
|
||||
"mdn_subject": "Læst: {subject}",
|
||||
"mdn_body": "Dette er en læsekvittering for den besked, du sendte til {recipient}.\n\nBemærk: Denne kvittering bekræfter kun, at beskeden blev vist på modtagerens computer. Der er ingen garanti for, at modtageren har læst eller forstået indholdet."
|
||||
},
|
||||
"no_email_selected": "Ingen e-mail valgt",
|
||||
"no_email_description": "Vælg en e-mail fra listen for at se den her",
|
||||
"no_conversation_selected": "Ingen samtale valgt",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Fortryd afsendelse"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Læsekvittering anmodet (klik for at deaktivere)",
|
||||
"read_receipt_off": "Anmod om læsekvittering",
|
||||
"new_message": "Ny besked",
|
||||
"reply": "Svar",
|
||||
"reply_all": "Svar alle",
|
||||
@@ -1000,6 +1011,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Anmod om læsekvittering som standard",
|
||||
"description": "Aktivér anmodningen om læsekvittering på forhånd, når du skriver en ny besked."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Svar på anmodninger om læsekvittering",
|
||||
"description": "Hvad der skal ske, når en indgående besked beder om en læsekvittering.",
|
||||
"ask": "Spørg hver gang",
|
||||
"always": "Send altid",
|
||||
"never": "Send aldrig"
|
||||
},
|
||||
"title": "E-mail-adfærd",
|
||||
"description": "Konfigurér hvordan e-mails håndteres",
|
||||
"mark_read": {
|
||||
@@ -1424,6 +1446,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Noter",
|
||||
"title": "Mapper",
|
||||
"description": "Administrér dine e-mail-mapper og tildel standardroller",
|
||||
"folder_list": "Dine mapper",
|
||||
@@ -1626,7 +1649,10 @@
|
||||
"actions": "Handlinger",
|
||||
"add_action": "Tilføj handling",
|
||||
"stop_processing": "Stop behandling af efterfølgende regler",
|
||||
"attachment_type_placeholder": "f.eks. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Værdi (flere adskilt med komma)",
|
||||
"condition_fields": {
|
||||
"attachment": "Vedhæftet fil",
|
||||
"from": "Fra",
|
||||
"to": "Til",
|
||||
"cc": "Cc",
|
||||
@@ -1636,6 +1662,8 @@
|
||||
"body": "Brødtekst"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "er til stede",
|
||||
"has_type": "af typen",
|
||||
"contains": "indeholder",
|
||||
"not_contains": "indeholder ikke",
|
||||
"is": "er præcis",
|
||||
@@ -3046,5 +3074,20 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Søgning er ikke tilgængelig i den samlede visning"
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "Den {date} skrev {from}:",
|
||||
"forwarded_separator": "---------- Videresendt besked ----------",
|
||||
"from_label": "Fra",
|
||||
"date_label": "Dato",
|
||||
"subject_label": "Emne"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "Installer {appName}",
|
||||
"description": "Installer vores app for hurtig adgang og offline-understøttelse.",
|
||||
"not_now": "Ikke nu",
|
||||
"install": "Installer",
|
||||
"dont_remind": "Påmind mig ikke igen",
|
||||
"dismiss_aria": "Afvis installationsprompt"
|
||||
}
|
||||
}
|
||||
|
||||
+48
-7
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "Der Absender bittet um eine Lesebestätigung:",
|
||||
"send": "Bestätigung senden",
|
||||
"ignore": "Ignorieren",
|
||||
"sent": "Lesebestätigung gesendet.",
|
||||
"send_failed": "Lesebestätigung konnte nicht gesendet werden",
|
||||
"mdn_subject": "Gelesen: {subject}",
|
||||
"mdn_body": "Dies ist eine Lesebestätigung für die Nachricht, die Sie an {recipient} gesendet haben.\n\nHinweis: Diese Bestätigung bestätigt lediglich, dass die Nachricht auf dem Computer des Empfängers angezeigt wurde. Es gibt keine Garantie, dass der Empfänger den Inhalt gelesen oder verstanden hat."
|
||||
},
|
||||
"no_email_selected": "Keine E-Mail ausgewählt",
|
||||
"no_email_description": "Wählen Sie eine E-Mail aus der Liste aus, um sie hier anzuzeigen",
|
||||
"no_conversation_selected": "Keine Unterhaltung ausgewählt",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Senden rückgängig"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Lesebestätigung angefordert (klicken zum Deaktivieren)",
|
||||
"read_receipt_off": "Lesebestätigung anfordern",
|
||||
"new_message": "Neue Nachricht",
|
||||
"reply": "Antworten",
|
||||
"reply_all": "Allen antworten",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "Senden",
|
||||
"cancel": "Abbrechen",
|
||||
"attach": "Anhängen",
|
||||
"attach_photos": "Fotos & Videos",
|
||||
"attach_files": "Dateien",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Verwerfen",
|
||||
"discard_draft_title": "Entwurf verwerfen?",
|
||||
"discard_draft_confirm": "Sie haben ungespeicherte Änderungen. Möchten Sie diesen Entwurf verwerfen?",
|
||||
@@ -571,8 +582,8 @@
|
||||
"subject_label": "Betreff:",
|
||||
"file_size_kb": "KB",
|
||||
"prefix": {
|
||||
"forward": "Fwd:",
|
||||
"reply": "Re:"
|
||||
"forward": "WG:",
|
||||
"reply": "AW:"
|
||||
},
|
||||
"no_subject": "(Kein Betreff)",
|
||||
"unknown_sender": "Unbekannt",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "Geplantes Senden wird für dieses Konto nicht unterstützt.",
|
||||
"schedule_send_cleanup_warning": "Geplantes Senden wurde erstellt, aber das Bereinigen des Entwurfs ist fehlgeschlagen.",
|
||||
"send_delay_unsupported": "Sendeverzögerung wird für dieses Konto nicht unterstützt.",
|
||||
"send_delay_unsupported_confirm": "Dieses Konto unterstützt keine Sendeverzögerung. Stattdessen sofort senden?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "Dieses Konto unterstützt keine Sendeverzögerung. Stattdessen sofort senden?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Bestätigen",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Lesebestätigung standardmäßig anfordern",
|
||||
"description": "Beim Verfassen einer neuen Nachricht die Anforderung einer Lesebestätigung vorab aktivieren."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Auf Lesebestätigungs-Anfragen reagieren",
|
||||
"description": "Verhalten, wenn eine eingehende Nachricht um eine Lesebestätigung bittet.",
|
||||
"ask": "Jedes Mal fragen",
|
||||
"always": "Immer senden",
|
||||
"never": "Nie senden"
|
||||
},
|
||||
"title": "E-Mail-Verhalten",
|
||||
"description": "Konfigurieren Sie, wie E-Mails verarbeitet werden",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Notizen",
|
||||
"title": "Ordner",
|
||||
"description": "Verwalten Sie Ihre E-Mail-Ordner und weisen Sie Standardrollen zu",
|
||||
"folder_list": "Ihre Ordner",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "Aktionen",
|
||||
"add_action": "Aktion hinzufügen",
|
||||
"stop_processing": "Verarbeitung nachfolgender Regeln stoppen",
|
||||
"attachment_type_placeholder": "z.B. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Wert (mehrere mit Komma trennen)",
|
||||
"condition_fields": {
|
||||
"attachment": "Anhang",
|
||||
"from": "Von",
|
||||
"to": "An",
|
||||
"cc": "Cc",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "Nachrichtentext"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "vorhanden",
|
||||
"has_type": "vom Typ",
|
||||
"contains": "enthält",
|
||||
"not_contains": "enthält nicht",
|
||||
"is": "ist genau",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "Verwalten",
|
||||
"custom": "Benutzerdefiniert"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "Am {date} schrieb {from}:",
|
||||
"forwarded_separator": "---------- Weitergeleitete Nachricht ----------",
|
||||
"from_label": "Von",
|
||||
"date_label": "Datum",
|
||||
"subject_label": "Betreff"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "{appName} installieren",
|
||||
"description": "Installiere unsere App für schnellen Zugriff und Offline-Unterstützung.",
|
||||
"not_now": "Nicht jetzt",
|
||||
"install": "Installieren",
|
||||
"dont_remind": "Nicht mehr erinnern",
|
||||
"dismiss_aria": "Installationshinweis schließen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "The sender requested a read receipt:",
|
||||
"send": "Send receipt",
|
||||
"ignore": "Ignore",
|
||||
"sent": "Read receipt sent.",
|
||||
"send_failed": "Read receipt could not be sent",
|
||||
"mdn_subject": "Read: {subject}",
|
||||
"mdn_body": "This is a return receipt for the message you sent to {recipient}.\n\nNote: This receipt only acknowledges that the message was displayed on the recipient''s computer. There is no guarantee that the recipient has read or understood the message contents."
|
||||
},
|
||||
"no_email_selected": "No email selected",
|
||||
"no_email_description": "Select an email from the list to view it here",
|
||||
"no_conversation_selected": "No conversation selected",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Undo send"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Read receipt requested (click to disable)",
|
||||
"read_receipt_off": "Request a read receipt",
|
||||
"new_message": "New Message",
|
||||
"reply": "Reply",
|
||||
"reply_all": "Reply All",
|
||||
@@ -1000,6 +1011,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Request read receipts by default",
|
||||
"description": "Pre-enable the read-receipt request when composing a new message."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Respond to read-receipt requests",
|
||||
"description": "What to do when an incoming message asks for a read receipt.",
|
||||
"ask": "Ask each time",
|
||||
"always": "Always send",
|
||||
"never": "Never send"
|
||||
},
|
||||
"title": "Email Behavior",
|
||||
"description": "Configure how emails are handled",
|
||||
"mark_read": {
|
||||
@@ -1424,6 +1446,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Notes",
|
||||
"title": "Folders",
|
||||
"description": "Manage your email folders and assign standard roles",
|
||||
"folder_list": "Your Folders",
|
||||
@@ -1626,7 +1649,10 @@
|
||||
"actions": "Actions",
|
||||
"add_action": "Add Action",
|
||||
"stop_processing": "Stop processing subsequent rules",
|
||||
"attachment_type_placeholder": "e.g. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Value (multiple separated by commas)",
|
||||
"condition_fields": {
|
||||
"attachment": "Attachment",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"cc": "Cc",
|
||||
@@ -1636,6 +1662,8 @@
|
||||
"body": "Body"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "is present",
|
||||
"has_type": "of type",
|
||||
"contains": "contains",
|
||||
"not_contains": "does not contain",
|
||||
"is": "is exactly",
|
||||
@@ -3046,5 +3074,20 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Search is not available in the unified view"
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "On {date}, {from} wrote:",
|
||||
"forwarded_separator": "---------- Forwarded message ----------",
|
||||
"from_label": "From",
|
||||
"date_label": "Date",
|
||||
"subject_label": "Subject"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "Install {appName}",
|
||||
"description": "Install our app for quick access and offline support.",
|
||||
"not_now": "Not now",
|
||||
"install": "Install",
|
||||
"dont_remind": "Don't remind me again",
|
||||
"dismiss_aria": "Dismiss install prompt"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "El remitente solicita una confirmación de lectura:",
|
||||
"send": "Enviar confirmación",
|
||||
"ignore": "Ignorar",
|
||||
"sent": "Confirmación de lectura enviada.",
|
||||
"send_failed": "No se pudo enviar la confirmación de lectura",
|
||||
"mdn_subject": "Leído: {subject}",
|
||||
"mdn_body": "Este es un acuse de recibo del mensaje que enviaste a {recipient}.\n\nNota: Este acuse solo confirma que el mensaje se mostró en el ordenador del destinatario. No garantiza que el destinatario haya leído o entendido el contenido."
|
||||
},
|
||||
"no_email_selected": "Ningún correo seleccionado",
|
||||
"no_email_description": "Seleccione un correo de la lista para verlo aquí",
|
||||
"no_conversation_selected": "Ninguna conversación seleccionada",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Deshacer envío"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Confirmación de lectura solicitada (haz clic para desactivar)",
|
||||
"read_receipt_off": "Solicitar confirmación de lectura",
|
||||
"new_message": "Nuevo Mensaje",
|
||||
"reply": "Responder",
|
||||
"reply_all": "Responder a Todos",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "Enviar",
|
||||
"cancel": "Cancelar",
|
||||
"attach": "Adjuntar",
|
||||
"attach_photos": "Fotos y videos",
|
||||
"attach_files": "Archivos",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Descartar",
|
||||
"discard_draft_title": "¿Descartar borrador?",
|
||||
"discard_draft_confirm": "Tiene cambios sin guardar. ¿Desea descartar este borrador?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "El envío programado no es compatible con esta cuenta.",
|
||||
"schedule_send_cleanup_warning": "Se creó el envío programado, pero falló la limpieza del borrador.",
|
||||
"send_delay_unsupported": "La demora de envío no es compatible con esta cuenta.",
|
||||
"send_delay_unsupported_confirm": "Esta cuenta no admite demora de envío. ¿Enviar inmediatamente?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "Esta cuenta no admite demora de envío. ¿Enviar inmediatamente?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmar",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Solicitar confirmaciones de lectura de forma predeterminada",
|
||||
"description": "Activar previamente la solicitud de confirmación de lectura al redactar un mensaje nuevo."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Responder a las solicitudes de confirmación de lectura",
|
||||
"description": "Qué hacer cuando un mensaje entrante solicita una confirmación de lectura.",
|
||||
"ask": "Preguntar cada vez",
|
||||
"always": "Enviar siempre",
|
||||
"never": "No enviar nunca"
|
||||
},
|
||||
"title": "Comportamiento del Correo",
|
||||
"description": "Configure cómo se manejan los correos",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Notas",
|
||||
"title": "Carpetas",
|
||||
"description": "Administre sus carpetas de correo y asigne roles estándar",
|
||||
"folder_list": "Sus carpetas",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "Acciones",
|
||||
"add_action": "Agregar acción",
|
||||
"stop_processing": "Detener el procesamiento de reglas posteriores",
|
||||
"attachment_type_placeholder": "p. ej. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Valor (varios separados por comas)",
|
||||
"condition_fields": {
|
||||
"attachment": "Adjunto",
|
||||
"from": "De",
|
||||
"to": "Para",
|
||||
"cc": "Cc",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "Cuerpo"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "está presente",
|
||||
"has_type": "del tipo",
|
||||
"contains": "contiene",
|
||||
"not_contains": "no contiene",
|
||||
"is": "es exactamente",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "Administrador",
|
||||
"custom": "Personalizado"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "El {date}, {from} escribió:",
|
||||
"forwarded_separator": "---------- Mensaje reenviado ----------",
|
||||
"from_label": "De",
|
||||
"date_label": "Fecha",
|
||||
"subject_label": "Asunto"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "Instalar {appName}",
|
||||
"description": "Instala nuestra app para un acceso rápido y soporte sin conexión.",
|
||||
"not_now": "Ahora no",
|
||||
"install": "Instalar",
|
||||
"dont_remind": "No volver a recordármelo",
|
||||
"dismiss_aria": "Cerrar aviso de instalación"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Saisissez une nouvelle date/heure, p. ex. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "L''expéditeur demande un accusé de lecture :",
|
||||
"send": "Envoyer l''accusé",
|
||||
"ignore": "Ignorer",
|
||||
"sent": "Accusé de lecture envoyé.",
|
||||
"send_failed": "Impossible d''envoyer l''accusé de lecture",
|
||||
"mdn_subject": "Lu : {subject}",
|
||||
"mdn_body": "Ceci est un accusé de réception du message que vous avez envoyé à {recipient}.\n\nRemarque : cet accusé confirme uniquement que le message a été affiché sur l''ordinateur du destinataire. Il ne garantit pas que le destinataire a lu ou compris le contenu."
|
||||
},
|
||||
"no_email_selected": "Aucun email sélectionné",
|
||||
"no_email_description": "Sélectionnez un email dans la liste pour le voir ici",
|
||||
"no_conversation_selected": "Aucune conversation sélectionnée",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Annuler l’envoi"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Accusé de lecture demandé (cliquez pour désactiver)",
|
||||
"read_receipt_off": "Demander un accusé de lecture",
|
||||
"new_message": "Nouveau message",
|
||||
"reply": "Répondre",
|
||||
"reply_all": "Répondre à tous",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "Envoyer",
|
||||
"cancel": "Annuler",
|
||||
"attach": "Joindre",
|
||||
"attach_photos": "Photos et vidéos",
|
||||
"attach_files": "Fichiers",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Supprimer",
|
||||
"discard_draft_title": "Supprimer le brouillon ?",
|
||||
"discard_draft_confirm": "Vous avez des modifications non enregistrées. Voulez-vous supprimer ce brouillon ?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "L’envoi planifié n’est pas pris en charge pour ce compte.",
|
||||
"schedule_send_cleanup_warning": "L’envoi planifié a été créé, mais le nettoyage du brouillon a échoué.",
|
||||
"send_delay_unsupported": "Le délai d’envoi n’est pas pris en charge pour ce compte.",
|
||||
"send_delay_unsupported_confirm": "Ce compte ne prend pas en charge le délai d’envoi. Envoyer immédiatement ?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "Ce compte ne prend pas en charge le délai d’envoi. Envoyer immédiatement ?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmer",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Demander un accusé de lecture par défaut",
|
||||
"description": "Activer au préalable la demande d''accusé de lecture lors de la rédaction d''un nouveau message."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Répondre aux demandes d''accusé de lecture",
|
||||
"description": "Que faire lorsqu''un message entrant demande un accusé de lecture.",
|
||||
"ask": "Demander à chaque fois",
|
||||
"always": "Toujours envoyer",
|
||||
"never": "Ne jamais envoyer"
|
||||
},
|
||||
"title": "Comportement email",
|
||||
"description": "Configurez la gestion des emails",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Notes",
|
||||
"title": "Dossiers",
|
||||
"description": "Gérez vos dossiers de messagerie et attribuez des rôles standard",
|
||||
"folder_list": "Vos dossiers",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "Actions",
|
||||
"add_action": "Ajouter une action",
|
||||
"stop_processing": "Arrêter le traitement des règles suivantes",
|
||||
"attachment_type_placeholder": "p. ex. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Valeur (plusieurs séparées par des virgules)",
|
||||
"condition_fields": {
|
||||
"attachment": "Pièce jointe",
|
||||
"from": "De",
|
||||
"to": "À",
|
||||
"cc": "Cc",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "Corps"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "est présent",
|
||||
"has_type": "de type",
|
||||
"contains": "contient",
|
||||
"not_contains": "ne contient pas",
|
||||
"is": "est exactement",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "Gestionnaire",
|
||||
"custom": "Personnalisé"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "Le {date}, {from} a écrit :",
|
||||
"forwarded_separator": "---------- Message transféré ----------",
|
||||
"from_label": "De",
|
||||
"date_label": "Date",
|
||||
"subject_label": "Sujet"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "Installer {appName}",
|
||||
"description": "Installez notre application pour un accès rapide et un support hors ligne.",
|
||||
"not_now": "Pas maintenant",
|
||||
"install": "Installer",
|
||||
"dont_remind": "Ne plus me le rappeler",
|
||||
"dismiss_aria": "Fermer l'invite d'installation"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Inserisci una nuova data/ora, ad es. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "Il mittente richiede una conferma di lettura:",
|
||||
"send": "Invia conferma",
|
||||
"ignore": "Ignora",
|
||||
"sent": "Conferma di lettura inviata.",
|
||||
"send_failed": "Impossibile inviare la conferma di lettura",
|
||||
"mdn_subject": "Letto: {subject}",
|
||||
"mdn_body": "Questa è una conferma di lettura del messaggio che hai inviato a {recipient}.\n\nNota: questa conferma attesta solo che il messaggio è stato visualizzato sul computer del destinatario. Non garantisce che il destinatario abbia letto o compreso il contenuto."
|
||||
},
|
||||
"no_email_selected": "Nessun messaggio selezionato",
|
||||
"no_email_description": "Seleziona un messaggio dall'elenco per visualizzarlo qui",
|
||||
"no_conversation_selected": "Nessuna conversazione selezionata",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Annulla invio"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Conferma di lettura richiesta (clicca per disattivare)",
|
||||
"read_receipt_off": "Richiedi una conferma di lettura",
|
||||
"new_message": "Nuovo messaggio",
|
||||
"reply": "Rispondi",
|
||||
"reply_all": "Rispondi a tutti",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "Invia",
|
||||
"cancel": "Annulla",
|
||||
"attach": "Allega",
|
||||
"attach_photos": "Foto e video",
|
||||
"attach_files": "File",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Scarta",
|
||||
"discard_draft_title": "Eliminare la bozza?",
|
||||
"discard_draft_confirm": "Ci sono modifiche non salvate. Vuoi scartare questa bozza?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "L’invio programmato non è supportato per questo account.",
|
||||
"schedule_send_cleanup_warning": "L’invio programmato è stato creato, ma la pulizia della bozza non è riuscita.",
|
||||
"send_delay_unsupported": "Il ritardo di invio non è supportato per questo account.",
|
||||
"send_delay_unsupported_confirm": "Questo account non supporta il ritardo di invio. Inviare subito?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "Questo account non supporta il ritardo di invio. Inviare subito?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Conferma",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Richiedi conferme di lettura per impostazione predefinita",
|
||||
"description": "Attiva in anticipo la richiesta di conferma di lettura durante la composizione di un nuovo messaggio."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Rispondi alle richieste di conferma di lettura",
|
||||
"description": "Cosa fare quando un messaggio in arrivo richiede una conferma di lettura.",
|
||||
"ask": "Chiedi ogni volta",
|
||||
"always": "Invia sempre",
|
||||
"never": "Non inviare mai"
|
||||
},
|
||||
"title": "Comportamento email",
|
||||
"description": "Configura come vengono gestiti i messaggi",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Note",
|
||||
"title": "Cartelle",
|
||||
"description": "Gestisci le cartelle e-mail e assegna ruoli standard",
|
||||
"folder_list": "Le tue cartelle",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "Azioni",
|
||||
"add_action": "Aggiungi azione",
|
||||
"stop_processing": "Interrompere l'elaborazione delle regole successive",
|
||||
"attachment_type_placeholder": "es. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Valore (più valori separati da virgole)",
|
||||
"condition_fields": {
|
||||
"attachment": "Allegato",
|
||||
"from": "Da",
|
||||
"to": "A",
|
||||
"cc": "Cc",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "Corpo"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "è presente",
|
||||
"has_type": "di tipo",
|
||||
"contains": "contiene",
|
||||
"not_contains": "non contiene",
|
||||
"is": "è esattamente",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "Gestore",
|
||||
"custom": "Personalizzato"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "Il {date}, {from} ha scritto:",
|
||||
"forwarded_separator": "---------- Messaggio inoltrato ----------",
|
||||
"from_label": "Da",
|
||||
"date_label": "Data",
|
||||
"subject_label": "Oggetto"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "Installa {appName}",
|
||||
"description": "Installa la nostra app per un accesso rapido e il supporto offline.",
|
||||
"not_now": "Non ora",
|
||||
"install": "Installa",
|
||||
"dont_remind": "Non ricordarmelo più",
|
||||
"dismiss_aria": "Chiudi avviso di installazione"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "送信者が開封確認を求めています:",
|
||||
"send": "確認を送信",
|
||||
"ignore": "無視",
|
||||
"sent": "開封確認を送信しました。",
|
||||
"send_failed": "開封確認を送信できませんでした",
|
||||
"mdn_subject": "開封済み: {subject}",
|
||||
"mdn_body": "これは、あなたが {recipient} に送信したメッセージの開封確認です。\n\n注意: この確認は、メッセージが受信者のコンピューターに表示されたことを示すだけです。受信者が内容を読んだ、または理解したことを保証するものではありません。"
|
||||
},
|
||||
"no_email_selected": "メールが選択されていません",
|
||||
"no_email_description": "リストからメールを選択して表示してください",
|
||||
"no_conversation_selected": "会話が選択されていません",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "送信を取り消す"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "開封確認を要求中(クリックで無効化)",
|
||||
"read_receipt_off": "開封確認を要求する",
|
||||
"new_message": "新規メッセージ",
|
||||
"reply": "返信",
|
||||
"reply_all": "全員に返信",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "送信",
|
||||
"cancel": "キャンセル",
|
||||
"attach": "添付",
|
||||
"attach_photos": "写真と動画",
|
||||
"attach_files": "ファイル",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "破棄",
|
||||
"discard_draft_title": "下書きを破棄しますか?",
|
||||
"discard_draft_confirm": "未保存の変更があります。この下書きを破棄しますか?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "このアカウントでは予約送信はサポートされていません。",
|
||||
"schedule_send_cleanup_warning": "予約送信は作成されましたが、下書きのクリーンアップに失敗しました。",
|
||||
"send_delay_unsupported": "このアカウントでは送信遅延はサポートされていません。",
|
||||
"send_delay_unsupported_confirm": "このアカウントは送信遅延に対応していません。すぐに送信しますか?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "このアカウントは送信遅延に対応していません。すぐに送信しますか?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "確認",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "既定で開封確認を要求する",
|
||||
"description": "新しいメッセージの作成時に、開封確認の要求をあらかじめ有効にします。"
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "開封確認の要求に応答する",
|
||||
"description": "受信メッセージが開封確認を求めたときの動作。",
|
||||
"ask": "毎回確認する",
|
||||
"always": "常に送信",
|
||||
"never": "送信しない"
|
||||
},
|
||||
"title": "メール動作",
|
||||
"description": "メールの処理方法を設定",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "メモ",
|
||||
"title": "フォルダー",
|
||||
"description": "メールフォルダーを管理し、標準の役割を割り当てます",
|
||||
"folder_list": "フォルダー一覧",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "アクション",
|
||||
"add_action": "アクションを追加",
|
||||
"stop_processing": "以降のルールの処理を停止",
|
||||
"attachment_type_placeholder": "例: pdf, doc, jpg",
|
||||
"value_placeholder_multi": "値(カンマで複数指定可)",
|
||||
"condition_fields": {
|
||||
"attachment": "添付ファイル",
|
||||
"from": "差出人",
|
||||
"to": "宛先",
|
||||
"cc": "Cc",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "本文"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "あり",
|
||||
"has_type": "タイプ",
|
||||
"contains": "を含む",
|
||||
"not_contains": "を含まない",
|
||||
"is": "と完全一致",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "管理者",
|
||||
"custom": "カスタム"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "{date}に{from}が書きました:",
|
||||
"forwarded_separator": "---------- 転送メッセージ ----------",
|
||||
"from_label": "差出人",
|
||||
"date_label": "日付",
|
||||
"subject_label": "件名"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "{appName} をインストール",
|
||||
"description": "素早いアクセスとオフライン対応のため、アプリをインストールしましょう。",
|
||||
"not_now": "後で",
|
||||
"install": "インストール",
|
||||
"dont_remind": "今後表示しない",
|
||||
"dismiss_aria": "インストールプロンプトを閉じる"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "새 날짜/시간을 입력하세요. 예: 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "보낸 사람이 읽음 확인을 요청합니다:",
|
||||
"send": "확인 보내기",
|
||||
"ignore": "무시",
|
||||
"sent": "읽음 확인을 보냈습니다.",
|
||||
"send_failed": "읽음 확인을 보낼 수 없습니다",
|
||||
"mdn_subject": "읽음: {subject}",
|
||||
"mdn_body": "이것은 {recipient}(으)로 보낸 메시지에 대한 읽음 확인입니다.\n\n참고: 이 확인은 메시지가 수신자의 컴퓨터에 표시되었음을 알릴 뿐입니다. 수신자가 내용을 읽거나 이해했다는 보장은 없습니다."
|
||||
},
|
||||
"no_email_selected": "메일이 선택되지 않았어요",
|
||||
"no_email_description": "목록에서 메일을 선택하면 여기에 내용이 표시돼요",
|
||||
"no_conversation_selected": "대화가 선택되지 않았어요",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "보내기 실행 취소"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "읽음 확인 요청됨 (클릭하여 해제)",
|
||||
"read_receipt_off": "읽음 확인 요청",
|
||||
"new_message": "새 메시지",
|
||||
"reply": "답장",
|
||||
"reply_all": "전체 답장",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "보내기",
|
||||
"cancel": "취소",
|
||||
"attach": "첨부",
|
||||
"attach_photos": "사진 및 동영상",
|
||||
"attach_files": "파일",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "삭제",
|
||||
"discard_draft_title": "임시보관 메일을 삭제할까요?",
|
||||
"discard_draft_confirm": "저장되지 않은 내용이 있어요. 이 메일을 삭제할까요?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "이 계정은 예약 보내기를 지원하지 않습니다.",
|
||||
"schedule_send_cleanup_warning": "예약 보내기는 생성되었지만 초안 정리에 실패했습니다.",
|
||||
"send_delay_unsupported": "이 계정은 보내기 지연을 지원하지 않습니다.",
|
||||
"send_delay_unsupported_confirm": "이 계정은 보내기 지연을 지원하지 않습니다. 바로 보낼까요?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "이 계정은 보내기 지연을 지원하지 않습니다. 바로 보낼까요?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "확인",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "기본적으로 읽음 확인 요청",
|
||||
"description": "새 메시지를 작성할 때 읽음 확인 요청을 미리 켭니다."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "읽음 확인 요청에 응답",
|
||||
"description": "수신 메시지가 읽음 확인을 요청할 때의 동작.",
|
||||
"ask": "매번 묻기",
|
||||
"always": "항상 보내기",
|
||||
"never": "보내지 않음"
|
||||
},
|
||||
"title": "메일 동작",
|
||||
"description": "이메일 관련 동작 방식을 설정해 주세요",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "메모",
|
||||
"title": "폴더",
|
||||
"description": "이메일 폴더를 관리하고 기본 역할을 지정해 보세요",
|
||||
"folder_list": "내 폴더",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "동작",
|
||||
"add_action": "동작 추가",
|
||||
"stop_processing": "이후 규칙 무시하기",
|
||||
"attachment_type_placeholder": "예: pdf, doc, jpg",
|
||||
"value_placeholder_multi": "값 (쉼표로 여러 개 구분)",
|
||||
"condition_fields": {
|
||||
"attachment": "첨부 파일",
|
||||
"from": "보낸 사람",
|
||||
"to": "받는 사람",
|
||||
"cc": "참조",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "본문"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "있음",
|
||||
"has_type": "유형",
|
||||
"contains": "포함함",
|
||||
"not_contains": "포함하지 않음",
|
||||
"is": "정확히 일치",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "관리자",
|
||||
"custom": "사용자 지정"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "{date}에 {from}님이 작성:",
|
||||
"forwarded_separator": "---------- 전달된 메시지 ----------",
|
||||
"from_label": "보낸 사람",
|
||||
"date_label": "날짜",
|
||||
"subject_label": "제목"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "{appName} 설치",
|
||||
"description": "빠른 액세스와 오프라인 지원을 위해 앱을 설치하세요.",
|
||||
"not_now": "나중에",
|
||||
"install": "설치",
|
||||
"dont_remind": "다시 알리지 않음",
|
||||
"dismiss_aria": "설치 프롬프트 닫기"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Ievadiet jaunu datumu/laiku, piem. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "Sūtītājs pieprasa lasīšanas apstiprinājumu:",
|
||||
"send": "Sūtīt apstiprinājumu",
|
||||
"ignore": "Ignorēt",
|
||||
"sent": "Lasīšanas apstiprinājums nosūtīts.",
|
||||
"send_failed": "Neizdevās nosūtīt lasīšanas apstiprinājumu",
|
||||
"mdn_subject": "Izlasīts: {subject}",
|
||||
"mdn_body": "Šis ir lasīšanas apstiprinājums ziņojumam, ko nosūtījāt uz {recipient}.\n\nPiezīme: šis apstiprinājums tikai apliecina, ka ziņojums tika parādīts saņēmēja datorā. Tas negarantē, ka saņēmējs ir izlasījis vai sapratis saturu."
|
||||
},
|
||||
"no_email_selected": "Nav atlasīta neviena vēstule",
|
||||
"no_email_description": "Atlasiet vēstuli no saraksta, lai to skatītu šeit",
|
||||
"no_conversation_selected": "Nav atlasīta saruna",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Atsaukt sūtīšanu"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Pieprasīts lasīšanas apstiprinājums (noklikšķiniet, lai atspējotu)",
|
||||
"read_receipt_off": "Pieprasīt lasīšanas apstiprinājumu",
|
||||
"new_message": "Jauns ziņojums",
|
||||
"reply": "Atbildēt",
|
||||
"reply_all": "Atbildēt visiem",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "Sūtīt",
|
||||
"cancel": "Atcelt",
|
||||
"attach": "Pievienot",
|
||||
"attach_photos": "Fotoattēli un video",
|
||||
"attach_files": "Faili",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Atmest",
|
||||
"discard_draft_title": "Atmest melnrakstu?",
|
||||
"discard_draft_confirm": "Ir nesaglabātas izmaiņas. Vai tiešām vēlaties atmest šo melnrakstu?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "Šim kontam ieplānota sūtīšana netiek atbalstīta.",
|
||||
"schedule_send_cleanup_warning": "Ieplānotā sūtīšana tika izveidota, bet melnraksta tīrīšana neizdevās.",
|
||||
"send_delay_unsupported": "Šim kontam sūtīšanas aizture netiek atbalstīta.",
|
||||
"send_delay_unsupported_confirm": "Šis konts neatbalsta sūtīšanas aizturi. Sūtīt uzreiz?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "Šis konts neatbalsta sūtīšanas aizturi. Sūtīt uzreiz?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Apstiprināt",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Pēc noklusējuma pieprasīt lasīšanas apstiprinājumus",
|
||||
"description": "Sastādot jaunu ziņojumu, iepriekš ieslēgt lasīšanas apstiprinājuma pieprasījumu."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Atbildēt uz lasīšanas apstiprinājuma pieprasījumiem",
|
||||
"description": "Ko darīt, kad ienākošs ziņojums pieprasa lasīšanas apstiprinājumu.",
|
||||
"ask": "Vaicāt katru reizi",
|
||||
"always": "Vienmēr sūtīt",
|
||||
"never": "Nekad nesūtīt"
|
||||
},
|
||||
"title": "Pasta darbība",
|
||||
"description": "Pielāgojiet vēstuļu apstrādi",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Piezīmes",
|
||||
"title": "Mapes",
|
||||
"description": "Pārvaldiet pasta mapes un piešķiriet tām lomas",
|
||||
"folder_list": "Jūsu mapes",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "Darbības",
|
||||
"add_action": "Pievienot darbību",
|
||||
"stop_processing": "Pārtraukt nākamo noteikumu apstrādi",
|
||||
"attachment_type_placeholder": "piem. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Vērtība (vairākas atdalītas ar komatu)",
|
||||
"condition_fields": {
|
||||
"attachment": "Pielikums",
|
||||
"from": "No",
|
||||
"to": "Kam",
|
||||
"cc": "Kopija",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "Teksts"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "ir klāt",
|
||||
"has_type": "tipa",
|
||||
"contains": "satur",
|
||||
"not_contains": "nesatur",
|
||||
"is": "precīzi sakrīt",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "Pārvaldnieks",
|
||||
"custom": "Pielāgots"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "{date} {from} rakstīja:",
|
||||
"forwarded_separator": "---------- Pārsūtītā ziņa ----------",
|
||||
"from_label": "No",
|
||||
"date_label": "Datums",
|
||||
"subject_label": "Tēma"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "Instalēt {appName}",
|
||||
"description": "Instalējiet mūsu lietotni ātrai piekļuvei un bezsaistes atbalstam.",
|
||||
"not_now": "Ne tagad",
|
||||
"install": "Instalēt",
|
||||
"dont_remind": "Vairs man neatgādināt",
|
||||
"dismiss_aria": "Aizvērt instalēšanas paziņojumu"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Voer een nieuwe datum/tijd in, bijv. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "De afzender vraagt om een leesbevestiging:",
|
||||
"send": "Bevestiging verzenden",
|
||||
"ignore": "Negeren",
|
||||
"sent": "Leesbevestiging verzonden.",
|
||||
"send_failed": "Leesbevestiging kon niet worden verzonden",
|
||||
"mdn_subject": "Gelezen: {subject}",
|
||||
"mdn_body": "Dit is een leesbevestiging voor het bericht dat u hebt verzonden naar {recipient}.\n\nLet op: deze bevestiging geeft alleen aan dat het bericht is weergegeven op de computer van de ontvanger. Er is geen garantie dat de ontvanger de inhoud heeft gelezen of begrepen."
|
||||
},
|
||||
"no_email_selected": "Geen e-mail geselecteerd",
|
||||
"no_email_description": "Selecteer een e-mail uit de lijst om deze hier te bekijken",
|
||||
"no_conversation_selected": "Geen gesprek geselecteerd",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Verzenden ongedaan maken"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Leesbevestiging aangevraagd (klik om uit te schakelen)",
|
||||
"read_receipt_off": "Leesbevestiging aanvragen",
|
||||
"new_message": "Nieuw bericht",
|
||||
"reply": "Beantwoorden",
|
||||
"reply_all": "Allen beantwoorden",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "Verzenden",
|
||||
"cancel": "Annuleren",
|
||||
"attach": "Bijlage toevoegen",
|
||||
"attach_photos": "Foto's en video's",
|
||||
"attach_files": "Bestanden",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Verwijderen",
|
||||
"discard_draft_title": "Concept verwijderen?",
|
||||
"discard_draft_confirm": "Je hebt niet-opgeslagen wijzigingen. Wil je dit concept verwijderen?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "Gepland verzenden wordt niet ondersteund voor dit account.",
|
||||
"schedule_send_cleanup_warning": "Gepland verzenden is aangemaakt, maar het opschonen van de conceptversie is mislukt.",
|
||||
"send_delay_unsupported": "Verzendvertraging wordt niet ondersteund voor dit account.",
|
||||
"send_delay_unsupported_confirm": "Dit account ondersteunt geen verzendvertraging. Meteen verzenden?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "Dit account ondersteunt geen verzendvertraging. Meteen verzenden?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Bevestigen",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Standaard om leesbevestiging vragen",
|
||||
"description": "De aanvraag voor een leesbevestiging vooraf inschakelen bij het opstellen van een nieuw bericht."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Reageren op verzoeken om leesbevestiging",
|
||||
"description": "Wat te doen wanneer een inkomend bericht om een leesbevestiging vraagt.",
|
||||
"ask": "Elke keer vragen",
|
||||
"always": "Altijd verzenden",
|
||||
"never": "Nooit verzenden"
|
||||
},
|
||||
"title": "E-mailgedrag",
|
||||
"description": "Configureer hoe e-mails worden verwerkt",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Notities",
|
||||
"title": "Mappen",
|
||||
"description": "Beheer uw e-mailmappen en wijs standaardrollen toe",
|
||||
"folder_list": "Uw mappen",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "Acties",
|
||||
"add_action": "Actie toevoegen",
|
||||
"stop_processing": "Verwerking van volgende regels stoppen",
|
||||
"attachment_type_placeholder": "bijv. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Waarde (meerdere met komma's gescheiden)",
|
||||
"condition_fields": {
|
||||
"attachment": "Bijlage",
|
||||
"from": "Van",
|
||||
"to": "Aan",
|
||||
"cc": "Cc",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "Inhoud"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "aanwezig",
|
||||
"has_type": "van type",
|
||||
"contains": "bevat",
|
||||
"not_contains": "bevat niet",
|
||||
"is": "is precies",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "Beheerder",
|
||||
"custom": "Aangepast"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "Op {date} schreef {from}:",
|
||||
"forwarded_separator": "---------- Doorgestuurd bericht ----------",
|
||||
"from_label": "Van",
|
||||
"date_label": "Datum",
|
||||
"subject_label": "Onderwerp"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "{appName} installeren",
|
||||
"description": "Installeer onze app voor snelle toegang en offline-ondersteuning.",
|
||||
"not_now": "Niet nu",
|
||||
"install": "Installeren",
|
||||
"dont_remind": "Niet meer herinneren",
|
||||
"dismiss_aria": "Installatiemelding sluiten"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Wprowadź nową datę/godzinę, np. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "Nadawca prosi o potwierdzenie przeczytania:",
|
||||
"send": "Wyślij potwierdzenie",
|
||||
"ignore": "Ignoruj",
|
||||
"sent": "Wysłano potwierdzenie przeczytania.",
|
||||
"send_failed": "Nie udało się wysłać potwierdzenia przeczytania",
|
||||
"mdn_subject": "Przeczytano: {subject}",
|
||||
"mdn_body": "To jest potwierdzenie przeczytania wiadomości wysłanej do {recipient}.\n\nUwaga: to potwierdzenie oznacza jedynie, że wiadomość została wyświetlona na komputerze odbiorcy. Nie gwarantuje, że odbiorca przeczytał lub zrozumiał treść."
|
||||
},
|
||||
"no_email_selected": "Nie wybrano wiadomości",
|
||||
"no_email_description": "Wybierz wiadomość z listy, aby ją wyświetlić",
|
||||
"no_conversation_selected": "Nie wybrano konwersacji",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Cofnij wysyłkę"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Zażądano potwierdzenia przeczytania (kliknij, aby wyłączyć)",
|
||||
"read_receipt_off": "Zażądaj potwierdzenia przeczytania",
|
||||
"new_message": "Nowa wiadomość",
|
||||
"reply": "Odpowiedz",
|
||||
"reply_all": "Odpowiedz wszystkim",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "Wyślij",
|
||||
"cancel": "Anuluj",
|
||||
"attach": "Załącz",
|
||||
"attach_photos": "Zdjęcia i filmy",
|
||||
"attach_files": "Pliki",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Odrzuć",
|
||||
"discard_draft_title": "Odrzucić szkic?",
|
||||
"discard_draft_confirm": "Masz niezapisane zmiany. Czy chcesz odrzucić ten szkic?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "Zaplanowana wysyłka nie jest obsługiwana dla tego konta.",
|
||||
"schedule_send_cleanup_warning": "Zaplanowana wysyłka została utworzona, ale czyszczenie wersji roboczej nie powiodło się.",
|
||||
"send_delay_unsupported": "Opóźnienie wysyłki nie jest obsługiwane dla tego konta.",
|
||||
"send_delay_unsupported_confirm": "To konto nie obsługuje opóźnienia wysyłki. Wysłać natychmiast?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "To konto nie obsługuje opóźnienia wysyłki. Wysłać natychmiast?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Potwierdź",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Domyślnie żądaj potwierdzeń przeczytania",
|
||||
"description": "Włącz wcześniej żądanie potwierdzenia przeczytania podczas tworzenia nowej wiadomości."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Odpowiadaj na żądania potwierdzenia przeczytania",
|
||||
"description": "Co zrobić, gdy przychodząca wiadomość prosi o potwierdzenie przeczytania.",
|
||||
"ask": "Pytaj za każdym razem",
|
||||
"always": "Zawsze wysyłaj",
|
||||
"never": "Nigdy nie wysyłaj"
|
||||
},
|
||||
"title": "Zachowanie poczty e-mail",
|
||||
"description": "Skonfiguruj sposób obsługi wiadomości e-mail",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Notatki",
|
||||
"title": "Foldery",
|
||||
"description": "Zarządzaj folderami poczty i przypisuj standardowe role",
|
||||
"folder_list": "Twoje foldery",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "Akcje",
|
||||
"add_action": "Dodaj akcję",
|
||||
"stop_processing": "Zatrzymaj przetwarzanie kolejnych reguł",
|
||||
"attachment_type_placeholder": "np. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Wartość (kilka oddzielonych przecinkami)",
|
||||
"condition_fields": {
|
||||
"attachment": "Załącznik",
|
||||
"from": "Od",
|
||||
"to": "Do",
|
||||
"cc": "DW",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "Treść"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "jest obecny",
|
||||
"has_type": "typu",
|
||||
"contains": "zawiera",
|
||||
"not_contains": "nie zawiera",
|
||||
"is": "jest dokładnie",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "Menedżer",
|
||||
"custom": "Niestandardowe"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "{date}, {from} napisał(a):",
|
||||
"forwarded_separator": "---------- Wiadomość przekazana ----------",
|
||||
"from_label": "Od",
|
||||
"date_label": "Data",
|
||||
"subject_label": "Temat"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "Zainstaluj {appName}",
|
||||
"description": "Zainstaluj naszą aplikację, aby uzyskać szybki dostęp i obsługę offline.",
|
||||
"not_now": "Nie teraz",
|
||||
"install": "Zainstaluj",
|
||||
"dont_remind": "Nie przypominaj mi więcej",
|
||||
"dismiss_aria": "Zamknij monit instalacji"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Informe uma nova data/hora, ex. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "O remetente solicita uma confirmação de leitura:",
|
||||
"send": "Enviar confirmação",
|
||||
"ignore": "Ignorar",
|
||||
"sent": "Confirmação de leitura enviada.",
|
||||
"send_failed": "Não foi possível enviar a confirmação de leitura",
|
||||
"mdn_subject": "Lido: {subject}",
|
||||
"mdn_body": "Este é um aviso de leitura da mensagem que você enviou para {recipient}.\n\nObservação: este aviso apenas confirma que a mensagem foi exibida no computador do destinatário. Não há garantia de que o destinatário tenha lido ou compreendido o conteúdo."
|
||||
},
|
||||
"no_email_selected": "Nenhum e-mail selecionado",
|
||||
"no_email_description": "Selecione um e-mail da lista para visualizá-lo aqui",
|
||||
"no_conversation_selected": "Nenhuma conversa selecionada",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Desfazer envio"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Confirmação de leitura solicitada (clique para desativar)",
|
||||
"read_receipt_off": "Solicitar confirmação de leitura",
|
||||
"new_message": "Nova Mensagem",
|
||||
"reply": "Responder",
|
||||
"reply_all": "Responder a Todos",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "Enviar",
|
||||
"cancel": "Cancelar",
|
||||
"attach": "Anexar",
|
||||
"attach_photos": "Fotos e vídeos",
|
||||
"attach_files": "Arquivos",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Descartar",
|
||||
"discard_draft_title": "Descartar rascunho?",
|
||||
"discard_draft_confirm": "Você tem alterações não salvas. Deseja descartar este rascunho?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "Envio agendado não é compatível com esta conta.",
|
||||
"schedule_send_cleanup_warning": "O envio agendado foi criado, mas a limpeza do rascunho falhou.",
|
||||
"send_delay_unsupported": "Atraso de envio não é compatível com esta conta.",
|
||||
"send_delay_unsupported_confirm": "Esta conta não oferece atraso de envio. Enviar imediatamente?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "Esta conta não oferece atraso de envio. Enviar imediatamente?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmar",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Solicitar confirmações de leitura por padrão",
|
||||
"description": "Ativar previamente a solicitação de confirmação de leitura ao redigir uma nova mensagem."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Responder a solicitações de confirmação de leitura",
|
||||
"description": "O que fazer quando uma mensagem recebida solicita uma confirmação de leitura.",
|
||||
"ask": "Perguntar sempre",
|
||||
"always": "Enviar sempre",
|
||||
"never": "Nunca enviar"
|
||||
},
|
||||
"title": "Comportamento de E-mail",
|
||||
"description": "Configure como os e-mails são manipulados",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Notas",
|
||||
"title": "Pastas",
|
||||
"description": "Gerencie suas pastas de e-mail e atribua funções padrão",
|
||||
"folder_list": "Suas pastas",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "Ações",
|
||||
"add_action": "Adicionar ação",
|
||||
"stop_processing": "Parar o processamento das regras seguintes",
|
||||
"attachment_type_placeholder": "ex.: pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Valor (vários separados por vírgulas)",
|
||||
"condition_fields": {
|
||||
"attachment": "Anexo",
|
||||
"from": "De",
|
||||
"to": "Para",
|
||||
"cc": "Cc",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "Corpo"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "está presente",
|
||||
"has_type": "do tipo",
|
||||
"contains": "contém",
|
||||
"not_contains": "não contém",
|
||||
"is": "é exatamente",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "Gerente",
|
||||
"custom": "Personalizado"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "Em {date}, {from} escreveu:",
|
||||
"forwarded_separator": "---------- Mensagem encaminhada ----------",
|
||||
"from_label": "De",
|
||||
"date_label": "Data",
|
||||
"subject_label": "Assunto"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "Instalar {appName}",
|
||||
"description": "Instale o nosso app para acesso rápido e suporte offline.",
|
||||
"not_now": "Agora não",
|
||||
"install": "Instalar",
|
||||
"dont_remind": "Não lembrar novamente",
|
||||
"dismiss_aria": "Dispensar aviso de instalação"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Введите новую дату/время, например 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "Отправитель запрашивает уведомление о прочтении:",
|
||||
"send": "Отправить уведомление",
|
||||
"ignore": "Игнорировать",
|
||||
"sent": "Уведомление о прочтении отправлено.",
|
||||
"send_failed": "Не удалось отправить уведомление о прочтении",
|
||||
"mdn_subject": "Прочитано: {subject}",
|
||||
"mdn_body": "Это уведомление о прочтении сообщения, отправленного вами на адрес {recipient}.\n\nПримечание: это уведомление лишь подтверждает, что сообщение было показано на компьютере получателя. Оно не гарантирует, что получатель прочитал или понял содержимое."
|
||||
},
|
||||
"no_email_selected": "Письмо не выбрано",
|
||||
"no_email_description": "Выберите письмо из списка, чтобы просмотреть его здесь",
|
||||
"no_conversation_selected": "Беседа не выбрана",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Отменить отправку"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Запрошено уведомление о прочтении (нажмите, чтобы отключить)",
|
||||
"read_receipt_off": "Запросить уведомление о прочтении",
|
||||
"new_message": "Новое письмо",
|
||||
"reply": "Ответить",
|
||||
"reply_all": "Ответить всем",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "Отправить",
|
||||
"cancel": "Отмена",
|
||||
"attach": "Прикрепить",
|
||||
"attach_photos": "Фото и видео",
|
||||
"attach_files": "Файлы",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Удалить",
|
||||
"discard_draft_title": "Удалить черновик?",
|
||||
"discard_draft_confirm": "Есть несохранённые изменения. Удалить этот черновик?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "Запланированная отправка не поддерживается для этой учетной записи.",
|
||||
"schedule_send_cleanup_warning": "Запланированная отправка создана, но очистка черновика не удалась.",
|
||||
"send_delay_unsupported": "Задержка отправки не поддерживается для этой учетной записи.",
|
||||
"send_delay_unsupported_confirm": "Эта учетная запись не поддерживает задержку отправки. Отправить сразу?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "Эта учетная запись не поддерживает задержку отправки. Отправить сразу?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Подтвердить",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Запрашивать уведомления о прочтении по умолчанию",
|
||||
"description": "Заранее включать запрос уведомления о прочтении при создании нового сообщения."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Отвечать на запросы уведомления о прочтении",
|
||||
"description": "Что делать, когда входящее сообщение запрашивает уведомление о прочтении.",
|
||||
"ask": "Спрашивать каждый раз",
|
||||
"always": "Всегда отправлять",
|
||||
"never": "Никогда не отправлять"
|
||||
},
|
||||
"title": "Поведение почты",
|
||||
"description": "Настройте обработку писем",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Заметки",
|
||||
"title": "Папки",
|
||||
"description": "Управляйте папками почты и назначайте стандартные роли",
|
||||
"folder_list": "Ваши папки",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "Действия",
|
||||
"add_action": "Добавить действие",
|
||||
"stop_processing": "Прекратить обработку последующих правил",
|
||||
"attachment_type_placeholder": "напр. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Значение (несколько через запятую)",
|
||||
"condition_fields": {
|
||||
"attachment": "Вложение",
|
||||
"from": "От",
|
||||
"to": "Кому",
|
||||
"cc": "Копия",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "Тело"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "присутствует",
|
||||
"has_type": "типа",
|
||||
"contains": "содержит",
|
||||
"not_contains": "не содержит",
|
||||
"is": "точно совпадает",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "Управляющий",
|
||||
"custom": "Пользовательский"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "{date}, {from} написал:",
|
||||
"forwarded_separator": "---------- Пересланное сообщение ----------",
|
||||
"from_label": "От",
|
||||
"date_label": "Дата",
|
||||
"subject_label": "Тема"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "Установить {appName}",
|
||||
"description": "Установите наше приложение для быстрого доступа и работы офлайн.",
|
||||
"not_now": "Не сейчас",
|
||||
"install": "Установить",
|
||||
"dont_remind": "Больше не напоминать",
|
||||
"dismiss_aria": "Закрыть запрос на установку"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Yeni tarih/saat girin, ör. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "Gönderen bir okundu bilgisi istiyor:",
|
||||
"send": "Bilgi gönder",
|
||||
"ignore": "Yoksay",
|
||||
"sent": "Okundu bilgisi gönderildi.",
|
||||
"send_failed": "Okundu bilgisi gönderilemedi",
|
||||
"mdn_subject": "Okundu: {subject}",
|
||||
"mdn_body": "Bu, {recipient} adresine gönderdiğiniz iletinin okundu bilgisidir.\n\nNot: Bu bilgi yalnızca iletinin alıcının bilgisayarında görüntülendiğini belirtir. Alıcının içeriği okuduğunu veya anladığını garanti etmez."
|
||||
},
|
||||
"no_email_selected": "E-posta seçilmedi",
|
||||
"no_email_description": "Burada görüntülemek için listeden bir e-posta seçin",
|
||||
"no_conversation_selected": "Konuşma seçilmedi",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Göndermeyi geri al"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Okundu bilgisi istendi (devre dışı bırakmak için tıklayın)",
|
||||
"read_receipt_off": "Okundu bilgisi iste",
|
||||
"new_message": "Yeni İleti",
|
||||
"reply": "Yanıtla",
|
||||
"reply_all": "Tümünü Yanıtla",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "Gönder",
|
||||
"cancel": "İptal",
|
||||
"attach": "Ekle",
|
||||
"attach_photos": "Fotoğraflar ve videolar",
|
||||
"attach_files": "Dosyalar",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Vazgeç",
|
||||
"discard_draft_title": "Taslaktan vazgeçilsin mi?",
|
||||
"discard_draft_confirm": "Kaydedilmemiş değişiklikleriniz var. Bu taslaktan vazgeçmek istiyor musunuz?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "Bu hesap için zamanlanmış gönderim desteklenmiyor.",
|
||||
"schedule_send_cleanup_warning": "Zamanlanmış gönderim oluşturuldu, ancak taslak temizliği başarısız oldu.",
|
||||
"send_delay_unsupported": "Bu hesap için gönderim gecikmesi desteklenmiyor.",
|
||||
"send_delay_unsupported_confirm": "Bu hesap gönderim gecikmesini desteklemiyor. Hemen gönderilsin mi?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "Bu hesap gönderim gecikmesini desteklemiyor. Hemen gönderilsin mi?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Onayla",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Varsayılan olarak okundu bilgisi iste",
|
||||
"description": "Yeni bir ileti yazarken okundu bilgisi isteğini önceden etkinleştir."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Okundu bilgisi isteklerine yanıt ver",
|
||||
"description": "Gelen bir ileti okundu bilgisi istediğinde ne yapılacağı.",
|
||||
"ask": "Her seferinde sor",
|
||||
"always": "Her zaman gönder",
|
||||
"never": "Asla gönderme"
|
||||
},
|
||||
"title": "E-posta Davranışı",
|
||||
"description": "E-postaların nasıl işleneceğini yapılandırın",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Notlar",
|
||||
"title": "Klasörler",
|
||||
"description": "E-posta klasörlerinizi yönetin ve standart roller atayın",
|
||||
"folder_list": "Klasörleriniz",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "İşlemler",
|
||||
"add_action": "İşlem Ekle",
|
||||
"stop_processing": "Sonraki kuralları işlemeyi durdur",
|
||||
"attachment_type_placeholder": "örn. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Değer (birden fazla virgülle ayrılır)",
|
||||
"condition_fields": {
|
||||
"attachment": "Ek",
|
||||
"from": "Kimden",
|
||||
"to": "Kime",
|
||||
"cc": "Bilgi",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "Gövde"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "mevcut",
|
||||
"has_type": "türünde",
|
||||
"contains": "içerir",
|
||||
"not_contains": "içermez",
|
||||
"is": "tam olarak",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Birleşik görünümde arama kullanılamaz"
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "{date} tarihinde {from} şunu yazdı:",
|
||||
"forwarded_separator": "---------- İletilen mesaj ----------",
|
||||
"from_label": "Kimden",
|
||||
"date_label": "Tarih",
|
||||
"subject_label": "Konu"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "{appName} uygulamasını yükle",
|
||||
"description": "Hızlı erişim ve çevrimdışı destek için uygulamamızı yükleyin.",
|
||||
"not_now": "Şimdi değil",
|
||||
"install": "Yükle",
|
||||
"dont_remind": "Bir daha hatırlatma",
|
||||
"dismiss_aria": "Yükleme istemini kapat"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "Введіть нову дату/час, напр. 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "Відправник запитує сповіщення про прочитання:",
|
||||
"send": "Надіслати сповіщення",
|
||||
"ignore": "Ігнорувати",
|
||||
"sent": "Сповіщення про прочитання надіслано.",
|
||||
"send_failed": "Не вдалося надіслати сповіщення про прочитання",
|
||||
"mdn_subject": "Прочитано: {subject}",
|
||||
"mdn_body": "Це сповіщення про прочитання повідомлення, яке ви надіслали на адресу {recipient}.\n\nПримітка: це сповіщення лише підтверджує, що повідомлення було показано на комп''ютері отримувача. Воно не гарантує, що отримувач прочитав або зрозумів вміст."
|
||||
},
|
||||
"no_email_selected": "Електронна адреса не вибрана",
|
||||
"no_email_description": "Виберіть електронний лист зі списку, щоб переглянути його тут",
|
||||
"no_conversation_selected": "Розмова не вибрана",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "Скасувати надсилання"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Запитано сповіщення про прочитання (натисніть, щоб вимкнути)",
|
||||
"read_receipt_off": "Запитати сповіщення про прочитання",
|
||||
"new_message": "Нове повідомлення",
|
||||
"reply": "Відповісти",
|
||||
"reply_all": "Відповісти всім",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "Надіслати",
|
||||
"cancel": "Скасувати",
|
||||
"attach": "Прикріпити",
|
||||
"attach_photos": "Фото та відео",
|
||||
"attach_files": "Файли",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Відкинути",
|
||||
"discard_draft_title": "Відхилити чернетку?",
|
||||
"discard_draft_confirm": "У вас є незбережені зміни. Ви хочете відхилити цю чернетку?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "Заплановане надсилання не підтримується для цього облікового запису.",
|
||||
"schedule_send_cleanup_warning": "Заплановане надсилання створено, але очищення чернетки не вдалося.",
|
||||
"send_delay_unsupported": "Затримка надсилання не підтримується для цього облікового запису.",
|
||||
"send_delay_unsupported_confirm": "Цей обліковий запис не підтримує затримку надсилання. Надіслати негайно?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "Цей обліковий запис не підтримує затримку надсилання. Надіслати негайно?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Підтвердити",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "Запитувати сповіщення про прочитання за замовчуванням",
|
||||
"description": "Заздалегідь вмикати запит сповіщення про прочитання під час створення нового повідомлення."
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "Відповідати на запити сповіщення про прочитання",
|
||||
"description": "Що робити, коли вхідне повідомлення запитує сповіщення про прочитання.",
|
||||
"ask": "Запитувати щоразу",
|
||||
"always": "Завжди надсилати",
|
||||
"never": "Ніколи не надсилати"
|
||||
},
|
||||
"title": "Поведінка електронної пошти",
|
||||
"description": "Налаштувати спосіб обробки електронних листів",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "Нотатки",
|
||||
"title": "Папки",
|
||||
"description": "Керуйте своїми папками електронної пошти та призначайте стандартні ролі",
|
||||
"folder_list": "Ваші папки",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "Дії",
|
||||
"add_action": "Додати дію",
|
||||
"stop_processing": "Зупинити обробку наступних правил",
|
||||
"attachment_type_placeholder": "напр. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Значення (декілька через кому)",
|
||||
"condition_fields": {
|
||||
"attachment": "Вкладення",
|
||||
"from": "Від",
|
||||
"to": "до",
|
||||
"cc": "Cc",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "Тіло"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "присутнє",
|
||||
"has_type": "типу",
|
||||
"contains": "містить",
|
||||
"not_contains": "не містить",
|
||||
"is": "точно",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "Керівник",
|
||||
"custom": "Власне"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "{date}, {from} написав:",
|
||||
"forwarded_separator": "---------- Переслане повідомлення ----------",
|
||||
"from_label": "Від",
|
||||
"date_label": "Дата",
|
||||
"subject_label": "Тема"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "Встановити {appName}",
|
||||
"description": "Встановіть наш застосунок для швидкого доступу та офлайн-підтримки.",
|
||||
"not_now": "Не зараз",
|
||||
"install": "Встановити",
|
||||
"dont_remind": "Більше не нагадувати",
|
||||
"dismiss_aria": "Закрити запит на встановлення"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -253,6 +253,15 @@
|
||||
"reschedule_prompt": "输入新的日期/时间,例如 2026-05-04T15:30"
|
||||
},
|
||||
"email_viewer": {
|
||||
"read_receipt": {
|
||||
"prompt": "发件人请求已读回执:",
|
||||
"send": "发送回执",
|
||||
"ignore": "忽略",
|
||||
"sent": "已读回执已发送。",
|
||||
"send_failed": "无法发送已读回执",
|
||||
"mdn_subject": "已读:{subject}",
|
||||
"mdn_body": "这是您发送给 {recipient} 的邮件的已读回执。\n\n注意:此回执仅表示邮件已在收件人的计算机上显示,并不保证收件人已阅读或理解邮件内容。"
|
||||
},
|
||||
"no_email_selected": "未选择邮件",
|
||||
"no_email_description": "从列表中选择一封邮件以在此查看",
|
||||
"no_conversation_selected": "未选择会话",
|
||||
@@ -538,6 +547,8 @@
|
||||
"undo_send": "撤销发送"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "已请求已读回执(点击以关闭)",
|
||||
"read_receipt_off": "请求已读回执",
|
||||
"new_message": "新邮件",
|
||||
"reply": "回复",
|
||||
"reply_all": "全部回复",
|
||||
@@ -554,8 +565,8 @@
|
||||
"send": "发送",
|
||||
"cancel": "取消",
|
||||
"attach": "附件",
|
||||
"attach_photos": "照片和视频",
|
||||
"attach_files": "文件",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "丢弃",
|
||||
"discard_draft_title": "丢弃草稿?",
|
||||
"discard_draft_confirm": "您有未保存的更改。确定要丢弃这封草稿吗?",
|
||||
@@ -638,9 +649,7 @@
|
||||
"schedule_send_unsupported": "此账户不支持计划发送。",
|
||||
"schedule_send_cleanup_warning": "已创建计划发送,但清理草稿失败。",
|
||||
"send_delay_unsupported": "此账户不支持发送延迟。",
|
||||
"send_delay_unsupported_confirm": "此账户不支持发送延迟。是否立即发送?",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files"
|
||||
"send_delay_unsupported_confirm": "此账户不支持发送延迟。是否立即发送?"
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "确认",
|
||||
@@ -999,6 +1008,17 @@
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"request_read_receipt": {
|
||||
"label": "默认请求已读回执",
|
||||
"description": "撰写新邮件时预先启用已读回执请求。"
|
||||
},
|
||||
"read_receipt_response": {
|
||||
"label": "响应已读回执请求",
|
||||
"description": "当收到的邮件请求已读回执时的处理方式。",
|
||||
"ask": "每次询问",
|
||||
"always": "始终发送",
|
||||
"never": "从不发送"
|
||||
},
|
||||
"title": "邮件行为",
|
||||
"description": "配置邮件的处理方式",
|
||||
"mark_read": {
|
||||
@@ -1423,6 +1443,7 @@
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"role_memos": "备忘录",
|
||||
"title": "文件夹",
|
||||
"description": "管理邮件文件夹并分配邮箱角色",
|
||||
"folder_list": "您的文件夹",
|
||||
@@ -1625,7 +1646,10 @@
|
||||
"actions": "操作",
|
||||
"add_action": "添加操作",
|
||||
"stop_processing": "停止处理后续规则",
|
||||
"attachment_type_placeholder": "例如:pdf、doc、jpg",
|
||||
"value_placeholder_multi": "值(多个用逗号分隔)",
|
||||
"condition_fields": {
|
||||
"attachment": "附件",
|
||||
"from": "发件人",
|
||||
"to": "收件人",
|
||||
"cc": "抄送",
|
||||
@@ -1635,6 +1659,8 @@
|
||||
"body": "正文"
|
||||
},
|
||||
"comparators": {
|
||||
"has_any": "存在",
|
||||
"has_type": "类型为",
|
||||
"contains": "包含",
|
||||
"not_contains": "不包含",
|
||||
"is": "等于",
|
||||
@@ -3048,5 +3074,20 @@
|
||||
"manager": "管理员",
|
||||
"custom": "自定义"
|
||||
}
|
||||
},
|
||||
"quote_header": {
|
||||
"reply_line": "在 {date},{from} 写道:",
|
||||
"forwarded_separator": "---------- 转发邮件 ----------",
|
||||
"from_label": "发件人",
|
||||
"date_label": "日期",
|
||||
"subject_label": "主题"
|
||||
},
|
||||
"pwa_install": {
|
||||
"title": "安装 {appName}",
|
||||
"description": "安装我们的应用,以获得快速访问和离线支持。",
|
||||
"not_now": "暂不",
|
||||
"install": "安装",
|
||||
"dont_remind": "不再提醒",
|
||||
"dismiss_aria": "关闭安装提示"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ interface EmailStore {
|
||||
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
|
||||
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
||||
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string) => Promise<SendEmailResult>;
|
||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean }) => Promise<SendEmailResult>;
|
||||
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
|
||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||
@@ -841,10 +841,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom) => {
|
||||
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom);
|
||||
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options);
|
||||
// Refresh handled by UI layer for immediate feedback
|
||||
set({
|
||||
isLoading: false,
|
||||
|
||||
+73
-88
@@ -6,7 +6,8 @@ import { persist } from 'zustand/middleware';
|
||||
import type { InstalledPlugin, PluginStatus } from '@/lib/plugin-types';
|
||||
import { pluginStorage } from '@/lib/plugin-storage';
|
||||
import { extractPlugin } from '@/lib/plugin-validator';
|
||||
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader';
|
||||
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
|
||||
import { requestConsent } from '@/lib/plugin-sandbox/consent';
|
||||
import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity';
|
||||
@@ -17,6 +18,8 @@ import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types';
|
||||
import type { Permission } from '@/lib/plugin-types';
|
||||
|
||||
let pluginInitializationPromise: Promise<void> | null = null;
|
||||
// One-time guard so we attach the locale->sandbox subscription only once.
|
||||
let localeSubscribed = false;
|
||||
|
||||
// ─── Store Interface ─────────────────────────────────────────
|
||||
|
||||
@@ -258,6 +261,17 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
setPluginStatus: get().setPluginStatus,
|
||||
});
|
||||
setupAutoDisable();
|
||||
// Keep the sandbox locale in step with the app locale. Set it
|
||||
// synchronously *before* activation so background instances get the
|
||||
// right locale in their init payload (the bug: the only wiring lived
|
||||
// in the dead activateAllPlugins() path, so the sandbox locale stayed
|
||||
// 'en' forever and plugin i18n never localized). Subscribe once for
|
||||
// later language switches; those affect plugins/slots loaded after.
|
||||
setSandboxLocale(useLocaleStore.getState().locale);
|
||||
if (!localeSubscribed) {
|
||||
localeSubscribed = true;
|
||||
useLocaleStore.subscribe((s) => setSandboxLocale(s.locale));
|
||||
}
|
||||
|
||||
// Sync server-managed plugins before loading
|
||||
await syncServerPlugins(get, set);
|
||||
@@ -324,6 +338,33 @@ interface ServerPluginInfo {
|
||||
apiPostPaths?: string[];
|
||||
/** Per-user settings schema, captured from the manifest server-side. */
|
||||
settingsSchema?: InstalledPlugin['settingsSchema'];
|
||||
/** Plugin-declared i18n tables (locale -> key -> string), from the manifest. */
|
||||
locales?: InstalledPlugin['locales'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-owned metadata, passed through verbatim on every sync. Centralised in
|
||||
* ONE place so a newly added passthrough field can't be silently dropped at one
|
||||
* of several copy sites - which is exactly what previously lost `settingsSchema`
|
||||
* (hence the old "schema drift" special-case) and then `locales`. Excludes
|
||||
* fields the client owns (id, type, enabled/status, settings, adminApproved).
|
||||
*/
|
||||
function serverMeta(sp: ServerPluginInfo) {
|
||||
return {
|
||||
name: sp.name,
|
||||
version: sp.version,
|
||||
author: sp.author,
|
||||
description: sp.description,
|
||||
permissions: sp.permissions,
|
||||
entrypoint: sp.entrypoint,
|
||||
managed: true as const,
|
||||
forceEnabled: sp.forceEnabled,
|
||||
bundleHash: sp.bundleHash,
|
||||
httpOrigins: sp.httpOrigins,
|
||||
apiPostPaths: sp.apiPostPaths,
|
||||
settingsSchema: sp.settingsSchema,
|
||||
locales: sp.locales,
|
||||
};
|
||||
}
|
||||
|
||||
const SERVER_MANAGED_KEY = 'server-managed-plugin-ids';
|
||||
@@ -402,113 +443,57 @@ async function syncServerPlugins(
|
||||
const local = get().plugins.find(p => p.id === sp.id);
|
||||
|
||||
if (!local) {
|
||||
// New server plugin - download and install
|
||||
// New server plugin - download bundle and install.
|
||||
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
|
||||
if (!code) continue;
|
||||
|
||||
await pluginStorage.saveCode(sp.id, code);
|
||||
|
||||
const plugin: InstalledPlugin = {
|
||||
id: sp.id,
|
||||
name: sp.name,
|
||||
version: sp.version,
|
||||
author: sp.author,
|
||||
description: sp.description,
|
||||
type: sp.type as InstalledPlugin['type'],
|
||||
permissions: sp.permissions,
|
||||
entrypoint: sp.entrypoint,
|
||||
enabled: sp.forceEnabled,
|
||||
status: sp.forceEnabled ? 'enabled' : 'installed',
|
||||
managed: true,
|
||||
forceEnabled: sp.forceEnabled,
|
||||
adminApproved: true, // Server-managed plugins are always approved
|
||||
settings: {},
|
||||
settingsSchema: sp.settingsSchema,
|
||||
bundleHash: sp.bundleHash,
|
||||
...(sp.httpOrigins && sp.httpOrigins.length > 0
|
||||
? { httpOrigins: sp.httpOrigins }
|
||||
: {}),
|
||||
...(sp.apiPostPaths && sp.apiPostPaths.length > 0
|
||||
? { apiPostPaths: sp.apiPostPaths }
|
||||
: {}),
|
||||
...serverMeta(sp),
|
||||
};
|
||||
set(state =>
|
||||
state.plugins.some(p => p.id === sp.id)
|
||||
? {}
|
||||
: { plugins: [...state.plugins, plugin] },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
set(state => {
|
||||
if (state.plugins.some(p => p.id === sp.id)) {
|
||||
return {};
|
||||
}
|
||||
return { plugins: [...state.plugins, plugin] };
|
||||
});
|
||||
} else if (
|
||||
// Existing plugin. Re-download the bundle only when the code actually
|
||||
// changed, but ALWAYS re-derive server-owned metadata from one place
|
||||
// (serverMeta) so no passthrough field is silently dropped on a
|
||||
// metadata-only change. Only write when something differs, to avoid a
|
||||
// needless persist/re-render on every sync.
|
||||
const needsBundle =
|
||||
local.version !== sp.version ||
|
||||
// bundleHash mismatch covers re-uploads of the same version with new
|
||||
// code. Falsy local hash (older installs that never carried one) also
|
||||
// forces a refresh so we capture the hash on the next sync.
|
||||
(sp.bundleHash && local.bundleHash !== sp.bundleHash)
|
||||
) {
|
||||
// Version or content changed - re-download bundle
|
||||
// code; a falsy local hash (older installs) also forces a refresh so
|
||||
// we capture the hash on the next sync.
|
||||
(!!sp.bundleHash && local.bundleHash !== sp.bundleHash);
|
||||
|
||||
if (needsBundle) {
|
||||
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
|
||||
if (!code) continue;
|
||||
|
||||
await pluginStorage.saveCode(sp.id, code);
|
||||
}
|
||||
|
||||
// Force-enable in the same pass when the server flips it on, so the user
|
||||
// doesn't need a second refresh for it to run.
|
||||
const shouldAutoEnable = sp.forceEnabled && !local.enabled;
|
||||
const next: InstalledPlugin = {
|
||||
...local,
|
||||
...serverMeta(sp),
|
||||
...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}),
|
||||
};
|
||||
if (needsBundle || JSON.stringify(next) !== JSON.stringify(local)) {
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === sp.id
|
||||
? {
|
||||
...p,
|
||||
name: sp.name,
|
||||
version: sp.version,
|
||||
author: sp.author,
|
||||
description: sp.description,
|
||||
permissions: sp.permissions,
|
||||
entrypoint: sp.entrypoint,
|
||||
managed: true,
|
||||
forceEnabled: sp.forceEnabled,
|
||||
bundleHash: sp.bundleHash,
|
||||
httpOrigins: sp.httpOrigins,
|
||||
apiPostPaths: sp.apiPostPaths,
|
||||
settingsSchema: sp.settingsSchema,
|
||||
}
|
||||
: p
|
||||
),
|
||||
}));
|
||||
} else if (local.managed !== true || local.forceEnabled !== sp.forceEnabled) {
|
||||
// When forceEnabled flips on, enable the plugin in the same pass so
|
||||
// the user doesn't need a second refresh for it to run.
|
||||
const shouldAutoEnable = sp.forceEnabled && !local.enabled;
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === sp.id
|
||||
? {
|
||||
...p,
|
||||
managed: true,
|
||||
forceEnabled: sp.forceEnabled,
|
||||
settingsSchema: sp.settingsSchema,
|
||||
...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}),
|
||||
}
|
||||
: p
|
||||
),
|
||||
}));
|
||||
} else if (
|
||||
JSON.stringify(local.settingsSchema ?? null) !== JSON.stringify(sp.settingsSchema ?? null)
|
||||
) {
|
||||
// Schema drift: the bundle is current but the persisted plugin record
|
||||
// pre-dates the server passing settingsSchema through, so the per-user
|
||||
// settings UI was rendering empty. Patch the schema in place.
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === sp.id ? { ...p, settingsSchema: sp.settingsSchema } : p
|
||||
),
|
||||
}));
|
||||
} else if (sp.forceEnabled && !local.enabled) {
|
||||
// Force-enable if the server says so but client has it disabled
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === sp.id
|
||||
? { ...p, enabled: true, status: 'enabled' as const, managed: true, forceEnabled: true }
|
||||
: p
|
||||
),
|
||||
plugins: state.plugins.map(p => (p.id === sp.id ? next : p)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export type ListDensity = Density;
|
||||
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
|
||||
export type ReplyMode = 'reply' | 'replyAll';
|
||||
export type SignaturePosition = 'above_quote' | 'below_quote';
|
||||
/** How to handle an incoming Disposition-Notification-To (read-receipt) request. */
|
||||
export type ReadReceiptResponse = 'ask' | 'always' | 'never';
|
||||
export type DateFormat = 'smart' | 'relative' | 'full';
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
|
||||
@@ -157,6 +159,8 @@ interface SettingsState {
|
||||
sendDelaySeconds: SendDelaySeconds;
|
||||
signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards
|
||||
signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter
|
||||
requestReadReceiptDefault: boolean; // Pre-check "request read receipt" in the composer
|
||||
readReceiptResponse: ReadReceiptResponse; // How to respond to incoming read-receipt requests
|
||||
|
||||
// Privacy & Security
|
||||
sessionTimeout: number; // minutes (0 = never)
|
||||
@@ -332,6 +336,8 @@ const DEFAULT_SETTINGS = {
|
||||
sendDelaySeconds: 0 as SendDelaySeconds,
|
||||
signaturePosition: 'below_quote' as SignaturePosition,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
readReceiptResponse: 'ask' as ReadReceiptResponse,
|
||||
|
||||
// Privacy & Security
|
||||
sessionTimeout: 0, // Never
|
||||
@@ -526,6 +532,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
sendDelaySeconds: state.sendDelaySeconds,
|
||||
signaturePosition: state.signaturePosition,
|
||||
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
|
||||
requestReadReceiptDefault: state.requestReadReceiptDefault,
|
||||
readReceiptResponse: state.readReceiptResponse,
|
||||
sessionTimeout: state.sessionTimeout,
|
||||
emailNotificationsEnabled: state.emailNotificationsEnabled,
|
||||
emailNotificationSound: state.emailNotificationSound,
|
||||
|
||||
Reference in New Issue
Block a user