diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 60944ea5..8c3126af 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -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} + diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index ae57e8ec..63611cb4 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -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(/\n/g, '
'); + const finalHtmlBody = sendingIdentity?.htmlSignature?.trim() + ? appendHtmlSignature(`
${escapedBody}
`, 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, diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx index 09bd2ce7..bfa87c13 100644 --- a/app/(main)/admin/_tabs/branding.tsx +++ b/app/(main)/admin/_tabs/branding.tsx @@ -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() {
{field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} @@ -606,7 +608,7 @@ export function BrandingTab() {
{field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index dac3d3d3..05572ca3 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -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({ > {children} - ); diff --git a/app/(sandbox)/layout.tsx b/app/(sandbox)/layout.tsx index 9813c79d..45dfe938 100644 --- a/app/(sandbox)/layout.tsx +++ b/app/(sandbox)/layout.tsx @@ -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 ( - + {children} diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts index 60dcead7..11f68b1e 100644 --- a/app/api/admin/branding/route.ts +++ b/app/api/admin/branding/route.ts @@ -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([ +const VALID_SLOTS = new Set([ 'faviconUrl', 'pwaIconUrl', 'appLogoLightUrl', 'appLogoDarkUrl', 'loginLogoLightUrl', 'loginLogoDarkUrl', + 'pwaScreenshotMobileUrl', + 'pwaScreenshotDesktopUrl', ]); const EXT_BY_MIME: Record = { @@ -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 }); } diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 7f03c047..06562701 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -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 } : {}), diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 1d18ad4a..74d5971b 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -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 = {}; - const create = args.create as Record | undefined; + const updated: Record = {}; + const destroyed: string[] = []; + + const create = args.create as Record> | 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> | 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 { diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 2cde26c6..c6789a16 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -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 diff --git a/app/api/pwa-screenshot/[variant]/route.ts b/app/api/pwa-screenshot/[variant]/route.ts new file mode 100644 index 00000000..a1a83acd --- /dev/null +++ b/app/api/pwa-screenshot/[variant]/route.ts @@ -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(); + +async function fetchSourceImage(iconUrl: string): Promise { + 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/ + // 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('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 }); + } +} diff --git a/app/api/setup/branding/route.ts b/app/api/setup/branding/route.ts index 27fb58bb..0d41e7f7 100644 --- a/app/api/setup/branding/route.ts +++ b/app/api/setup/branding/route.ts @@ -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 = { diff --git a/app/manifest.ts b/app/manifest.ts index d7046d5e..4e878bdd 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -95,10 +95,25 @@ export default async function manifest(): Promise { 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") }, diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 64f9c9d8..fe733607 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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; onScheduledSendCreated?: () => void | Promise; 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 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 ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

` - : `On ${date}, ${fromStr} wrote:
`; + ? `${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${fromStrFull}
${tQuote('date_label')}: ${date}
${tQuote('subject_label')}: ${replyTo.subject || ''}

` + : `${tQuote('reply_line', { date, from: fromStr })}
`; // 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(/\n/g, '
'); if (mode === 'forward') { - return `${prefix}${signatureBlock}

---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

${escapedOriginal}`; + return `${prefix}${signatureBlock}

${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${fromStrFull}
${tQuote('date_label')}: ${date}
${tQuote('subject_label')}: ${replyTo.subject || ''}

${escapedOriginal}`; } else if (mode === 'reply' || mode === 'replyAll') { - return `${prefix}${signatureBlock}

On ${date}, ${fromStr} wrote:
${escapedOriginal}
`; + return `${prefix}${signatureBlock}

${tQuote('reply_line', { date, from: fromStr })}
${escapedOriginal}
`; } } 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(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 */} -
+
{/* Left side actions */}
)} + + {/* Read-receipt request toggle */} +
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0f5b25e6..08b6d603 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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 | 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(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)) && (
@@ -5003,6 +5107,17 @@ export function EmailViewer({ + {/* Read-receipt (MDN) request banner — only in "ask" mode */} + {readReceiptResponse === 'ask' && shouldOfferReadReceipt && readReceiptRequestedBy && ( +
+ sendReadReceiptNow(false)} + onIgnore={ignoreReadReceipt} + /> +
+ )} + {/* Calendar Invitation Banner */} {hasCalendarInvitation && (
diff --git a/components/email/read-receipt-banner.tsx b/components/email/read-receipt-banner.tsx new file mode 100644 index 00000000..933befc9 --- /dev/null +++ b/components/email/read-receipt-banner.tsx @@ -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; + /** 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 ( +
+ + {t('sent')} +
+ ); + } + + return ( +
+ + {t('prompt')} + {requestedBy} +
+ + +
+
+ ); +} diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 25b0eb9b..221a6e7a 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -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) => ( - - ) - )} + {comparatorsFor(condition.field).map((c) => ( + + ))} - 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" ? ( +
+ ) : ( + + // 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"} + /> + )}
-
+
From plugin: {current.pluginId}
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 0f5fc0d7..e80e0865 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -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]); diff --git a/components/pwa-install-prompt.tsx b/components/pwa-install-prompt.tsx index 00c8bfb3..4cc95f95 100644 --- a/components/pwa-install-prompt.tsx +++ b/components/pwa-install-prompt.tsx @@ -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(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() { )}

- Install {appName} + {t("title", { appName })}

- Install our app for quick access and offline support. + {t("description")}

@@ -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")}
diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index c7787b6d..7646e488 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -28,6 +28,8 @@ export function ComposingSettings() { subAddressDelimiter, signaturePosition, signatureSeparatorEnabled, + requestReadReceiptDefault, + readReceiptResponse, updateSetting, } = useSettingsStore(); const { client } = useAuthStore(); @@ -78,6 +80,25 @@ export function ComposingSettings() { /> + + updateSetting('requestReadReceiptDefault', checked)} + /> + + + +