diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c3d3d4f..2ec6743d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 1.7.9 (2026-08-07) + +### Bug Fixes (Phase 1 — VNCmailgraph audit) + +- **Mail**: Network transport failures now throw `TransportError` instead of returning empty results, so offline/network-down is distinguishable from an empty folder (#C1) +- **Mail**: Push handler now refreshes contacts and files on remote state changes (#H1) +- **Calendar**: Recurrence expansion IDs use `::occurrence::` delimiter to prevent collision with shared-event prefixes (#C2) +- **Calendar**: Cross-account event aggregation now deduplicates by UID + recurrenceId, preventing phantom duplicates (#C3) +- **Calendar**: `calendarTasksEnabled` admin policy now enforced at runtime, not just in settings UI (#H13) +- **Tasks**: All task mutations (update, delete, toggle) now have error handling with store error state (#H14) +- **Settings**: `updateSetting()` now checks admin policy lock before writing; `force` opt-in for legitimate bypassers (#C7) +- **Settings**: `autoSelectReplyIdentity` now defaults to `true` — auto-identity selection on by default (#H18) +- **Templates**: HTML template bodies are now sanitized with DOMPurify on import to prevent stored XSS (#H7) +- **Auth**: User authentication endpoints now rate-limited — 10 attempts per (IP + username) per 15 minutes (#H3) +- **Auth**: Admin sessions now support token revocation via JTI blacklist on logout (#C4) +- **Auth**: Secure cookie flag now derived from `x-forwarded-proto`, not `NODE_ENV` (#H8) +- **Auth**: OAuth token exchange error logs no longer leak `access_token` (#H4) +- **Auth**: `isHashed()` no longer accepts bcrypt prefixes — scrypt-only, preventing lockout from bcrypt passwords (#H9) +- **Push**: WS→SSE fallback now awaits state snapshot before reconciliation to prevent missed deliveries (#H2) +- **Push**: Offline event handler added — push transports pause when browser goes offline, reconnect on online (#C8) +- **Index**: FTS5 schema-drop now logs a warning so operators know a rebuild is needed (#C6) + +--- + ## 1.7.8 (2026-07-22) ### Features diff --git a/VERSION b/VERSION index 84298f96..f65dc1e2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.8 +1.7.9 diff --git a/app/(main)/[locale]/calendar/page.tsx b/app/(main)/[locale]/calendar/page.tsx index e9c70d31..23eaebbd 100644 --- a/app/(main)/[locale]/calendar/page.tsx +++ b/app/(main)/[locale]/calendar/page.tsx @@ -97,6 +97,7 @@ export default function CalendarPage() { setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar, removeCalendar, clearCalendarEvents, refreshAllSubscriptions, icalSubscriptions, + newEventPrefill, setNewEventPrefill, } = useCalendarStore(); const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled')); const calendarTasksEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarTasksEnabled')); @@ -464,6 +465,19 @@ export default function CalendarPage() { setShowEventModal(true); }, [selectedDate, setSelectedDate]); + useEffect(() => { + if (!newEventPrefill) return; + setEditEvent(null); + if (newEventPrefill.date) { + const d = new Date(newEventPrefill.date); + if (!isNaN(d.getTime())) { + setDefaultModalDate(d); + setSelectedDate(d); + } + } + setShowEventModal(true); + }, [newEventPrefill, setSelectedDate]); + const openEditModal = useCallback((event: CalendarEvent) => { setEditEvent(event); setDefaultModalDate(undefined); @@ -1198,6 +1212,9 @@ export default function CalendarPage() { onContextMenuEvent={handleContextMenuEvent} onContextMenuEmpty={handleContextMenuEmpty} onCreateAtTime={openCreateModal} + onEditEvent={openEditModal} + onDeleteEvent={handleDeleteContextMenu} + onDuplicateEvent={handleDuplicateContextMenu} firstDayOfWeek={firstDayOfWeek} isMobile={isMobile} pendingPreview={pendingPreview} @@ -1490,10 +1507,14 @@ export default function CalendarPage() { onDelete={handleDeleteEvent} onDuplicate={handleDuplicateEvent} onRsvp={handleRsvp} - onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }} onPreviewChange={setPendingPreview} currentUserEmails={currentUserEmails} isMobile={false} + prefillTitle={editEvent ? undefined : newEventPrefill?.title} + prefillDescription={editEvent ? undefined : newEventPrefill?.description} + prefillParticipants={editEvent ? undefined : newEventPrefill?.participants} + prefillDate={editEvent ? undefined : newEventPrefill?.date} /> )} @@ -1620,9 +1641,13 @@ export default function CalendarPage() { onDelete={handleDeleteEvent} onDuplicate={handleDuplicateEvent} onRsvp={handleRsvp} - onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }} currentUserEmails={currentUserEmails} isMobile={true} + prefillTitle={editEvent ? undefined : newEventPrefill?.title} + prefillDescription={editEvent ? undefined : newEventPrefill?.description} + prefillParticipants={editEvent ? undefined : newEventPrefill?.participants} + prefillDate={editEvent ? undefined : newEventPrefill?.date} /> )} diff --git a/app/(main)/[locale]/files/page.tsx b/app/(main)/[locale]/files/page.tsx index 88f24225..55e85f70 100644 --- a/app/(main)/[locale]/files/page.tsx +++ b/app/(main)/[locale]/files/page.tsx @@ -11,6 +11,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useEmailStore } from "@/stores/email-store"; import { useFileStore } from "@/stores/file-store"; +import { useProTabStore } from "@/stores/pro-tab-store"; import { toast } from "@/stores/toast-store"; import { cn, formatFileSize } from "@/lib/utils"; import { NavigationRail } from "@/components/layout/navigation-rail"; @@ -411,6 +412,31 @@ export default function FilesPage() { await shareResource(id, principalId, rights); }, [shareResource]); + const handleSendAsAttachment = useCallback((names: string[]) => { + const store = useFileStore.getState(); + const fileAtts = names + .map((name) => { + const r = store.resources.find((res) => res.name === name); + if (!r || r.isDirectory || !r.blobId) return null; + return { + blobId: r.blobId, + name: r.name, + type: r.contentType || "application/octet-stream", + size: r.contentLength, + }; + }) + .filter(Boolean) as Array<{ blobId: string; name: string; type: string; size: number }>; + + if (fileAtts.length === 0) return; + + useProTabStore.getState().openComposeTab({ + sessionId: Date.now(), + mode: "compose", + replyTo: { attachments: fileAtts }, + title: fileAtts.length === 1 ? fileAtts[0].name : `${fileAtts.length} attachments`, + }); + }, []); + // Pro shell only: all connected accounts are equal top-level entries at // the root. The root path "/" itself is a cross-account picker - no // account's files are shown until the user enters one. @@ -554,6 +580,7 @@ export default function FilesPage() { ownAccountId={filesAccountId} sharingEnabled={sharingEnabled} onShare={handleShare} + onSendAsAttachment={handleSendAsAttachment} /> )} diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 3735c161..7682950e 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -35,6 +35,8 @@ import { SwatchBook, Download, Sparkles, + Upload, + Share2, X, type LucideIcon, } from 'lucide-react'; @@ -46,6 +48,7 @@ import { LayoutSettings } from '@/components/settings/layout-settings'; import { LanguageSettings } from '@/components/settings/language-settings'; import { ReadingSettings } from '@/components/settings/reading-settings'; import { ComposingSettings } from '@/components/settings/composing-settings'; +import { SignatureSettings } from '@/components/settings/signature-settings'; import { ContentSendersSettings } from '@/components/settings/content-senders-settings'; import { AccountSettings } from '@/components/settings/account-settings'; import { IdentitySettings } from '@/components/settings/identity-settings'; @@ -70,6 +73,8 @@ import { PluginsSettings } from '@/components/settings/plugins-settings'; import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings'; import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; +import { ImportSettings } from '@/components/settings/import-settings'; +import { SharingSettings } from '@/components/settings/sharing-settings'; import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings'; import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; @@ -98,6 +103,7 @@ type Tab = | 'composing' | 'downloads' | 'identities' + | 'signatures' | 'vacation' | 'filters' | 'templates' @@ -113,6 +119,8 @@ type Tab = | 'about_data' | 'themes' | 'plugins' + | 'import' + | 'sharing' | 'ai_assistant' | 'debug'; @@ -141,6 +149,7 @@ const tabIcons: Record = { composing: PenLine, downloads: Download, identities: UserPen, + signatures: PenLine, vacation: PalmtreeIcon, filters: Filter, templates: FileText, @@ -156,6 +165,8 @@ const tabIcons: Record = { about_data: Info, themes: SwatchBook, plugins: Puzzle, + import: Upload, + sharing: Share2, ai_assistant: Sparkles, debug: Bug, }; @@ -219,6 +230,7 @@ const tabSearchPaths: Record = { ], downloads: ['settings.downloads'], identities: ['settings.identities'], + signatures: ['signatures'], vacation: ['settings.vacation'], filters: ['settings.filters'], templates: ['settings.templates'], @@ -239,6 +251,8 @@ const tabSearchPaths: Record = { themes: [], plugins: [], ai_assistant: [], + import: ['settings.importer'], + sharing: ['sharing'], debug: ['settings.advanced'], }; @@ -254,6 +268,7 @@ const tabKeywords: Record = { composing: 'editor signature plain text reply forward draft compose', downloads: 'download filename template eml attachment save export', identities: 'from address signature email', + signatures: 'signature rich text html editor', vacation: 'auto reply away out of office holiday responder', filters: 'sieve rules block junk forward', templates: 'snippet quick reply', @@ -270,6 +285,8 @@ const tabKeywords: Record = { themes: 'custom theme css skin appearance', plugins: 'extensions addons', ai_assistant: 'assistant ask model llm ollama chatbot', + import: 'import email eml zip tgz mbox csv vcard contacts', + sharing: 'share shared folder calendar address book permission', debug: 'logs developer console diagnostic', }; @@ -619,6 +636,7 @@ export default function SettingsPage() { { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' }, { id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' }, { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' }, + { id: 'sharing', label: t('tabs.sharing'), icon: tabIcons.sharing, group: 'general' }, { id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' }, // Appearance @@ -631,10 +649,12 @@ export default function SettingsPage() { { id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' }, { id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' }, { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' }, + { id: 'signatures', label: t('tabs.signatures'), icon: tabIcons.signatures, group: 'mail' }, ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []), ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []), ...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []), { id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' }, + { id: 'import', label: t('tabs.import'), icon: tabIcons.import, group: 'mail' }, ...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []), // Privacy & Security @@ -761,10 +781,13 @@ export default function SettingsPage() { {effectiveActiveTab === 'composing' && } {effectiveActiveTab === 'downloads' && } {effectiveActiveTab === 'identities' && } + {effectiveActiveTab === 'signatures' && } {effectiveActiveTab === 'vacation' && } {effectiveActiveTab === 'filters' && } {effectiveActiveTab === 'templates' && } {effectiveActiveTab === 'folders' && } + {effectiveActiveTab === 'import' && } + {effectiveActiveTab === 'sharing' && } {effectiveActiveTab === 'keywords' && } {effectiveActiveTab === 'security' && } {effectiveActiveTab === 'content_senders' && } diff --git a/app/(main)/admin/_tabs/vncdirectory.tsx b/app/(main)/admin/_tabs/vncdirectory.tsx new file mode 100644 index 00000000..911c167d --- /dev/null +++ b/app/(main)/admin/_tabs/vncdirectory.tsx @@ -0,0 +1,620 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Save, Loader2, Plus, X } from 'lucide-react'; +import { apiFetch } from '@/lib/browser-navigation'; + +interface VncDirectoryFormData { + enabled: boolean; + apiUrl: string; + apiKey: string; + samlEnabled: boolean; + samlIdpUrl: string; + samlSpCert: string; + samlIssuer: string; + ldapEnabled: boolean; + ldapUri: string; + ldapBindDn: string; + ldapBindPassword: string; + ldapSearchBase: string; + ldapType: 'openldap' | 'ms-ad'; + tfaEnabled: boolean; + oidcEnabled: boolean; + oidcClientId: string; + oidcDiscoveryUrl: string; + sessionTtl: number; + federatedApps: Record; +} + +const BLANK_FORM: VncDirectoryFormData = { + enabled: false, + apiUrl: '', + apiKey: '', + samlEnabled: false, + samlIdpUrl: '', + samlSpCert: '', + samlIssuer: '', + ldapEnabled: false, + ldapUri: '', + ldapBindDn: '', + ldapBindPassword: '', + ldapSearchBase: '', + ldapType: 'openldap', + tfaEnabled: false, + oidcEnabled: false, + oidcClientId: '', + oidcDiscoveryUrl: '', + sessionTtl: 28800, + federatedApps: {}, +}; + +export function VncDirectoryTab() { + const [config, setConfig] = useState({ ...BLANK_FORM }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [dirty, setDirty] = useState(false); + + useEffect(() => { fetchConfig(); }, []); + + async function fetchConfig() { + setLoading(true); + try { + const res = await apiFetch('/api/admin/vncdirectory'); + if (res.ok) { + const data = await res.json(); + setConfig(data); + } + } finally { + setLoading(false); + } + } + + function updateField(key: K, value: VncDirectoryFormData[K]) { + setConfig((prev) => ({ ...prev, [key]: value })); + setDirty(true); + setMessage(null); + } + + function toggleBool(key: keyof VncDirectoryFormData) { + setConfig((prev) => ({ ...prev, [key]: !prev[key] })); + setDirty(true); + setMessage(null); + } + + function setFederatedApp(name: string, url: string) { + setConfig((prev) => ({ + ...prev, + federatedApps: { ...prev.federatedApps, [name]: url }, + })); + setDirty(true); + setMessage(null); + } + + function removeFederatedApp(name: string) { + setConfig((prev) => { + const next = { ...prev.federatedApps }; + delete next[name]; + return { ...prev, federatedApps: next }; + }); + setDirty(true); + setMessage(null); + } + + async function handleSave() { + setSaving(true); + setMessage(null); + + const res = await apiFetch('/api/admin/vncdirectory', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + + if (res.ok) { + setMessage({ type: 'success', text: 'VNCdirectory configuration saved.' }); + setDirty(false); + await fetchConfig(); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Failed to save' }); + } + setSaving(false); + } + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + const federatedAppsList = Object.entries(config.federatedApps); + + return ( +
+
+
+

VNCdirectory

+

+ Centralized identity and directory integration (SAML, LDAP, 2FA) +

+
+ {dirty && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+
+ Enabled +

+ Turn on VNCdirectory integration for identity management, SSO, and directory services +

+
+ +
+
+ + {config.enabled && ( + <> +
+
+ updateField('apiUrl', v)} + placeholder="https://vncdirectory.example.com" + /> + updateField('apiKey', v)} + placeholder="Enter API key" + /> +
+
+ +
+
+ toggleBool('samlEnabled')} + /> + {config.samlEnabled && ( + <> + updateField('samlIdpUrl', v)} + placeholder="https://idp.example.com/saml2/idp" + /> + updateField('samlIssuer', v)} + placeholder="urn:example:vncmail" + /> +
+ +