Compare commits

..
20 Commits
Author SHA1 Message Date
Linus Rath 27451807db chore: update version to 1.5.2 2026-04-27 22:45:23 +02:00
Linus Rath 55099bdcbb fix: disable Stalwart admin check when custom JMAP endpoints allowed 2026-04-27 22:41:51 +02:00
Linus Rath 71eb720065 chore: update version to 1.5.2 2026-04-27 22:30:32 +02:00
Linus Rath 8abb0c8717 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-27 22:24:59 +02:00
Linus Rath 3043639d2d fix: validate URLs before outbound fetch 2026-04-27 22:23:39 +02:00
Linus Rath e9b3eacbb7 fix: prevent drag creation on touch events in useTimeGridInteractions 2026-04-27 13:37:34 +02:00
Linus Rath f37e55e285 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-27 10:36:22 +02:00
c0af2dbdd1 i18n: Fix word 'ficheiro' and its variants.
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 10:17:32 +02:00
Luis Felipe MarzagaoandLinus Rath ae517732f7 i18n: Fix word 'contactos' and its variants. 2026-04-27 10:17:32 +02:00
Linus Rath 3e1de10213 feat: add JMAP sharing for calendars and address books 2026-04-26 20:10:04 +02:00
Roman VanicekandLinus Rath 511740bb6d Additional Czech translations for recent changes. 2026-04-26 15:48:43 +02:00
Roman VanicekandLinus Rath 4a24d2a11d feat: add Czech language support 2026-04-26 15:48:43 +02:00
Linus Rath aadf56c27b fix: emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import #224 #187 2026-04-26 02:47:19 +02:00
Linus Rath 9f8588eadc fix: hide preview line in compact density to match settings preview (#223) 2026-04-26 02:12:28 +02:00
Linus Rath d657aec391 feat: add support for right-side composer sidebar and update related types 2026-04-25 19:45:45 +02:00
Linus RathandCopilot e683c90404 fix: implement inline matcher for Next.js proxy and remove unnecessary config
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 19:25:15 +02:00
Linus Rath cfb4a23c9d fix: remove unnecessary runtime config for Node.js in proxy settings 2026-04-25 18:54:15 +02:00
Linus Rath fe1d4861bb feat: composer-sidebar slot + plugin-declared frame-src origins 2026-04-25 18:40:54 +02:00
Linus Rath 5aa9b1d5f9 fix: honor SESSION_SECRET_FILE in dashboard warning check #222 2026-04-25 17:21:17 +02:00
Linus Rath 9c7452e7fd chore: add admin dashboard data directory configuration to environment files 2026-04-23 11:22:14 +02:00
69 changed files with 5263 additions and 354 deletions
+11 -3
View File
@@ -71,11 +71,19 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Directory for storing encrypted settings files (default: ./data/settings).
# For Docker, the working directory is /app, so the default resolves to
# /app/data/settings - mount a persistent volume there:
# volumes:
# - bulwark-settings:/app/data/settings
# /app/data/settings - mount a persistent volume there (see docker-compose.yml).
# SETTINGS_DATA_DIR=./data/settings
# =============================================================================
# Admin Dashboard Data
# =============================================================================
# Directory for admin dashboard state: config overrides, admin password hash,
# installed plugins/themes, and audit logs (default: ./data/admin).
# For Docker, the default resolves to /app/data/admin - mount a persistent
# volume there (see docker-compose.yml).
# ADMIN_DATA_DIR=./data/admin
# =============================================================================
# Server Listen Address
# =============================================================================
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## 1.5.2 (2026-04-27)
### Features
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission — plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
- **Plugins**: Manifests can declare `frameOrigins` — a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
- **Calendar/Contacts**: JMAP sharing for calendars and address books
- **i18n**: Czech language support
### Fixes
- **Security**: Validate URLs before outbound fetch
- **Calendar**: Prevent drag creation on touch events in the time grid
- **Contacts**: Emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import (#224, #187)
- **Mail**: Hide preview line in compact density to match settings preview (#223)
- **Proxy**: Inline matcher for Next.js proxy and drop unnecessary Node.js runtime config
- **i18n**: Portuguese fixes for "ficheiro" and "contactos" variants
## 1.5.1 (2026-04-25)
### Features
+2 -2
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.5.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.5.2-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div>
@@ -62,7 +62,7 @@ Bulwark is a full webmail suite not just an inbox. It bundles the four apps
- **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
Full feature list: **[FEATURES.md](FEATURES.md)**.
+1 -1
View File
@@ -1 +1 @@
1.5.1
1.5.2
+79 -4
View File
@@ -47,7 +47,11 @@ import { getEventStartDate } from "@/lib/calendar-utils";
import { useTaskStore } from "@/stores/task-store";
import { useContactStore } from "@/stores/contact-store";
import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { debug } from "@/lib/debug";
@@ -72,7 +76,8 @@ export default function CalendarPage() {
calendars, events, selectedDate, viewMode, selectedCalendarIds,
isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
removeCalendar, clearCalendarEvents,
refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
@@ -91,6 +96,11 @@ export default function CalendarPage() {
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
const [showCreateCalendar, setShowCreateCalendar] = useState(false);
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
const tMgmt = useTranslations("calendar.management");
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -1102,6 +1112,42 @@ export default function CalendarPage() {
}
updateCalendar(client, calendarId, { color });
} : undefined}
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
onCreateEvent={(cal: Calendar) => {
setDefaultCalendarIdForCreate(cal.id);
openCreateModal();
}}
onClearCalendar={client ? async (cal: Calendar) => {
const ok = await confirmAction({
title: tMgmt("clear_events"),
message: tMgmt("confirm_clear", { name: cal.name }),
variant: "destructive",
confirmText: tMgmt("clear_events"),
});
if (!ok) return;
try {
const count = await clearCalendarEvents(client, cal.id);
toast.success(tMgmt("events_cleared", { count }));
} catch {
toast.error(tMgmt("error_clear"));
}
} : undefined}
onDeleteCalendar={client ? async (cal: Calendar) => {
const ok = await confirmAction({
title: tMgmt("delete"),
message: tMgmt("confirm_delete", { name: cal.name }),
variant: "destructive",
confirmText: tMgmt("delete"),
});
if (!ok) return;
try {
await removeCalendar(client, cal.id);
toast.success(tMgmt("calendar_deleted"));
} catch {
toast.error(tMgmt("error_delete"));
}
} : undefined}
onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client}
@@ -1165,11 +1211,12 @@ export default function CalendarPage() {
calendars={calendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }}
onPreviewChange={setPendingPreview}
currentUserEmails={currentUserEmails}
isMobile={false}
@@ -1261,11 +1308,12 @@ export default function CalendarPage() {
calendars={calendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }}
currentUserEmails={currentUserEmails}
isMobile={true}
/>
@@ -1305,6 +1353,33 @@ export default function CalendarPage() {
onSelect={handleScopeSelect}
onClose={() => setPendingScopeAction(null)}
/>
<ConfirmDialog {...confirmDialogProps} />
{showCreateCalendar && client && (
<CreateCalendarModal
client={client}
onClose={() => setShowCreateCalendar(false)}
/>
)}
{sharingCalendarId && client && (() => {
const cal = allCalendars.find((c) => c.id === sharingCalendarId);
if (!cal) return null;
return (
<ShareCollectionDialog
client={client}
kind="calendar"
collectionName={cal.name}
shareWith={cal.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
}}
onClose={() => setSharingCalendarId(null)}
/>
);
})()}
</div>
);
}
+46 -2
View File
@@ -27,7 +27,8 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
type View =
| "list"
@@ -75,6 +76,8 @@ export default function ContactsPage() {
bulkAddToGroup,
moveContactToAddressBook,
renameAddressBook,
removeAddressBook,
shareAddressBook,
renameKeyword,
importContacts,
} = useContactStore();
@@ -83,6 +86,8 @@ export default function ContactsPage() {
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false);
@@ -329,6 +334,7 @@ export default function ContactsPage() {
addLocalContact(localContact);
toast.success(t("toast.created"));
}
setDefaultBookIdForCreate(undefined);
setView("list");
}, [supportsSync, client, createContact, addLocalContact, t]);
@@ -346,6 +352,7 @@ export default function ContactsPage() {
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
const handleCancel = () => {
setDefaultBookIdForCreate(undefined);
if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list");
} else if (view === "bulk-add-to-group") {
@@ -517,7 +524,7 @@ export default function ContactsPage() {
const renderRightPanel = () => {
switch (view) {
case "create":
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit":
if (!selectedContact) return null;
@@ -690,6 +697,26 @@ export default function ContactsPage() {
onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
onCreateContactInBook={(book) => {
setDefaultBookIdForCreate(book.id);
handleCreateNew();
}}
onDeleteAddressBook={client ? async (book) => {
const ok = await confirmDialog({
title: t("address_books.delete"),
message: t("address_books.confirm_delete", { name: book.name }),
variant: "destructive",
confirmText: t("address_books.delete"),
});
if (!ok) return;
try {
await removeAddressBook(client, book);
toast.success(t("address_books.deleted"));
} catch {
toast.error(t("address_books.delete_failed"));
}
} : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
/>
</div>
@@ -838,6 +865,23 @@ export default function ContactsPage() {
</div>
</div>
)}
{sharingAddressBookId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
if (!book) return null;
return (
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
}}
onClose={() => setSharingAddressBookId(null)}
/>
);
})()}
</div>
);
}
Binary file not shown.
+25 -1
View File
@@ -10,6 +10,10 @@ import {
type ServerPlugin,
type ServerTheme,
} from '@/lib/admin/plugin-registry';
import {
sanitizeFrameOrigins,
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
@@ -226,6 +230,22 @@ export async function POST(request: NextRequest) {
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
}
// Plugins may declare iframe origins they need for embedded content.
// Anything that doesn't pass strict origin validation is silently
// dropped — the plugin still installs, but those origins are not
// added to the host CSP.
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
? (manifest.frameOrigins as unknown[]).filter(
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
)
: [];
if (droppedFrameOrigins.length > 0) {
warnings.push(
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
);
}
const plugin: ServerPlugin = {
id: (manifest.id as string) || slug,
name: (manifest.name as string) || slug,
@@ -238,10 +258,14 @@ export async function POST(request: NextRequest) {
enabled: true,
installedAt: now,
updatedAt: now,
...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins }
: {}),
};
await savePlugin(plugin, code);
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
invalidateFrameOriginsCache();
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip);
return NextResponse.json({ success: true, plugin, warnings });
}
+17 -1
View File
@@ -8,6 +8,10 @@ import {
deletePlugin as removePlugin,
type ServerPlugin,
} from '@/lib/admin/plugin-registry';
import {
sanitizeFrameOrigins,
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
// Server-side extraction using the same validation logic
// ZIP parsing needs to happen on the server for admin-uploaded plugins
@@ -152,6 +156,8 @@ export async function POST(request: NextRequest) {
);
}
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const now = new Date().toISOString();
const plugin: ServerPlugin = {
id: manifest.id as string,
@@ -166,12 +172,16 @@ export async function POST(request: NextRequest) {
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins }
: {}),
installedAt: now,
updatedAt: now,
};
await savePlugin(plugin, code);
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
invalidateFrameOriginsCache();
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip);
return NextResponse.json({ plugin });
} catch (error) {
@@ -209,6 +219,11 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
// Enable/disable changes the set of plugins contributing frame origins.
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
invalidateFrameOriginsCache();
}
await auditLog('plugin.update', { id, ...updates }, ip);
return NextResponse.json({ plugin: updated });
} catch (error) {
@@ -238,6 +253,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
invalidateFrameOriginsCache();
await auditLog('plugin.delete', { id }, ip);
return NextResponse.json({ success: true });
} catch (error) {
+3 -84
View File
@@ -1,89 +1,9 @@
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
import { NextRequest, NextResponse } from 'next/server';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000;
const blockedAddressRanges = new BlockList();
blockedAddressRanges.addAddress('0.0.0.0');
blockedAddressRanges.addAddress('127.0.0.1');
blockedAddressRanges.addSubnet('10.0.0.0', 8);
blockedAddressRanges.addSubnet('172.16.0.0', 12);
blockedAddressRanges.addSubnet('192.168.0.0', 16);
blockedAddressRanges.addSubnet('169.254.0.0', 16);
blockedAddressRanges.addAddress('::', 'ipv6');
blockedAddressRanges.addAddress('::1', 'ipv6');
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
}
function isBlockedIpAddress(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized);
if (family === 4) {
return blockedAddressRanges.check(normalized, 'ipv4');
}
if (family === 6) {
return blockedAddressRanges.check(normalized, 'ipv6');
}
return false;
}
async function isValidExternalUrl(urlString: string): Promise<boolean> {
let url: URL;
try {
url = new URL(urlString);
} catch {
return false;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return false;
}
const hostname = normalizeHostname(url.hostname);
// Block private/internal hostnames
if (
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname.endsWith('.local') ||
hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') ||
hostname.endsWith('.localdomain')
) {
return false;
}
// Block URLs with credentials
if (url.username || url.password) {
return false;
}
if (isBlockedIpAddress(hostname)) {
return false;
}
if (isIP(hostname)) {
return true;
}
try {
const records = await lookup(hostname, { all: true, verbatim: true });
if (records.length === 0) {
return false;
}
return records.every((record) => !isBlockedIpAddress(record.address));
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
let body: { url?: string };
try {
@@ -98,7 +18,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
if (!(await isValidExternalUrl(url))) {
if (!(await isPublicHttpUrl(url))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
@@ -111,7 +31,7 @@ export async function POST(request: NextRequest) {
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!(await isValidExternalUrl(currentUrl))) {
if (!(await isPublicHttpUrl(currentUrl))) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
}
@@ -131,7 +51,6 @@ export async function POST(request: NextRequest) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
}
// Resolve relative redirects
currentUrl = new URL(location, currentUrl).toString();
continue;
}
+127 -105
View File
@@ -1,8 +1,8 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -11,6 +11,8 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
interface CalendarSidebarPanelProps {
@@ -18,6 +20,11 @@ interface CalendarSidebarPanelProps {
selectedCalendarIds: string[];
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
onShareCalendar?: (calendar: Calendar) => void;
onCreateEvent?: (calendar: Calendar) => void;
onClearCalendar?: (calendar: Calendar) => void;
onDeleteCalendar?: (calendar: Calendar) => void;
onCreateCalendar?: () => void;
onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null;
@@ -28,12 +35,18 @@ export function CalendarSidebarPanel({
selectedCalendarIds,
onToggleVisibility,
onColorChange,
onShareCalendar,
onCreateEvent,
onClearCalendar,
onDeleteCalendar,
onCreateCalendar,
onSubscribe,
onEditSubscription,
client,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
const tSub = useTranslations("calendar.subscription");
const tMgmt = useTranslations("calendar.management");
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
@@ -49,11 +62,8 @@ export function CalendarSidebarPanel({
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
}, [tasks]);
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const colorPickerRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
const sharedAccountGroups = useMemo(() => {
@@ -69,30 +79,6 @@ export function CalendarSidebarPanel({
return Array.from(groups.values());
}, [calendars]);
useEffect(() => {
if (!colorPickerId && !contextMenuCalId) return;
const handleClick = (e: MouseEvent) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
setColorPickerId(null);
}
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenuCalId(null);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setColorPickerId(null);
setContextMenuCalId(null);
}
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [colorPickerId, contextMenuCalId]);
const getSubscriptionForCalendar = (calendarId: string) => {
return icalSubscriptions.find(s => s.calendarId === calendarId);
};
@@ -100,7 +86,6 @@ export function CalendarSidebarPanel({
const handleRefreshSubscription = async (subId: string) => {
if (!client) return;
setRefreshingSubId(subId);
setContextMenuCalId(null);
try {
await refreshICalSubscription(client, subId);
toast.success(tSub('refresh_success'));
@@ -113,7 +98,6 @@ export function CalendarSidebarPanel({
const handleUnsubscribe = async (subId: string) => {
if (!client) return;
setContextMenuCalId(null);
try {
await removeICalSubscription(client, subId);
toast.success(tSub('deleted'));
@@ -127,21 +111,13 @@ export function CalendarSidebarPanel({
const renderCalendarItem = (cal: Calendar) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
return (
<div key={cal.id} className="relative">
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={(e) => {
e.preventDefault();
if (isSubscriptionCalendar(cal.id) && client) {
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
setColorPickerId(null);
} else if (onColorChange) {
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
setContextMenuCalId(null);
}
}}
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
@@ -169,70 +145,101 @@ export function CalendarSidebarPanel({
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
</button>
{/* Subscription context menu on right-click */}
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
const sub = getSubscriptionForCalendar(cal.id);
if (!sub) return null;
return (
<div
ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
>
<button
onClick={() => {
setContextMenuCalId(null);
onEditSubscription?.(sub.id);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
{tSub('edit')}
</button>
<button
onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
{tSub('refresh')}
</button>
<button
onClick={() => handleUnsubscribe(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
{tSub('unsubscribe')}
</button>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
</div>
)}
</div>
);
})()}
{/* Color picker popover on right-click */}
{colorPickerId === cal.id && onColorChange && (
<div
ref={colorPickerRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
<CalendarColorPicker
value={color}
onChange={(c) => {
onColorChange(cal.id, c);
setColorPickerId(null);
}}
allowCustom
/>
</div>
)}
</div>
);
};
const renderCalendarMenu = () => {
const cal = contextMenu.data;
if (!cal) return null;
if (isSubscriptionCalendar(cal.id)) {
const sub = getSubscriptionForCalendar(cal.id);
if (!sub || !client) return null;
return (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<ContextMenuItem
icon={Pencil}
label={tSub('edit')}
onClick={() => { closeContextMenu(); onEditSubscription?.(sub.id); }}
/>
<ContextMenuItem
icon={RefreshCw}
label={tSub('refresh')}
onClick={() => { closeContextMenu(); handleRefreshSubscription(sub.id); }}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={tSub('unsubscribe')}
onClick={() => { closeContextMenu(); handleUnsubscribe(sub.id); }}
destructive
/>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
</div>
)}
</ContextMenu>
);
}
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
const canChangeColor = !!onColorChange;
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
const color = cal.color || "#3b82f6";
return (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
{canCreate && (
<ContextMenuItem
icon={Plus}
label={tMgmt('new_event_in_calendar')}
onClick={() => { closeContextMenu(); onCreateEvent(cal); }}
/>
)}
{canShare && (
<ContextMenuItem
icon={Users}
label={tMgmt('share')}
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
/>
)}
{canChangeColor && (
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
<div className="px-2 py-1.5 w-[200px]">
<CalendarColorPicker
value={color}
onChange={(c) => { onColorChange(cal.id, c); closeContextMenu(); }}
allowCustom
/>
</div>
</ContextMenuSubMenu>
)}
{showSeparator && <ContextMenuSeparator />}
{canClear && (
<ContextMenuItem
icon={Eraser}
label={tMgmt('clear_events')}
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
/>
)}
{canDelete && (
<ContextMenuItem
icon={Trash2}
label={tMgmt('delete')}
onClick={() => { closeContextMenu(); onDeleteCalendar(cal); }}
destructive
/>
)}
</ContextMenu>
);
};
return (
<div className="mt-4">
{enableCalendarTasks && (
@@ -250,9 +257,22 @@ export function CalendarSidebarPanel({
)}
</button>
)}
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="flex items-center justify-between mb-2 px-1 group">
{onCreateCalendar ? (
<button
onClick={onCreateCalendar}
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
title={tMgmt('add_calendar')}
>
{t('my_calendars')}
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
) : (
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('my_calendars')}
</h3>
)}
</div>
<div className="space-y-0.5">
{personalCalendars.map(renderCalendarItem)}
</div>
@@ -268,6 +288,8 @@ export function CalendarSidebarPanel({
</div>
</div>
))}
{renderCalendarMenu()}
</div>
);
}
@@ -0,0 +1,151 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Calendar as CalendarIcon } from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useCalendarStore } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store";
interface CreateCalendarModalProps {
client: IJMAPClient;
onClose: () => void;
}
export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProps) {
const t = useTranslations("calendar.management");
const tCommon = useTranslations("common");
const createCalendar = useCalendarStore((s) => s.createCalendar);
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6");
const [isSubmitting, setIsSubmitting] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const isValid = name.trim().length > 0;
const handleSubmit = useCallback(async () => {
const trimmed = name.trim();
if (!trimmed) return;
setIsSubmitting(true);
try {
const created = await createCalendar(client, { name: trimmed, color });
if (created) {
toast.success(t("calendar_created"));
onClose();
} else {
toast.error(t("error_create"));
}
} catch {
toast.error(t("error_create"));
} finally {
setIsSubmitting(false);
}
}, [name, color, client, createCalendar, onClose, t]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isSubmitting) onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose, isSubmitting]);
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusableEls = modal.querySelectorAll<HTMLElement>(
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
const handler = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl?.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl?.focus();
}
};
modal.addEventListener("keydown", handler);
firstEl?.focus();
return () => modal.removeEventListener("keydown", handler);
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
onClick={() => !isSubmitting && onClose()}
aria-hidden="true"
/>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("add_calendar")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<CalendarIcon className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
</div>
<button
onClick={onClose}
disabled={isSubmitting}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color")}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{tCommon("loading")}
</>
) : (
t("create")
)}
</Button>
</div>
</div>
</div>
);
}
+3
View File
@@ -35,6 +35,7 @@ interface EventModalProps {
calendars: Calendar[];
defaultDate?: Date;
defaultEndDate?: Date;
defaultCalendarId?: string;
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
onDuplicate?: (data: Partial<CalendarEvent>) => void;
@@ -113,6 +114,7 @@ export function EventModal({
calendars,
defaultDate,
defaultEndDate,
defaultCalendarId,
onSave,
onDelete,
onDuplicate,
@@ -200,6 +202,7 @@ export function EventModal({
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
const [calendarId, setCalendarId] = useState<string>(() => {
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
const defaultCal = calendars.find(c => c.isDefault);
return defaultCal?.id || calendars[0]?.id || "";
});
+16 -9
View File
@@ -50,6 +50,7 @@ interface ContactFormProps {
contact?: ContactCard | null;
addressBooks?: AddressBook[];
allKeywords?: string[];
defaultAddressBookId?: string;
onSave: (data: Partial<ContactCard>) => Promise<void>;
onCancel: () => void;
}
@@ -143,11 +144,13 @@ function Select({ value, onChange, children, className }: {
);
}
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) {
const t = useTranslations("contacts.form");
const isEditing = !!contact;
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
const findComponent = (...kinds: string[]) =>
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
function anniversaryDateToString(date: AnniversaryDate): string {
@@ -210,11 +213,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
};
}
const [prefix, setPrefix] = useState(findComponent("prefix"));
const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
const [givenName, setGivenName] = useState(findComponent("given"));
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
const [surname, setSurname] = useState(findComponent("surname"));
const [suffix, setSuffix] = useState(findComponent("suffix"));
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
const [nickname, setNickname] = useState(
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
@@ -328,8 +331,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
return ids[0];
}
}
if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
return defaultAddressBookId;
}
return "";
}, [contact]);
}, [contact, defaultAddressBookId, addressBooks]);
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
const initialPhotoEntry = useMemo(() => {
@@ -436,12 +442,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
phonesMap[`p${i}`] = obj;
});
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
const nameComponents = [];
if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() });
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
if (additionalName.trim()) nameComponents.push({ kind: "additional" as const, value: additionalName.trim() });
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
if (suffix.trim()) nameComponents.push({ kind: "suffix" as const, value: suffix.trim() });
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
+67 -20
View File
@@ -27,6 +27,9 @@ interface ContactsSidebarProps {
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
onShareAddressBook?: (addressBook: AddressBook) => void;
onCreateContactInBook?: (addressBook: AddressBook) => void;
onDeleteAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
className?: string;
}
@@ -62,6 +65,9 @@ export function ContactsSidebar({
onDropContacts,
onDropContactsToCategory,
onRenameAddressBook,
onShareAddressBook,
onCreateContactInBook,
onDeleteAddressBook,
onRenameKeyword,
className,
}: ContactsSidebarProps) {
@@ -288,7 +294,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
@@ -430,7 +436,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
@@ -438,24 +444,65 @@ export function ContactsSidebar({
</div>
{/* Address book context menu */}
{bookContextMenu.data && onRenameAddressBook && (
<ContextMenu
ref={bookMenuRef}
isOpen={bookContextMenu.isOpen}
position={bookContextMenu.position}
onClose={closeBookContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
const book = bookContextMenu.data!;
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
</ContextMenu>
)}
{bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => {
const book = bookContextMenu.data;
const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false;
const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false;
const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared;
const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false;
const showSeparator = (canCreate || canRename || canShare) && canDelete;
return (
<ContextMenu
ref={bookMenuRef}
isOpen={bookContextMenu.isOpen}
position={bookContextMenu.position}
onClose={closeBookContextMenu}
>
{canCreate && (
<ContextMenuItem
icon={UserPlus}
label={t("address_books.new_contact_in_book")}
onClick={() => {
closeBookContextMenu();
onCreateContactInBook(book);
}}
/>
)}
{canRename && (
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
)}
{canShare && (
<ContextMenuItem
icon={Users}
label={t("address_books.share")}
onClick={() => {
closeBookContextMenu();
onShareAddressBook(book);
}}
/>
)}
{showSeparator && <ContextMenuSeparator />}
{canDelete && (
<ContextMenuItem
icon={Trash2}
label={t("address_books.delete")}
onClick={() => {
closeBookContextMenu();
onDeleteAddressBook(book);
}}
destructive
/>
)}
</ContextMenu>
);
})()}
{/* Keyword (category) context menu */}
{keywordContextMenu.data && onRenameKeyword && (
+12 -1
View File
@@ -1100,8 +1100,14 @@ export function EmailComposer({
};
return (
<div className={cn("flex h-full bg-background", className)}>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
/>
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
<div
className={cn("flex flex-col h-full bg-background relative", className)}
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
data-tour="composer"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -1674,6 +1680,11 @@ export function EmailComposer({
</div>
)}
</div>
<PluginSlot
name="composer-sidebar-right"
className="hidden md:flex shrink-0 h-full overflow-hidden border-l border-border"
/>
</div>
);
}
+1 -1
View File
@@ -288,7 +288,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && density !== 'extra-compact' && (
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
+2 -2
View File
@@ -309,7 +309,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{email.subject || "(no subject)"}
</div>
{showPreview && density !== 'extra-compact' && (
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
@@ -715,7 +715,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{latestEmail.subject || "(no subject)"}
</div>
{showPreview && density !== 'extra-compact' && (
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
hasUnread
+2
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
import csMessages from '@/locales/cs/common.json';
import enMessages from '@/locales/en/common.json';
import frMessages from '@/locales/fr/common.json';
import jaMessages from '@/locales/ja/common.json';
@@ -20,6 +21,7 @@ import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
cs: csMessages,
en: enMessages,
fr: frMessages,
ja: jaMessages,
@@ -2,13 +2,14 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag } from "lucide-react";
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import type { AddressBook } from "@/lib/jmap/types";
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "./share-collection-dialog";
function AddressBookEditRow({
initial,
@@ -70,9 +71,10 @@ export function AddressBookManagementSettings() {
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@@ -148,6 +150,16 @@ export function AddressBookManagementSettings() {
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{!book.isShared && book.myRights?.mayShare && (
<button
type="button"
onClick={() => setSharingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("share")}
>
<Users className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
@@ -242,6 +254,24 @@ export function AddressBookManagementSettings() {
</div>
</SettingsSection>
</div>
{sharingId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingId);
if (!book) return null;
return (
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
}}
onClose={() => setSharingId(null)}
/>
);
})()}
</>
);
}
@@ -7,7 +7,9 @@ import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react';
import { ShareCollectionDialog } from './share-collection-dialog';
import type { CalendarRights } from '@/lib/jmap/types';
import { cn, formatDateTime } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
@@ -83,7 +85,7 @@ function CalendarColorPicker({
);
}
function CalendarEditForm({
export function CalendarEditForm({
initial,
onSave,
onCancel,
@@ -153,7 +155,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
@@ -164,6 +166,7 @@ export function CalendarManagementSettings() {
const [clearingId, setClearingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
@@ -522,6 +525,16 @@ export function CalendarManagementSettings() {
>
<Pencil className="w-3.5 h-3.5" />
</button>
{cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (
<button
type="button"
onClick={() => setSharingId(cal.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t('share')}
>
<Users className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => setClearingId(cal.id)}
@@ -689,6 +702,24 @@ export function CalendarManagementSettings() {
onClose={() => setEditingSubscription(null)}
/>
)}
{sharingId && client && (() => {
const cal = calendars.find((c) => c.id === sharingId);
if (!cal) return null;
return (
<ShareCollectionDialog
client={client}
kind="calendar"
collectionName={cal.name}
shareWith={cal.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
}}
onClose={() => setSharingId(null)}
/>
);
})()}
</SettingsSection>
);
}
@@ -0,0 +1,347 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
import { toast } from "@/stores/toast-store";
type ShareKind = "calendar" | "addressBook";
type AnyRights = CalendarRights | AddressBookRights;
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
const CALENDAR_PRESETS: Record<Exclude<RolePreset, "custom">, CalendarRights> = {
freeBusy: {
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
read: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
readWrite: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
},
manager: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
},
};
const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights> = {
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false },
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
};
function detectCalendarPreset(r: CalendarRights): RolePreset {
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
return name;
}
}
return "custom";
}
function detectAddressBookPreset(r: AddressBookRights): RolePreset {
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights][]) {
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
return name;
}
}
return "custom";
}
interface ShareCollectionDialogProps {
client: IJMAPClient;
kind: ShareKind;
collectionName: string;
shareWith: Record<string, AnyRights> | null | undefined;
ownAccountId: string;
onShare: (principalId: string, rights: AnyRights | null) => Promise<void>;
onClose: () => void;
}
export function ShareCollectionDialog({
client,
kind,
collectionName,
shareWith,
ownAccountId,
onShare,
onClose,
}: ShareCollectionDialogProps) {
const t = useTranslations("sharing");
const tCommon = useTranslations("common");
const modalRef = useRef<HTMLDivElement>(null);
const [principals, setPrincipals] = useState<Principal[]>([]);
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
const [search, setSearch] = useState("");
const [savingId, setSavingId] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
// Load principals on mount
useEffect(() => {
let cancelled = false;
setLoadingPrincipals(true);
client.getPrincipals().then((list) => {
if (cancelled) return;
// Exclude the user themselves and any principal that already has a share
const existing = new Set(Object.keys(shareWith || {}));
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
setPrincipals(filtered);
setLoadingPrincipals(false);
}).catch(() => {
if (!cancelled) setLoadingPrincipals(false);
});
return () => { cancelled = true; };
}, [client, ownAccountId, shareWith]);
// Map principal id -> Principal for displayed shares
const allPrincipalsById = useMemo(() => {
const map = new Map<string, Principal>();
for (const p of principals) map.set(p.id, p);
return map;
}, [principals]);
// Close on Escape, focus trap, click outside
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const handleSetRights = async (principalId: string, preset: RolePreset) => {
if (preset === "custom") return; // custom is read-only here
const rights = kind === "calendar"
? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]
: ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
if (!rights) return;
setSavingId(principalId);
try {
await onShare(principalId, rights);
toast.success(t("share_updated"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("share_failed"));
} finally {
setSavingId(null);
}
};
const handleRemove = async (principalId: string) => {
setSavingId(principalId);
try {
await onShare(principalId, null);
toast.success(t("share_removed"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("share_failed"));
} finally {
setSavingId(null);
}
};
const handleAdd = async (principal: Principal) => {
const defaultPreset: RolePreset = "read";
const rights = kind === "calendar"
? CALENDAR_PRESETS[defaultPreset]
: ADDRESS_BOOK_PRESETS[defaultPreset];
setSavingId(principal.id);
try {
await onShare(principal.id, rights);
// Move principal out of the "to add" list
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
setShowAdd(false);
setSearch("");
toast.success(t("share_added"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("share_failed"));
} finally {
setSavingId(null);
}
};
const filteredPrincipals = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return principals;
return principals.filter((p) =>
p.name.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q)
);
}, [principals, search]);
const sharedEntries = useMemo(() => {
return Object.entries(shareWith || {}) as [string, AnyRights][];
}, [shareWith]);
const presetOptions = kind === "calendar"
? ["freeBusy", "read", "readWrite", "manager"] as const
: ["read", "readWrite", "manager"] as const;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title", { name: collectionName })}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title", { name: collectionName })}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4 overflow-y-auto">
<p className="text-sm text-muted-foreground">{t("description")}</p>
{sharedEntries.length === 0 && !showAdd && (
<div className="text-sm text-muted-foreground italic py-4 text-center">
{t("no_shares")}
</div>
)}
{sharedEntries.length > 0 && (
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
{sharedEntries.map(([principalId, rights]) => {
const principal = allPrincipalsById.get(principalId);
const preset = kind === "calendar"
? detectCalendarPreset(rights as CalendarRights)
: detectAddressBookPreset(rights as AddressBookRights);
return (
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{principal?.name || principal?.email || principalId}
</div>
{principal?.description && (
<div className="text-xs text-muted-foreground truncate">
{principal.description}
</div>
)}
</div>
<div className="relative">
<select
value={preset}
onChange={(e) => handleSetRights(principalId, e.target.value as RolePreset)}
disabled={savingId === principalId}
className="appearance-none rounded-md border border-input bg-background pl-3 pr-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
>
{presetOptions.map((p) => (
<option key={p} value={p}>{t(`preset.${p}`)}</option>
))}
{preset === "custom" && (
<option value="custom">{t("preset.custom")}</option>
)}
</select>
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
</div>
<button
onClick={() => handleRemove(principalId)}
disabled={savingId === principalId}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
aria-label={t("remove")}
title={t("remove")}
>
{savingId === principalId
? <Loader2 className="w-4 h-4 animate-spin" />
: <Trash2 className="w-4 h-4" />}
</button>
</li>
);
})}
</ul>
)}
{!showAdd && (
<Button
variant="outline"
onClick={() => setShowAdd(true)}
className="w-full"
>
<UserPlus className="w-4 h-4 mr-2" />
{t("add_person")}
</Button>
)}
{showAdd && (
<div className="space-y-2 border border-border rounded-md p-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("search_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
/>
<div className="max-h-48 overflow-y-auto -mx-1">
{loadingPrincipals && (
<div className="flex items-center justify-center py-4 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("loading_principals")}
</div>
)}
{!loadingPrincipals && filteredPrincipals.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-3">
{search.trim() ? t("no_match") : t("no_principals")}
</div>
)}
{!loadingPrincipals && filteredPrincipals.map((p) => (
<button
key={p.id}
onClick={() => handleAdd(p)}
disabled={savingId === p.id}
className="w-full text-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate flex items-center gap-2">
{p.name}
{p.type === "group" && (
<span className="text-[10px] uppercase font-normal text-muted-foreground bg-muted rounded px-1 py-0.5">
{t("group")}
</span>
)}
</div>
{p.email && p.email !== p.name && (
<div className="text-xs text-muted-foreground truncate">{p.email}</div>
)}
</div>
{savingId === p.id && <Loader2 className="w-4 h-4 animate-spin" />}
</div>
</button>
))}
</div>
<div className="flex justify-end pt-1">
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setSearch(""); }}>
{tCommon("cancel")}
</Button>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button onClick={onClose}>{tCommon("close")}</Button>
</div>
</div>
</div>
);
}
+1
View File
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
import { flagComponents } from './flag-icons';
const languages = [
{ value: 'cs', label: 'Česky' },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
+9
View File
@@ -8,6 +8,11 @@ services:
- PORT=3000
env_file:
- .env.local
volumes:
# Encrypted user settings (SETTINGS_DATA_DIR).
- bulwark-settings:/app/data/settings
# Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR).
- bulwark-admin:/app/data/admin
healthcheck:
test:
[
@@ -23,3 +28,7 @@ services:
retries: 3
start_period: 10s
restart: unless-stopped
volumes:
bulwark-settings:
bulwark-admin:
+1
View File
@@ -80,6 +80,7 @@ export function useTimeGridInteractions({
dayDate: Date,
) => {
if (isMobile) return;
if (e.pointerType === "touch") return;
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return;
+3
View File
@@ -11,6 +11,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
// Use static imports for better compatibility
let messages;
switch (locale) {
case 'cs':
messages = (await import('../locales/cs/common.json')).default;
break;
case 'fr':
messages = (await import('../locales/fr/common.json')).default;
break;
+1 -1
View File
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'as-needed';
export const routing = defineRouting({
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
defaultLocale: 'en',
localePrefix
});
+1 -1
View File
@@ -82,7 +82,7 @@ function makeCalendar(overrides: Partial<Calendar> = {}): Calendar {
mayWriteOwn: true,
mayUpdatePrivate: true,
mayRSVP: true,
mayAdmin: false,
mayShare: false,
mayDelete: false,
},
...overrides,
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import {
isValidFrameOrigin,
sanitizeFrameOrigins,
} from '@/lib/admin/csp-frame-origins';
describe('isValidFrameOrigin', () => {
it('accepts plain https origins', () => {
expect(isValidFrameOrigin('https://www.youtube-nocookie.com')).toBe(true);
expect(isValidFrameOrigin('https://meet.example.com')).toBe(true);
expect(isValidFrameOrigin('https://a.b.c.example.com')).toBe(true);
});
it('accepts a wildcard subdomain', () => {
expect(isValidFrameOrigin('https://*.example.com')).toBe(true);
expect(isValidFrameOrigin('https://*.youtube.com')).toBe(true);
});
it('accepts an explicit port', () => {
expect(isValidFrameOrigin('https://meet.example.com:8443')).toBe(true);
expect(isValidFrameOrigin('https://*.example.com:443')).toBe(true);
});
it('rejects non-https schemes', () => {
expect(isValidFrameOrigin('http://example.com')).toBe(false);
expect(isValidFrameOrigin('ftp://example.com')).toBe(false);
expect(isValidFrameOrigin('data:text/html,foo')).toBe(false);
expect(isValidFrameOrigin('javascript:alert(1)')).toBe(false);
});
it('rejects bare schemes and wildcard hosts', () => {
expect(isValidFrameOrigin('https://')).toBe(false);
expect(isValidFrameOrigin('https://*')).toBe(false);
expect(isValidFrameOrigin('https://*.com')).toBe(false);
expect(isValidFrameOrigin('https://localhost')).toBe(false);
});
it('rejects paths, queries, and fragments', () => {
expect(isValidFrameOrigin('https://example.com/embed')).toBe(false);
expect(isValidFrameOrigin('https://example.com/')).toBe(false);
expect(isValidFrameOrigin('https://example.com?x=1')).toBe(false);
expect(isValidFrameOrigin('https://example.com#x')).toBe(false);
});
it('rejects userinfo, IPs, and IPv6', () => {
expect(isValidFrameOrigin('https://user:pass@example.com')).toBe(false);
expect(isValidFrameOrigin('https://1.2.3.4')).toBe(false);
expect(isValidFrameOrigin('https://[::1]')).toBe(false);
});
it('rejects values that try to break out of the directive', () => {
expect(isValidFrameOrigin("https://example.com'; script-src 'unsafe-eval")).toBe(false);
expect(isValidFrameOrigin('https://example.com" data:')).toBe(false);
expect(isValidFrameOrigin('https://example.com data:')).toBe(false);
expect(isValidFrameOrigin('https://example.com\nhttps://evil.com')).toBe(false);
expect(isValidFrameOrigin('https://example.com;https://evil.com')).toBe(false);
expect(isValidFrameOrigin('https://exa,mple.com')).toBe(false);
});
it('rejects non-strings and obvious garbage', () => {
expect(isValidFrameOrigin(undefined)).toBe(false);
expect(isValidFrameOrigin(null)).toBe(false);
expect(isValidFrameOrigin(42)).toBe(false);
expect(isValidFrameOrigin('')).toBe(false);
expect(isValidFrameOrigin('not-a-url')).toBe(false);
expect(isValidFrameOrigin('a'.repeat(300))).toBe(false);
});
});
describe('sanitizeFrameOrigins', () => {
it('returns empty for non-array input', () => {
expect(sanitizeFrameOrigins(undefined)).toEqual([]);
expect(sanitizeFrameOrigins(null)).toEqual([]);
expect(sanitizeFrameOrigins('https://example.com')).toEqual([]);
expect(sanitizeFrameOrigins({})).toEqual([]);
});
it('keeps valid entries and drops invalid ones silently', () => {
expect(
sanitizeFrameOrigins([
'https://www.youtube-nocookie.com',
'http://insecure.com',
'https://meet.example.com:8443',
'https://example.com/path',
42,
'https://*.vimeo.com',
]),
).toEqual([
'https://www.youtube-nocookie.com',
'https://meet.example.com:8443',
'https://*.vimeo.com',
]);
});
it('dedupes case-insensitively', () => {
expect(
sanitizeFrameOrigins([
'https://Example.com',
'https://example.com',
'https://EXAMPLE.com',
]),
).toEqual(['https://Example.com']);
});
});
+2
View File
@@ -47,6 +47,8 @@ function resetStore() {
'email-banner': [],
'email-footer': [],
'composer-toolbar': [],
'composer-sidebar': [],
'composer-sidebar-right': [],
'sidebar-widget': [],
'email-detail-sidebar': [],
'settings-section': [],
+124
View File
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const lookup = vi.fn();
vi.mock('node:dns/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:dns/promises')>();
return {
...actual,
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
lookup: (...args: unknown[]) => lookup(...args),
};
});
describe('isPublicHttpUrl', () => {
beforeEach(() => {
lookup.mockReset();
});
afterEach(() => {
vi.resetModules();
});
async function load() {
const mod = await import('@/lib/security/url-guard');
return mod.isPublicHttpUrl;
}
it('accepts public https URLs whose DNS resolves to a public address', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://example.com/jmap')).toBe(true);
});
it('rejects malformed URLs', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('not a url')).toBe(false);
expect(await isPublicHttpUrl('')).toBe(false);
});
it('rejects non-http(s) protocols', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('file:///etc/passwd')).toBe(false);
expect(await isPublicHttpUrl('gopher://example.com/')).toBe(false);
expect(await isPublicHttpUrl('javascript:alert(1)')).toBe(false);
});
it('rejects URLs with embedded credentials', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://user:pass@example.com/')).toBe(false);
expect(await isPublicHttpUrl('https://user@example.com/')).toBe(false);
});
it('rejects loopback hostnames without DNS', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('http://localhost/')).toBe(false);
expect(await isPublicHttpUrl('http://service.localhost/')).toBe(false);
expect(await isPublicHttpUrl('http://server.local/')).toBe(false);
expect(await isPublicHttpUrl('http://kube.internal/api')).toBe(false);
expect(await isPublicHttpUrl('http://1.0.0.127.in-addr.arpa/')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects literal IPv4 loopback and RFC-1918 ranges', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('http://127.0.0.1/')).toBe(false);
expect(await isPublicHttpUrl('http://10.0.0.5/')).toBe(false);
expect(await isPublicHttpUrl('http://10.255.255.255/')).toBe(false);
expect(await isPublicHttpUrl('http://172.16.0.1/')).toBe(false);
expect(await isPublicHttpUrl('http://172.31.255.254/')).toBe(false);
expect(await isPublicHttpUrl('http://192.168.1.1/')).toBe(false);
expect(await isPublicHttpUrl('http://0.0.0.0/')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects literal AWS / GCP / Azure metadata IP', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('http://169.254.169.254/latest/meta-data/')).toBe(false);
expect(await isPublicHttpUrl('http://169.254.0.1/')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects IPv6 loopback, ULA, and link-local literals', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('http://[::1]/')).toBe(false);
expect(await isPublicHttpUrl('http://[::]/')).toBe(false);
expect(await isPublicHttpUrl('http://[fc00::1]/')).toBe(false);
expect(await isPublicHttpUrl('http://[fd12:3456::1]/')).toBe(false);
expect(await isPublicHttpUrl('http://[fe80::1]/')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects when DNS resolves to a private address (rebinding)', async () => {
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://evil.example.com/')).toBe(false);
});
it('rejects when any resolved address is private (mixed)', async () => {
lookup.mockResolvedValue([
{ address: '93.184.216.34', family: 4 },
{ address: '10.0.0.1', family: 4 },
]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://mixed.example.com/')).toBe(false);
});
it('rejects when DNS resolves to IPv6 loopback', async () => {
lookup.mockResolvedValue([{ address: '::1', family: 6 }]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://evil6.example.com/')).toBe(false);
});
it('rejects when DNS lookup throws', async () => {
lookup.mockRejectedValue(new Error('ENOTFOUND'));
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://nonexistent.example.com/')).toBe(false);
});
it('rejects when DNS returns no records', async () => {
lookup.mockResolvedValue([]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://empty.example.com/')).toBe(false);
});
});
+72 -3
View File
@@ -34,11 +34,11 @@ describe("parseVCard", () => {
expect(result).toHaveLength(1);
const components = result[0].name?.components || [];
expect(components).toEqual([
{ kind: "prefix", value: "Mr." },
{ kind: "title", value: "Mr." },
{ kind: "given", value: "John" },
{ kind: "additional", value: "Michael" },
{ kind: "given2", value: "Michael" },
{ kind: "surname", value: "Doe" },
{ kind: "suffix", value: "Jr." },
{ kind: "generation", value: "Jr." },
]);
});
@@ -52,6 +52,21 @@ describe("parseVCard", () => {
expect(components.find((c) => c.kind === "surname")?.value).toBe("Doe");
});
it("maps prefix and middle name to RFC 9553 standard kinds (issue #224)", () => {
// N: family;given;additional;prefix;suffix (RFC 6350 order)
const withPrefix = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;;Mr.;\r\nEMAIL:j@example.com\r\nEND:VCARD`);
const c1 = withPrefix[0].name?.components || [];
expect(c1.find((c) => c.kind === "surname")?.value).toBe("Smith");
expect(c1.find((c) => c.kind === "given")?.value).toBe("John");
expect(c1.find((c) => c.kind === "title")?.value).toBe("Mr.");
const withMiddle = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;Mike;;\r\nEMAIL:j@example.com\r\nEND:VCARD`);
const c2 = withMiddle[0].name?.components || [];
expect(c2.find((c) => c.kind === "surname")?.value).toBe("Smith");
expect(c2.find((c) => c.kind === "given")?.value).toBe("John");
expect(c2.find((c) => c.kind === "given2")?.value).toBe("Mike");
});
it("parses vCard with phone, org, and address", () => {
const vcf = [
"BEGIN:VCARD",
@@ -204,6 +219,60 @@ describe("parseVCard", () => {
expect(result[0].kind).toBe("group");
});
it("decodes ENCODING=QUOTED-PRINTABLE values with UTF-8 charset", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:2.1",
"N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;Hans;;;",
"FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans M=C3=BCller",
"NOTE;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Caf=C3=A9 stra=C3=9Fe",
"EMAIL:hans@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
const components = card.name?.components || [];
expect(components.find((c) => c.kind === "given")?.value).toBe("Hans");
expect(components.find((c) => c.kind === "surname")?.value).toBe("Müller");
expect(card.notes?.n0?.note).toBe("Café straße");
});
it("joins QUOTED-PRINTABLE soft line breaks (= at end of line)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:2.1",
"FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans=20J=",
"=C3=BCrgen=20M=C3=BCller",
"EMAIL:hj@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const components = result[0].name?.components || [];
const given = components.find((c) => c.kind === "given")?.value;
const surname = components.find((c) => c.kind === "surname")?.value;
expect(given).toBe("Hans");
expect(surname).toBe("Jürgen Müller");
});
it("recognizes bare QUOTED-PRINTABLE encoding parameter (vCard 2.1 style)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:2.1",
"FN;QUOTED-PRINTABLE;CHARSET=UTF-8:Caf=C3=A9",
"EMAIL:c@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const components = result[0].name?.components || [];
expect(components.find((c) => c.kind === "given")?.value).toBe("Café");
});
it("parses GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, SOURCE", () => {
const vcf = [
"BEGIN:VCARD",
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const lookup = vi.fn();
vi.mock('node:dns/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:dns/promises')>();
return {
...actual,
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
lookup: (...args: unknown[]) => lookup(...args),
};
});
describe('verifyJmapAuth SSRF protection', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
lookup.mockReset();
vi.resetModules();
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
async function load() {
const mod = await import('@/lib/auth/verify-jmap-auth');
return mod;
}
it('rejects loopback literal without issuing fetch', async () => {
const { verifyJmapAuth, JmapAuthVerificationError } = await load();
await expect(verifyJmapAuth('http://127.0.0.1', 'Bearer x')).rejects.toBeInstanceOf(
JmapAuthVerificationError,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects AWS IMDS endpoint without issuing fetch', async () => {
const { verifyJmapAuth } = await load();
await expect(
verifyJmapAuth('http://169.254.169.254', 'Bearer x'),
).rejects.toMatchObject({ status: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects RFC-1918 literals without issuing fetch', async () => {
const { verifyJmapAuth } = await load();
for (const target of ['http://10.0.0.5', 'http://172.16.0.1', 'http://192.168.1.1']) {
await expect(verifyJmapAuth(target, 'Bearer x')).rejects.toMatchObject({ status: 400 });
}
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects localhost hostname without issuing fetch', async () => {
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('http://localhost', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects IPv6 loopback literal without issuing fetch', async () => {
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('http://[::1]', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects hostnames whose DNS resolves to a private IP without issuing fetch', async () => {
lookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://internal.example.com', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects file:// URLs', async () => {
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('file:///etc/passwd', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('refuses to follow a redirect to a private address', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
fetchSpy.mockResolvedValueOnce(
new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/.well-known/jmap' } }),
);
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('refuses to follow a redirect to AWS IMDS', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
fetchSpy.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
}),
);
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('accepts a public host that returns a valid JMAP session', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ apiUrl: 'https://example.com/api', accounts: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).resolves.toBe(
'https://example.com',
);
expect(fetchSpy).toHaveBeenCalledWith(
'https://example.com/.well-known/jmap',
expect.objectContaining({ redirect: 'manual' }),
);
});
it('rejects an invalid Authorization header before any fetch', async () => {
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://example.com', 'NotAuth')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+16 -2
View File
@@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
function getAdminDir(): string {
@@ -64,6 +65,12 @@ class ConfigManager {
if (envVal !== undefined) {
return parseEnvValue(envVal, mapping.type) as T;
}
if (mapping.fileEnvVar) {
const fileVal = readFileEnv(process.env[mapping.fileEnvVar]);
if (fileVal !== null) {
return parseEnvValue(fileVal, mapping.type) as T;
}
}
if (defaultValue !== undefined) return defaultValue;
return mapping.defaultValue as T;
}
@@ -94,9 +101,16 @@ class ConfigManager {
const envVal = process.env[mapping.envVar];
if (envVal !== undefined) {
result[key] = { value: parseEnvValue(envVal, mapping.type), source: 'env' };
} else {
result[key] = { value: mapping.defaultValue, source: 'default' };
continue;
}
if (mapping.fileEnvVar) {
const fileVal = readFileEnv(process.env[mapping.fileEnvVar]);
if (fileVal !== null) {
result[key] = { value: parseEnvValue(fileVal, mapping.type), source: 'env' };
continue;
}
}
result[key] = { value: mapping.defaultValue, source: 'default' };
}
}
return result;
+102
View File
@@ -0,0 +1,102 @@
/**
* Computes the union of CSP `frame-src` origins declared by installed and
* enabled plugins. The proxy reads this on each request so that plugins can
* embed external content (YouTube, Vimeo, Jitsi, ) without us hard-coding
* domains in the host CSP.
*
* Origins are validated at install time and re-validated here as defense in
* depth any malformed value is dropped so a corrupted registry can never
* inject arbitrary CSP fragments.
*/
import { getPluginRegistry } from './plugin-registry';
// `https://host`, `https://host:port`, or `https://*.host[:port]`
//
// Each label is alphanumeric with optional inner dashes; the final TLD label
// MUST start with a letter so we reject raw IPv4 literals.
//
// Disallowed by the regex (intentionally):
// - any scheme other than https
// - paths, queries, fragments
// - userinfo, IPv4 literals, IPv6 literals (`[::1]`)
// - bare wildcards (`https://*`)
const FRAME_ORIGIN_RE =
/^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i;
export function isValidFrameOrigin(origin: unknown): origin is string {
if (typeof origin !== 'string') return false;
if (origin.length > 200) return false;
if (!FRAME_ORIGIN_RE.test(origin)) return false;
// Reject control characters / whitespace as a final safeguard against
// anything that would let an attacker break out of the directive.
if (/[\s'"`;,()]/.test(origin)) return false;
return true;
}
/**
* Sanitises a list of candidate origins from a manifest. Drops invalid
* entries silently and dedupes (case-insensitive on the host).
*/
export function sanitizeFrameOrigins(input: unknown): string[] {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const value of input) {
if (!isValidFrameOrigin(value)) continue;
const key = value.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(value);
}
return out;
}
// In-memory cache. The proxy fires on every page navigation; reading the
// registry JSON every time is fine but cheap to skip when nothing has
// changed. Five seconds is short enough to make plugin install/uninstall
// feel snappy without measurable overhead.
let cachedAt = 0;
let cachedOrigins: string[] = [];
const CACHE_TTL_MS = 5_000;
/**
* Returns the union of frame origins declared by every enabled plugin in
* the server-side registry, deduped and validated.
*
* Returns an empty array on any failure (missing file, parse error, ) so
* a broken registry only ever shrinks the CSP never widens it.
*/
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
const now = Date.now();
if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
try {
const registry = await getPluginRegistry();
const seen = new Set<string>();
const out: string[] = [];
for (const plugin of registry.plugins) {
if (!plugin.enabled) continue;
const origins = sanitizeFrameOrigins(plugin.frameOrigins);
for (const o of origins) {
const key = o.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(o);
}
}
cachedOrigins = out;
cachedAt = now;
return out;
} catch {
cachedOrigins = [];
cachedAt = now;
return [];
}
}
/** Force the next call to re-read the registry. Used by install/uninstall. */
export function invalidateFrameOriginsCache(): void {
cachedAt = 0;
cachedOrigins = [];
}
+5
View File
@@ -41,6 +41,11 @@ export interface ServerPlugin {
configSchema?: Record<string, PluginConfigField>;
installedAt: string;
updatedAt: string;
/**
* Validated CSP origins (https-only, single-origin form) the plugin may
* embed. Merged into the host frame-src by the proxy.
*/
frameOrigins?: string[];
}
export interface ServerTheme {
+3 -3
View File
@@ -106,7 +106,7 @@ export interface AuditEntry {
}
/** Config keys that map to environment variables */
export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
@@ -124,7 +124,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false },
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
@@ -134,7 +134,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false },
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
sessionSecret: { envVar: 'SESSION_SECRET', type: 'string', defaultValue: '' },
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
};
/** Keys that should never be exposed to the client config endpoint */
+41 -6
View File
@@ -1,4 +1,7 @@
import { isPublicHttpUrl } from '@/lib/security/url-guard';
const VERIFY_TIMEOUT_MS = 10000;
const MAX_REDIRECTS = 3;
export class JmapAuthVerificationError extends Error {
status: number;
@@ -41,15 +44,47 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
validateProxyAuthHeader(authHeader);
if (!(await isPublicHttpUrl(normalizedServerUrl))) {
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
try {
const response = await fetch(`${normalizedServerUrl}/.well-known/jmap`, {
method: 'GET',
headers: { Authorization: authHeader },
signal: controller.signal,
});
let currentUrl = `${normalizedServerUrl}/.well-known/jmap`;
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!(await isPublicHttpUrl(currentUrl))) {
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
}
response = await fetch(currentUrl, {
method: 'GET',
headers: { Authorization: authHeader },
signal: controller.signal,
redirect: 'manual',
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
}
currentUrl = new URL(location, currentUrl).toString();
continue;
}
break;
}
if (!response) {
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
}
if (response.status >= 300 && response.status < 400) {
throw new JmapAuthVerificationError('Too many redirects verifying JMAP session', 502);
}
if (!response.ok) {
throw new JmapAuthVerificationError(
@@ -77,4 +112,4 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro
} finally {
clearTimeout(timeout);
}
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ export function createBirthdayCalendar(name?: string, color?: string): Calendar
mayWriteOwn: false,
mayUpdatePrivate: false,
mayRSVP: false,
mayAdmin: false,
mayShare: false,
mayDelete: false,
},
};
+10 -1
View File
@@ -78,6 +78,10 @@ export class DemoJMAPClient implements IJMAPClient {
supportsCalendars(): boolean { return true; }
supportsSieve(): boolean { return true; }
supportsFiles(): boolean { return true; }
supportsPrincipals(): boolean { return false; }
async getPrincipals(): Promise<never[]> { return []; }
async setCalendarShare(): Promise<void> { /* demo: no-op */ }
async setAddressBookShare(): Promise<void> { /* demo: no-op */ }
// ── Push / state ──────────────────────────────────────────────
@@ -552,6 +556,11 @@ export class DemoJMAPClient implements IJMAPClient {
if (book) Object.assign(book, updates);
}
async deleteAddressBook(addressBookId: string): Promise<void> {
this.data.addressBooks = this.data.addressBooks.filter(b => b.id !== addressBookId);
this.data.contacts = this.data.contacts.filter(c => !c.addressBookIds?.[addressBookId]);
}
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
return [...this.data.contacts];
@@ -609,7 +618,7 @@ export class DemoJMAPClient implements IJMAPClient {
includeInAvailability: 'all',
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
timeZone: null, shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
...calendar,
} as Calendar;
this.data.calendars.push(full);
+3 -3
View File
@@ -17,7 +17,7 @@ export function createDemoCalendars(): Calendar[] {
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false },
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: false },
},
{
id: 'demo-calendar-work',
@@ -33,7 +33,7 @@ export function createDemoCalendars(): Calendar[] {
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
},
{
id: 'demo-calendar-birthdays',
@@ -49,7 +49,7 @@ export function createDemoCalendars(): Calendar[] {
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
},
];
}
+8 -1
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
/**
@@ -190,6 +190,7 @@ export interface IJMAPClient {
getAllAddressBooks(): Promise<AddressBook[]>;
createAddressBook(name: string): Promise<AddressBook>;
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void>;
getContacts(addressBookId?: string): Promise<ContactCard[]>;
getAllContacts(): Promise<ContactCard[]>;
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
@@ -227,6 +228,12 @@ export interface IJMAPClient {
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
// ── Sharing (RFC 9670 Principals) ─────────────────────────────
supportsPrincipals(): boolean;
getPrincipals(targetAccountId?: string): Promise<Principal[]>;
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
// ── Sieve / Filters ──────────────────────────────────────────
getSieveAccountId(): string;
getSieveCapabilities(): SieveCapabilities | null;
+101 -1
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
@@ -2826,6 +2826,10 @@ export class JMAPClient implements IJMAPClient {
return this.hasCapability("urn:ietf:params:jmap:sieve");
}
supportsPrincipals(): boolean {
return this.hasCapability("urn:ietf:params:jmap:principals");
}
getSieveAccountId(): string {
const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"];
return sieveAccount || this.accountId;
@@ -3198,6 +3202,102 @@ export class JMAPClient implements IJMAPClient {
throw new Error("Failed to update address book");
}
async deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
const response = await this.request([
["AddressBook/set", { accountId, destroy: [addressBookId] }, "0"],
], this.contactUsing());
const result = response.methodResponses?.[0]?.[1];
if (result?.notDestroyed?.[addressBookId]) {
const err = result.notDestroyed[addressBookId];
throw new Error(err.description || "Failed to delete address book");
}
}
// ── Sharing (RFC 9670) ──────────────────────────────────────────────────────
private principalsUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:principals"];
}
/**
* List all principals visible to the user (RFC 9670). Stalwart returns the
* full directory regardless of `filter`, so we fetch the whole list and let
* callers filter client-side.
*/
async getPrincipals(targetAccountId?: string): Promise<Principal[]> {
if (!this.supportsPrincipals()) return [];
const accountId = targetAccountId || this.accountId;
try {
const response = await this.request([
["Principal/query", { accountId }, "0"],
["Principal/get", {
accountId,
"#ids": { resultOf: "0", name: "Principal/query", path: "/ids" },
}, "1"],
], this.principalsUsing());
const getResp = response.methodResponses?.find((r) => r[0] === "Principal/get");
if (!getResp) return [];
const list = (getResp[1].list || []) as Principal[];
return list.map((p) => ({ ...p, accountId }));
} catch (error) {
console.error("Failed to fetch principals:", error);
return [];
}
}
/**
* Add, update, or remove a principal's rights on a calendar.
* Pass `rights: null` to revoke access.
*/
async setCalendarShare(
calendarId: string,
principalId: string,
rights: CalendarRights | null,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || this.getCalendarsAccountId();
const response = await this.request([
["Calendar/set", {
accountId,
update: { [calendarId]: { [`shareWith/${principalId}`]: rights } },
}, "0"],
], this.calendarUsing());
const result = response.methodResponses?.[0]?.[1];
if (result?.notUpdated?.[calendarId]) {
const err = result.notUpdated[calendarId];
throw new Error(err.description || "Failed to update calendar share");
}
}
/**
* Add, update, or remove a principal's rights on an address book.
* Pass `rights: null` to revoke access.
*/
async setAddressBookShare(
addressBookId: string,
principalId: string,
rights: AddressBookRights | null,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
const response = await this.request([
["AddressBook/set", {
accountId,
update: { [addressBookId]: { [`shareWith/${principalId}`]: rights } },
}, "0"],
], this.contactUsing());
const result = response.methodResponses?.[0]?.[1];
if (result?.notUpdated?.[addressBookId]) {
const err = result.notUpdated[addressBookId];
throw new Error(err.description || "Failed to update address book share");
}
}
private async fetchPaginatedContacts(
accountId: string,
filter?: Record<string, unknown>,
+15 -2
View File
@@ -368,6 +368,7 @@ export interface AddressBook {
isDefault?: boolean;
isSubscribed?: boolean;
myRights?: AddressBookRights;
shareWith?: Record<string, AddressBookRights> | null;
accountId?: string;
accountName?: string;
isShared?: boolean;
@@ -376,10 +377,22 @@ export interface AddressBook {
export interface AddressBookRights {
mayRead: boolean;
mayWrite: boolean;
mayShare: boolean;
mayShare?: boolean;
mayDelete: boolean;
}
// JMAP Principals (RFC 9670)
export interface Principal {
id: string;
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
name: string;
description?: string | null;
email?: string | null;
timeZone?: string | null;
capabilities?: Record<string, unknown>;
accountId?: string;
}
export interface VacationResponse {
id: string;
isEnabled: boolean;
@@ -442,7 +455,7 @@ export interface CalendarRights {
mayWriteOwn: boolean;
mayUpdatePrivate: boolean;
mayRSVP: boolean;
mayAdmin: boolean;
mayShare: boolean;
mayDelete: boolean;
}
+7
View File
@@ -121,6 +121,7 @@ export interface PluginAPI {
registerSettingsSection: (section: SettingsSection) => Disposable;
registerComposerAction: (action: ComposerAction) => Disposable;
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
registerComposerSidebar: (widget: SidebarWidget) => Disposable;
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
@@ -609,6 +610,12 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
},
registerComposerSidebar: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:composer-sidebar');
const slot = widget.side === 'right' ? 'composer-sidebar-right' : 'composer-sidebar';
return registerSlot(plugin.id, slot, widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
},
registerDetailSidebar: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:sidebar-widget');
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
+18 -1
View File
@@ -41,6 +41,14 @@ export interface PluginManifest {
* so plugins can use api.i18n.t() without calling addTranslations() first.
*/
locales?: Record<string, Record<string, string>>;
/**
* External origins this plugin may embed in iframes (e.g. for YouTube,
* Vimeo, Jitsi). Each entry is a single CSP origin like
* "https://www.youtube-nocookie.com"
* "https://*.example.com:8443"
* Validated at install time and merged into the host CSP `frame-src`.
*/
frameOrigins?: string[];
}
export interface SettingFieldSchema {
@@ -101,6 +109,8 @@ export type SlotName =
| 'email-banner'
| 'email-footer'
| 'composer-toolbar'
| 'composer-sidebar'
| 'composer-sidebar-right'
| 'sidebar-widget'
| 'email-detail-sidebar'
| 'settings-section'
@@ -150,6 +160,12 @@ export interface SidebarWidget {
label: string;
render: React.ComponentType;
order?: number;
/**
* For composer sidebars, choose which side of the New Message dialog the
* panel renders on. Defaults to `'left'` for backwards compatibility.
* Ignored by other sidebar slots.
*/
side?: 'left' | 'right';
}
export interface ContextMenuItem {
@@ -495,7 +511,8 @@ export const ALL_PERMISSIONS = [
'auth:observe',
'http:post',
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
'ui:composer-toolbar', 'ui:composer-sidebar',
'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
'ui:calendar-action', 'ui:admin-page',
'admin:config',
+67
View File
@@ -0,0 +1,67 @@
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
const blockedAddressRanges = new BlockList();
blockedAddressRanges.addAddress('0.0.0.0');
blockedAddressRanges.addAddress('127.0.0.1');
blockedAddressRanges.addSubnet('10.0.0.0', 8);
blockedAddressRanges.addSubnet('172.16.0.0', 12);
blockedAddressRanges.addSubnet('192.168.0.0', 16);
blockedAddressRanges.addSubnet('169.254.0.0', 16);
blockedAddressRanges.addAddress('::', 'ipv6');
blockedAddressRanges.addAddress('::1', 'ipv6');
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
const BLOCKED_HOSTNAMES = new Set(['localhost']);
const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.arpa', '.localdomain'];
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
}
function isBlockedIpAddress(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized);
if (family === 4) return blockedAddressRanges.check(normalized, 'ipv4');
if (family === 6) return blockedAddressRanges.check(normalized, 'ipv6');
return false;
}
/**
* Returns true only when the URL targets a public host reachable over http(s).
* Rejects loopback / RFC-1918 / link-local / ULA addresses, special hostname
* suffixes (.local, .internal, .arpa, ...), URLs with embedded credentials,
* and any hostname whose DNS resolves to a blocked address.
*
* Note: there is a TOCTOU window between this lookup and the eventual fetch().
* Callers that need rebinding-safe behavior must additionally pin the resolved
* IP at connect time (e.g. via a custom undici dispatcher).
*/
export async function isPublicHttpUrl(urlString: string): Promise<boolean> {
let url: URL;
try {
url = new URL(urlString);
} catch {
return false;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
if (url.username || url.password) return false;
const hostname = normalizeHostname(url.hostname);
if (!hostname) return false;
if (BLOCKED_HOSTNAMES.has(hostname)) return false;
if (BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) return false;
if (isBlockedIpAddress(hostname)) return false;
if (isIP(hostname)) return true;
try {
const records = await lookup(hostname, { all: true, verbatim: true });
if (records.length === 0) return false;
return records.every((record) => !isBlockedIpAddress(record.address));
} catch {
return false;
}
}
+72 -11
View File
@@ -51,6 +51,53 @@ function unfoldLines(vcf: string): string {
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
// vCard 2.1 quoted-printable soft line breaks: a line ending in `=` continues
// onto the next line. This is distinct from RFC 5545/6350 line folding (which
// uses leading whitespace and is already handled in unfoldLines). Only merge
// when the originating line declares ENCODING=QUOTED-PRINTABLE so we don't
// accidentally splice unrelated lines.
function joinQpSoftBreaks(lines: string[]): string[] {
const result: string[] = [];
let i = 0;
while (i < lines.length) {
let line = lines[i];
if (/;ENCODING=QUOTED-PRINTABLE/i.test(line)) {
while (line.endsWith("=") && i + 1 < lines.length) {
i++;
line = line.slice(0, -1) + lines[i];
}
}
result.push(line);
i++;
}
return result;
}
function decodeQuotedPrintable(input: string, charset?: string): string {
const cleaned = input.replace(/=\r?\n/g, "");
const bytes: number[] = [];
let i = 0;
while (i < cleaned.length) {
const ch = cleaned[i];
if (ch === "=" && i + 2 < cleaned.length) {
const hex = cleaned.substring(i + 1, i + 3);
if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
bytes.push(parseInt(hex, 16));
i += 3;
continue;
}
}
bytes.push(cleaned.charCodeAt(i) & 0xff);
i += 1;
}
const label = (charset || "utf-8").toLowerCase();
try {
return new TextDecoder(label).decode(new Uint8Array(bytes));
} catch {
return new TextDecoder("utf-8").decode(new Uint8Array(bytes));
}
}
function decodeValue(raw: string): string {
return raw
.replace(/\\n/gi, "\n")
@@ -77,7 +124,9 @@ function parseParams(paramStr: string): Record<string, string> {
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
} else {
const upper = part.toUpperCase();
if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) {
if (upper === "QUOTED-PRINTABLE" || upper === "BASE64") {
params.ENCODING = upper;
} else if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) {
params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper;
}
}
@@ -118,7 +167,7 @@ function contextToType(contexts: Record<string, boolean> | undefined): string {
export function parseVCard(vcfString: string): ContactCard[] {
const text = unfoldLines(vcfString);
const lines = text.split("\n");
const lines = joinQpSoftBreaks(text.split("\n"));
const contacts: ContactCard[] = [];
let current: Record<string, string[]> | null = null;
@@ -163,8 +212,13 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : "";
const params = parseParams(paramStr);
const isQuotedPrintable = params.ENCODING?.toUpperCase() === "QUOTED-PRINTABLE";
for (const rawValue of values) {
const val = decodeValue(rawValue);
const decoded = isQuotedPrintable
? decodeQuotedPrintable(rawValue, params.CHARSET)
: rawValue;
const val = decodeValue(decoded);
switch (propName) {
case "FN":
@@ -182,13 +236,17 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
break;
case "N": {
// vCard N: family;given;additional;prefix;suffix (RFC 6350 §6.2.2)
// Mapped to JSContact-standard kinds (RFC 9553 §2.2.1):
// prefix→title, additional→given2, suffix→generation.
// Pushed in natural display order so `isOrdered: true` renders correctly.
const nParts = val.split(";");
const components: NameComponent[] = [];
if (nParts[3]) components.push({ kind: "prefix", value: nParts[3] });
if (nParts[3]) components.push({ kind: "title", value: nParts[3] });
if (nParts[1]) components.push({ kind: "given", value: nParts[1] });
if (nParts[2]) components.push({ kind: "additional", value: nParts[2] });
if (nParts[2]) components.push({ kind: "given2", value: nParts[2] });
if (nParts[0]) components.push({ kind: "surname", value: nParts[0] });
if (nParts[4]) components.push({ kind: "suffix", value: nParts[4] });
if (nParts[4]) components.push({ kind: "generation", value: nParts[4] });
if (components.length > 0) {
card.name = { components, isOrdered: true };
}
@@ -558,11 +616,14 @@ function generateSingleVCard(contact: ContactCard): string {
}
const components = contact.name?.components || [];
const given = components.find(c => c.kind === "given")?.value || "";
const surname = components.find(c => c.kind === "surname")?.value || "";
const prefix = components.find(c => c.kind === "prefix")?.value || "";
const suffix = components.find(c => c.kind === "suffix")?.value || "";
const additional = components.find(c => c.kind === "additional")?.value || "";
const findKind = (...kinds: string[]) =>
components.find(c => kinds.includes(c.kind))?.value || "";
const given = findKind("given");
const surname = findKind("surname");
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
const prefix = findKind("title", "prefix");
const suffix = findKind("generation", "suffix");
const additional = findKind("given2", "additional", "middle");
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
if (fn) {
File diff suppressed because it is too large Load Diff
+33 -2
View File
@@ -1815,7 +1815,13 @@
"renamed": "Adressbuch umbenannt",
"rename_failed": "Adressbuch konnte nicht umbenannt werden",
"default": "Standard",
"manage": "Adressbücher verwalten"
"manage": "Adressbücher verwalten",
"share": "Adressbuch freigeben",
"new_contact_in_book": "Neuer Kontakt in diesem Adressbuch",
"delete": "Adressbuch löschen",
"confirm_delete": "„{name}\" löschen? Alle Kontakte in diesem Adressbuch werden entfernt.",
"deleted": "Adressbuch gelöscht",
"delete_failed": "Adressbuch konnte nicht gelöscht werden"
},
"detail": {
"emails": "E-Mail-Adressen",
@@ -2340,7 +2346,9 @@
"confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.",
"clear_events": "Ereignisse löschen",
"events_cleared": "{count} Ereignisse gelöscht",
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden"
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden",
"share": "Kalender freigeben",
"new_event_in_calendar": "Neuer Termin in diesem Kalender"
},
"subscription": {
"title": "iCal-Abonnement",
@@ -2706,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar"
},
"sharing": {
"title": "„{name}\" freigeben",
"description": "Anderen Benutzern oder Gruppen auf diesem Server Zugriff gewähren. Änderungen werden sofort wirksam.",
"no_shares": "Noch nicht freigegeben.",
"add_person": "Person oder Gruppe hinzufügen",
"search_placeholder": "Nach Name oder E-Mail suchen…",
"loading_principals": "Benutzer werden geladen…",
"no_principals": "Keine weiteren Benutzer oder Gruppen gefunden.",
"no_match": "Keine Treffer.",
"remove": "Zugriff entfernen",
"group": "Gruppe",
"share_added": "Zugriff erteilt",
"share_updated": "Zugriff aktualisiert",
"share_removed": "Zugriff entfernt",
"share_failed": "Freigabe konnte nicht aktualisiert werden",
"preset": {
"freeBusy": "Nur Frei/Belegt",
"read": "Nur lesen",
"readWrite": "Lesen & schreiben",
"manager": "Verwalten",
"custom": "Benutzerdefiniert"
}
}
}
+36 -2
View File
@@ -454,6 +454,9 @@
"discard_draft_title": "Discard draft?",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
"saving": "Saving...",
"sending": "Sending...",
"add_link": "Add link",
"link_url_prompt": "Enter the URL",
"draft_saved": "Draft saved",
"save_failed": "Failed to save",
"to_placeholder": "Recipient email addresses",
@@ -1819,7 +1822,13 @@
"renamed": "Address book renamed",
"rename_failed": "Failed to rename address book",
"default": "Default",
"manage": "Manage address books"
"manage": "Manage address books",
"share": "Share address book",
"new_contact_in_book": "New contact in this address book",
"delete": "Delete address book",
"confirm_delete": "Delete \"{name}\"? All contacts in this address book will be removed.",
"deleted": "Address book deleted",
"delete_failed": "Failed to delete address book"
},
"detail": {
"emails": "Email Addresses",
@@ -2344,7 +2353,9 @@
"error_delete": "Failed to delete calendar",
"caldav_url": "CalDAV URL",
"copy_url": "Copy CalDAV URL",
"url_copied": "CalDAV URL copied to clipboard"
"url_copied": "CalDAV URL copied to clipboard",
"share": "Share calendar",
"new_event_in_calendar": "New event in this calendar"
},
"subscription": {
"title": "iCal Subscription",
@@ -2426,6 +2437,29 @@
"overdue": "Overdue"
}
},
"sharing": {
"title": "Share \"{name}\"",
"description": "Grant access to other users or groups on this server. Changes take effect immediately.",
"no_shares": "Not shared with anyone yet.",
"add_person": "Add person or group",
"search_placeholder": "Search by name or email…",
"loading_principals": "Loading users…",
"no_principals": "No other users or groups found.",
"no_match": "No matches.",
"remove": "Remove access",
"group": "Group",
"share_added": "Access granted",
"share_updated": "Access updated",
"share_removed": "Access removed",
"share_failed": "Failed to update sharing",
"preset": {
"freeBusy": "Free/busy only",
"read": "Read only",
"readWrite": "Read & write",
"manager": "Manager",
"custom": "Custom"
}
},
"advanced_search": {
"title": "Advanced Search",
"from": "From",
+33 -2
View File
@@ -1815,7 +1815,13 @@
"renamed": "Libreta de direcciones renombrada",
"rename_failed": "Error al renombrar la libreta de direcciones",
"default": "Predeterminada",
"manage": "Administrar libretas de direcciones"
"manage": "Administrar libretas de direcciones",
"share": "Compartir libreta de direcciones",
"new_contact_in_book": "Nuevo contacto en esta libreta",
"delete": "Eliminar libreta de direcciones",
"confirm_delete": "¿Eliminar «{name}»? Todos los contactos de esta libreta se eliminarán.",
"deleted": "Libreta de direcciones eliminada",
"delete_failed": "No se pudo eliminar la libreta de direcciones"
},
"detail": {
"emails": "Direcciones de correo",
@@ -2340,7 +2346,9 @@
"confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.",
"clear_events": "Borrar eventos",
"events_cleared": "{count} eventos borrados",
"error_clear": "No se pudieron borrar los eventos del calendario"
"error_clear": "No se pudieron borrar los eventos del calendario",
"share": "Compartir calendario",
"new_event_in_calendar": "Nuevo evento en este calendario"
},
"subscription": {
"title": "Suscripción iCal",
@@ -2706,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "La búsqueda no está disponible en la vista unificada"
},
"sharing": {
"title": "Compartir «{name}»",
"description": "Concede acceso a otros usuarios o grupos de este servidor. Los cambios surten efecto inmediatamente.",
"no_shares": "Aún no se ha compartido con nadie.",
"add_person": "Añadir persona o grupo",
"search_placeholder": "Buscar por nombre o correo…",
"loading_principals": "Cargando usuarios…",
"no_principals": "No se han encontrado otros usuarios ni grupos.",
"no_match": "Sin resultados.",
"remove": "Quitar acceso",
"group": "Grupo",
"share_added": "Acceso concedido",
"share_updated": "Acceso actualizado",
"share_removed": "Acceso retirado",
"share_failed": "No se pudo actualizar el uso compartido",
"preset": {
"freeBusy": "Solo disponibilidad",
"read": "Solo lectura",
"readWrite": "Lectura y escritura",
"manager": "Administrador",
"custom": "Personalizado"
}
}
}
+33 -2
View File
@@ -1815,7 +1815,13 @@
"renamed": "Carnet d'adresses renommé",
"rename_failed": "Échec du renommage du carnet d'adresses",
"default": "Par défaut",
"manage": "Gérer les carnets d'adresses"
"manage": "Gérer les carnets d'adresses",
"share": "Partager le carnet d'adresses",
"new_contact_in_book": "Nouveau contact dans ce carnet d'adresses",
"delete": "Supprimer le carnet d'adresses",
"confirm_delete": "Supprimer « {name} » ? Tous les contacts de ce carnet d'adresses seront supprimés.",
"deleted": "Carnet d'adresses supprimé",
"delete_failed": "Échec de la suppression du carnet d'adresses"
},
"detail": {
"emails": "Adresses e-mail",
@@ -2340,7 +2346,9 @@
"confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.",
"clear_events": "Supprimer les événements",
"events_cleared": "{count} événements supprimés",
"error_clear": "Impossible de supprimer les événements du calendrier"
"error_clear": "Impossible de supprimer les événements du calendrier",
"share": "Partager le calendrier",
"new_event_in_calendar": "Nouvel événement dans ce calendrier"
},
"subscription": {
"title": "Abonnement iCal",
@@ -2706,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "La recherche n'est pas disponible dans la vue unifiée"
},
"sharing": {
"title": "Partager « {name} »",
"description": "Accordez l'accès à d'autres utilisateurs ou groupes de ce serveur. Les modifications sont immédiates.",
"no_shares": "Pas encore partagé.",
"add_person": "Ajouter une personne ou un groupe",
"search_placeholder": "Rechercher par nom ou e-mail…",
"loading_principals": "Chargement des utilisateurs…",
"no_principals": "Aucun autre utilisateur ou groupe trouvé.",
"no_match": "Aucun résultat.",
"remove": "Révoquer l'accès",
"group": "Groupe",
"share_added": "Accès accordé",
"share_updated": "Accès mis à jour",
"share_removed": "Accès révoqué",
"share_failed": "Échec de la mise à jour du partage",
"preset": {
"freeBusy": "Disponibilité uniquement",
"read": "Lecture seule",
"readWrite": "Lecture & écriture",
"manager": "Gestionnaire",
"custom": "Personnalisé"
}
}
}
+33 -2
View File
@@ -1815,7 +1815,13 @@
"renamed": "Rubrica rinominata",
"rename_failed": "Impossibile rinominare la rubrica",
"default": "Predefinita",
"manage": "Gestisci rubriche"
"manage": "Gestisci rubriche",
"share": "Condividi rubrica",
"new_contact_in_book": "Nuovo contatto in questa rubrica",
"delete": "Elimina rubrica",
"confirm_delete": "Eliminare \"{name}\"? Tutti i contatti in questa rubrica verranno rimossi.",
"deleted": "Rubrica eliminata",
"delete_failed": "Impossibile eliminare la rubrica"
},
"detail": {
"emails": "Indirizzi email",
@@ -2340,7 +2346,9 @@
"confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.",
"clear_events": "Cancella eventi",
"events_cleared": "{count} eventi cancellati",
"error_clear": "Impossibile cancellare gli eventi del calendario"
"error_clear": "Impossibile cancellare gli eventi del calendario",
"share": "Condividi calendario",
"new_event_in_calendar": "Nuovo evento in questo calendario"
},
"subscription": {
"title": "Abbonamento iCal",
@@ -2706,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "La ricerca non è disponibile nella vista unificata"
},
"sharing": {
"title": "Condividi \"{name}\"",
"description": "Concedi l'accesso ad altri utenti o gruppi su questo server. Le modifiche hanno effetto immediato.",
"no_shares": "Non ancora condiviso.",
"add_person": "Aggiungi persona o gruppo",
"search_placeholder": "Cerca per nome o email…",
"loading_principals": "Caricamento utenti…",
"no_principals": "Nessun altro utente o gruppo trovato.",
"no_match": "Nessun risultato.",
"remove": "Rimuovi accesso",
"group": "Gruppo",
"share_added": "Accesso concesso",
"share_updated": "Accesso aggiornato",
"share_removed": "Accesso rimosso",
"share_failed": "Impossibile aggiornare la condivisione",
"preset": {
"freeBusy": "Solo libero/occupato",
"read": "Sola lettura",
"readWrite": "Lettura e scrittura",
"manager": "Gestore",
"custom": "Personalizzato"
}
}
}
+33 -2
View File
@@ -1815,7 +1815,13 @@
"renamed": "アドレス帳の名前を変更しました",
"rename_failed": "アドレス帳の名前変更に失敗しました",
"default": "デフォルト",
"manage": "アドレス帳を管理"
"manage": "アドレス帳を管理",
"share": "アドレス帳を共有",
"new_contact_in_book": "このアドレス帳に新規連絡先",
"delete": "アドレス帳を削除",
"confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。",
"deleted": "アドレス帳を削除しました",
"delete_failed": "アドレス帳の削除に失敗しました"
},
"detail": {
"emails": "メールアドレス",
@@ -2340,7 +2346,9 @@
"confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。",
"clear_events": "イベントを削除",
"events_cleared": "{count}件のイベントを削除しました",
"error_clear": "カレンダーイベントの削除に失敗しました"
"error_clear": "カレンダーイベントの削除に失敗しました",
"share": "カレンダーを共有",
"new_event_in_calendar": "このカレンダーに新規イベント"
},
"subscription": {
"title": "iCal購読",
@@ -2706,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "統合ビューでは検索を利用できません"
},
"sharing": {
"title": "「{name}」を共有",
"description": "このサーバー上の他のユーザーまたはグループにアクセス権を付与します。変更はすぐに反映されます。",
"no_shares": "まだ誰にも共有されていません。",
"add_person": "ユーザーまたはグループを追加",
"search_placeholder": "名前またはメールで検索…",
"loading_principals": "ユーザーを読み込み中…",
"no_principals": "他のユーザーまたはグループは見つかりません。",
"no_match": "一致する項目がありません。",
"remove": "アクセス権を削除",
"group": "グループ",
"share_added": "アクセス権を付与しました",
"share_updated": "アクセス権を更新しました",
"share_removed": "アクセス権を削除しました",
"share_failed": "共有の更新に失敗しました",
"preset": {
"freeBusy": "空き時間情報のみ",
"read": "読み取り専用",
"readWrite": "読み取り・書き込み",
"manager": "管理者",
"custom": "カスタム"
}
}
}
+33 -2
View File
@@ -1815,7 +1815,13 @@
"renamed": "주소록 이름이 변경되었습니다",
"rename_failed": "주소록 이름 변경 실패",
"default": "기본",
"manage": "주소록 관리"
"manage": "주소록 관리",
"share": "주소록 공유",
"new_contact_in_book": "이 주소록에 새 연락처",
"delete": "주소록 삭제",
"confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.",
"deleted": "주소록이 삭제되었습니다",
"delete_failed": "주소록 삭제에 실패했습니다"
},
"detail": {
"emails": "이메일",
@@ -2340,7 +2346,9 @@
"error_delete": "캘린더를 삭제하지 못했어요",
"caldav_url": "CalDAV URL",
"copy_url": "CalDAV URL 복사",
"url_copied": "CalDAV URL이 클립보드에 복사되었어요"
"url_copied": "CalDAV URL이 클립보드에 복사되었어요",
"share": "캘린더 공유",
"new_event_in_calendar": "이 캘린더에 새 일정"
},
"subscription": {
"title": "iCal 구독",
@@ -2706,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다"
},
"sharing": {
"title": "\"{name}\" 공유",
"description": "이 서버의 다른 사용자나 그룹에 액세스 권한을 부여합니다. 변경 사항은 즉시 적용됩니다.",
"no_shares": "아직 공유되지 않았습니다.",
"add_person": "사용자 또는 그룹 추가",
"search_placeholder": "이름 또는 이메일로 검색…",
"loading_principals": "사용자 불러오는 중…",
"no_principals": "다른 사용자나 그룹을 찾을 수 없습니다.",
"no_match": "일치하는 항목이 없습니다.",
"remove": "액세스 권한 제거",
"group": "그룹",
"share_added": "액세스 권한이 부여되었습니다",
"share_updated": "액세스 권한이 업데이트되었습니다",
"share_removed": "액세스 권한이 제거되었습니다",
"share_failed": "공유 업데이트에 실패했습니다",
"preset": {
"freeBusy": "한가함/바쁨만",
"read": "읽기 전용",
"readWrite": "읽기 및 쓰기",
"manager": "관리자",
"custom": "사용자 지정"
}
}
}
+33 -2
View File
@@ -1811,7 +1811,13 @@
"renamed": "Adrešu grāmata pārdēvēta",
"rename_failed": "Neizdevās pārdēvēt adrešu grāmatu",
"default": "Noklusējuma",
"manage": "Pārvaldīt adrešu grāmatas"
"manage": "Pārvaldīt adrešu grāmatas",
"share": "Kopīgot adrešu grāmatu",
"new_contact_in_book": "Jauns kontakts šajā adrešu grāmatā",
"delete": "Dzēst adrešu grāmatu",
"confirm_delete": "Dzēst \"{name}\"? Visi kontakti šajā adrešu grāmatā tiks noņemti.",
"deleted": "Adrešu grāmata dzēsta",
"delete_failed": "Neizdevās dzēst adrešu grāmatu"
},
"detail": {
"emails": "E-pasta adreses",
@@ -2339,7 +2345,9 @@
"error_delete": "Neizdevās izdzēst kalendāru",
"caldav_url": "CalDAV URL",
"copy_url": "Kopēt CalDAV URL",
"url_copied": "CalDAV URL nokopēts starpliktuvē"
"url_copied": "CalDAV URL nokopēts starpliktuvē",
"share": "Kopīgot kalendāru",
"new_event_in_calendar": "Jauns notikums šajā kalendārā"
},
"subscription": {
"title": "iCal abonements",
@@ -2706,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "Meklēšana nav pieejama apvienotajā skatā"
},
"sharing": {
"title": "Kopīgot \"{name}\"",
"description": "Piešķiriet piekļuvi citiem lietotājiem vai grupām šajā serverī. Izmaiņas stājas spēkā nekavējoties.",
"no_shares": "Vēl nav kopīgots.",
"add_person": "Pievienot personu vai grupu",
"search_placeholder": "Meklēt pēc vārda vai e-pasta…",
"loading_principals": "Ielādē lietotājus…",
"no_principals": "Citi lietotāji vai grupas nav atrastas.",
"no_match": "Nav atbilstību.",
"remove": "Noņemt piekļuvi",
"group": "Grupa",
"share_added": "Piekļuve piešķirta",
"share_updated": "Piekļuve atjaunināta",
"share_removed": "Piekļuve noņemta",
"share_failed": "Neizdevās atjaunināt kopīgošanu",
"preset": {
"freeBusy": "Tikai brīvs/aizņemts",
"read": "Tikai lasīšana",
"readWrite": "Lasīšana un rakstīšana",
"manager": "Pārvaldnieks",
"custom": "Pielāgots"
}
}
}
+33 -2
View File
@@ -1815,7 +1815,13 @@
"renamed": "Adresboek hernoemd",
"rename_failed": "Adresboek hernoemen mislukt",
"default": "Standaard",
"manage": "Adresboeken beheren"
"manage": "Adresboeken beheren",
"share": "Adresboek delen",
"new_contact_in_book": "Nieuw contact in dit adresboek",
"delete": "Adresboek verwijderen",
"confirm_delete": "\"{name}\" verwijderen? Alle contacten in dit adresboek worden verwijderd.",
"deleted": "Adresboek verwijderd",
"delete_failed": "Adresboek kon niet worden verwijderd"
},
"detail": {
"emails": "E-mailadressen",
@@ -2340,7 +2346,9 @@
"confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.",
"clear_events": "Afspraken verwijderen",
"events_cleared": "{count} afspraken verwijderd",
"error_clear": "Kan agendagebeurtenissen niet verwijderen"
"error_clear": "Kan agendagebeurtenissen niet verwijderen",
"share": "Agenda delen",
"new_event_in_calendar": "Nieuwe afspraak in deze agenda"
},
"subscription": {
"title": "iCal-abonnement",
@@ -2706,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave"
},
"sharing": {
"title": "\"{name}\" delen",
"description": "Geef andere gebruikers of groepen op deze server toegang. Wijzigingen zijn direct van kracht.",
"no_shares": "Nog niet gedeeld.",
"add_person": "Persoon of groep toevoegen",
"search_placeholder": "Zoeken op naam of e-mail…",
"loading_principals": "Gebruikers laden…",
"no_principals": "Geen andere gebruikers of groepen gevonden.",
"no_match": "Geen overeenkomsten.",
"remove": "Toegang intrekken",
"group": "Groep",
"share_added": "Toegang verleend",
"share_updated": "Toegang bijgewerkt",
"share_removed": "Toegang ingetrokken",
"share_failed": "Delen kon niet worden bijgewerkt",
"preset": {
"freeBusy": "Alleen vrij/bezet",
"read": "Alleen lezen",
"readWrite": "Lezen en schrijven",
"manager": "Beheerder",
"custom": "Aangepast"
}
}
}
+33 -2
View File
@@ -1815,7 +1815,13 @@
"renamed": "Zmieniono nazwę książki adresowej",
"rename_failed": "Nie udało się zmienić nazwy książki adresowej",
"default": "Domyślna",
"manage": "Zarządzaj książkami adresowymi"
"manage": "Zarządzaj książkami adresowymi",
"share": "Udostępnij książkę adresową",
"new_contact_in_book": "Nowy kontakt w tej książce adresowej",
"delete": "Usuń książkę adresową",
"confirm_delete": "Usunąć „{name}\"? Wszystkie kontakty w tej książce adresowej zostaną usunięte.",
"deleted": "Książka adresowa usunięta",
"delete_failed": "Nie udało się usunąć książki adresowej"
},
"detail": {
"emails": "Adresy e-mail",
@@ -2340,7 +2346,9 @@
"error_delete": "Nie udało się usunąć kalendarza",
"caldav_url": "Adres URL CalDAV",
"copy_url": "Kopiuj adres URL CalDAV",
"url_copied": "Adres URL CalDAV skopiowano do schowka"
"url_copied": "Adres URL CalDAV skopiowano do schowka",
"share": "Udostępnij kalendarz",
"new_event_in_calendar": "Nowe wydarzenie w tym kalendarzu"
},
"subscription": {
"title": "Subskrypcja iCal",
@@ -2706,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym"
},
"sharing": {
"title": "Udostępnij „{name}\"",
"description": "Udziel dostępu innym użytkownikom lub grupom na tym serwerze. Zmiany są natychmiastowe.",
"no_shares": "Jeszcze nie udostępniono.",
"add_person": "Dodaj osobę lub grupę",
"search_placeholder": "Szukaj po imieniu lub e-mailu…",
"loading_principals": "Ładowanie użytkowników…",
"no_principals": "Nie znaleziono innych użytkowników ani grup.",
"no_match": "Brak wyników.",
"remove": "Usuń dostęp",
"group": "Grupa",
"share_added": "Dostęp przyznany",
"share_updated": "Dostęp zaktualizowany",
"share_removed": "Dostęp usunięty",
"share_failed": "Nie udało się zaktualizować udostępniania",
"preset": {
"freeBusy": "Tylko dostępność",
"read": "Tylko do odczytu",
"readWrite": "Odczyt i zapis",
"manager": "Menedżer",
"custom": "Niestandardowe"
}
}
}
+68 -37
View File
@@ -20,7 +20,7 @@
"generic": "Ocorreu um erro. Por favor, tente novamente.",
"totp_required": "É necessário um código de autenticação de dois fatores. Insira seu código abaixo.",
"totp_invalid": "Código de autenticação inválido. Verifique seu aplicativo de autenticação.",
"oauth_discovery_failed": "SSO está ativado mas o provedor de identidade não pôde ser contactado. Verifique sua configuração OAuth."
"oauth_discovery_failed": "SSO está ativado mas o provedor de identidade não pôde ser contatado. Verifique sua configuração OAuth."
},
"show_password": "Mostrar senha",
"hide_password": "Ocultar senha",
@@ -80,7 +80,7 @@
"calendar": "Calendário",
"settings": "Configurações",
"admin": "Admin",
"files": "Ficheiros",
"files": "Arquivos",
"loading_mailboxes": "Carregando caixas de entrada...",
"push_connected": "Atualizações em tempo real ativas",
"push_disconnected": "Atualizações em tempo real inativas",
@@ -505,7 +505,7 @@
"show_less": "Mostrar menos",
"forgot_attachment": {
"title": "Esqueceu um anexo?",
"message": "A sua mensagem menciona \"{keyword}\" mas nenhum ficheiro está anexado. Enviar mesmo assim?",
"message": "A sua mensagem menciona \"{keyword}\" mas nenhum arquivo está anexado. Enviar mesmo assim?",
"send_anyway": "Enviar mesmo assim",
"back": "Voltar à edição"
}
@@ -669,7 +669,7 @@
"security": "Segurança",
"encryption": "Criptografia",
"files": "Arquivos",
"contacts": "Contactos",
"contacts": "Contatos",
"sidebar_apps": "Apps da barra lateral",
"notifications": "Notificações",
"layout": "Layout",
@@ -1322,12 +1322,12 @@
"manage_description": "Adicionar, editar ou remover apps personalizados da barra lateral"
},
"contacts": {
"title": "Contactos",
"description": "Importar e exportar os seus contactos",
"import_label": "Importar contactos",
"import_description": "Importar contactos de um ficheiro vCard (.vcf)",
"export_label": "Exportar contactos",
"export_description": "Exportar todos os contactos como ficheiro vCard (.vcf)",
"title": "Contatos",
"description": "Importar e exportar os seus contatos",
"import_label": "Importar contatos",
"import_description": "Importar contatos de um arquivo vCard (.vcf)",
"export_label": "Exportar contatos",
"export_description": "Exportar todos os contatos como arquivo vCard (.vcf)",
"manage_title": "Catálogos de endereços",
"manage_description": "Renomeie seus catálogos de endereços",
"no_address_books": "Nenhum catálogo de endereços encontrado",
@@ -1503,7 +1503,7 @@
},
"folder_layout": {
"label": "Navegação de pastas",
"description": "Escolha como as pastas são apresentadas: integradas com os ficheiros ou numa árvore na barra lateral",
"description": "Escolha como as pastas são apresentadas: integradas com os arquivos ou numa árvore na barra lateral",
"inline": "Integrado",
"sidebar": "Barra lateral"
},
@@ -1815,7 +1815,13 @@
"renamed": "Catálogo de endereços renomeado",
"rename_failed": "Falha ao renomear o catálogo de endereços",
"default": "Padrão",
"manage": "Gerenciar catálogos de endereços"
"manage": "Gerenciar catálogos de endereços",
"share": "Compartilhar lista de contatos",
"new_contact_in_book": "Novo contato nesta lista",
"delete": "Excluir lista de contatos",
"confirm_delete": "Excluir \"{name}\"? Todos os contatos desta lista serão removidos.",
"deleted": "Lista de contatos excluída",
"delete_failed": "Falha ao excluir a lista de contatos"
},
"detail": {
"emails": "Endereços de e-mail",
@@ -1868,7 +1874,7 @@
"cert_already_imported": "Certificado já importado",
"cert_imported": "Certificado importado",
"cert_import_failed": "Falha ao importar o certificado",
"section_contact": "Contact details",
"section_contact": "Detalhes do contato",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
@@ -2237,7 +2243,7 @@
"hover_preview_delay_2s": "Atraso de 2 segundos",
"hover_preview_off": "Desativado",
"show_birthday_calendar": "Calendário de aniversários",
"show_birthday_calendar_desc": "Mostrar um calendário virtual com os aniversários dos seus contactos"
"show_birthday_calendar_desc": "Mostrar um calendário virtual com os aniversários dos seus contatos"
},
"days": {
"monday": "Segunda-feira",
@@ -2340,7 +2346,9 @@
"confirm_clear": "Limpar todos os eventos de \"{name}\"? Esta ação não pode ser desfeita.",
"clear_events": "Limpar eventos",
"events_cleared": "{count} eventos removidos",
"error_clear": "Falha ao limpar os eventos do calendário"
"error_clear": "Falha ao limpar os eventos do calendário",
"share": "Compartilhar calendário",
"new_event_in_calendar": "Novo evento neste calendário"
},
"subscription": {
"title": "Assinatura iCal",
@@ -2473,12 +2481,12 @@
"hint": "Clique num e-mail à esquerda para começar, ou inicie o tour."
},
"files": {
"title": "Ficheiros",
"search_placeholder": "Pesquisar ficheiros...",
"empty_state_title": "Ainda não há ficheiros",
"empty_state_description": "Carregue ficheiros ou crie pastas para começar",
"title": "Arquivos",
"search_placeholder": "Pesquisar arquivos...",
"empty_state_title": "Ainda não há arquivos",
"empty_state_description": "Carregue arquivos ou crie pastas para começar",
"upload": "Carregar",
"upload_files": "Carregar ficheiros",
"upload_files": "Carregar arquivos",
"new_folder": "Nova pasta",
"new_folder_name": "Nome da pasta",
"rename": "Renomear",
@@ -2493,13 +2501,13 @@
"modified": "Modificado",
"type": "Tipo",
"folder": "Pasta",
"file": "Ficheiro",
"file": "Arquivo",
"parent_directory": "Diretório superior",
"breadcrumb_root": "Início",
"drop_files_here": "Largue ficheiros ou pastas aqui para carregar",
"drop_files_here": "Largue arquivos ou pastas aqui para carregar",
"uploading": "A carregar...",
"upload_success": "{count, plural, one {1 ficheiro carregado} other {# ficheiros carregados}}",
"upload_error": "Falha ao carregar o ficheiro",
"upload_success": "{count, plural, one {1 arquivo carregado} other {# arquivos carregados}}",
"upload_error": "Falha ao carregar o arquivo",
"create_folder_success": "Pasta criada",
"create_folder_error": "Falha ao criar a pasta",
"delete_success": "Eliminado com sucesso",
@@ -2507,11 +2515,11 @@
"rename_success": "Renomeado com sucesso",
"rename_error": "Falha ao renomear",
"download_error": "Falha ao transferir",
"not_available": "O armazenamento de ficheiros não está disponível neste servidor",
"not_available": "O armazenamento de arquivos não está disponível neste servidor",
"cancel": "Cancelar",
"create": "Criar",
"save": "Guardar",
"no_results": "Nenhum ficheiro corresponde à sua pesquisa",
"no_results": "Nenhum arquivo corresponde à sua pesquisa",
"batch_delete_confirm_message": "Tem a certeza de que deseja eliminar {count, plural, one {1 item} other {# itens}}? Esta ação não pode ser desfeita.",
"batch_delete_success": "{count, plural, one {1 item eliminado} other {# itens eliminados}}",
"grid_view": "Vista em grelha",
@@ -2527,27 +2535,27 @@
"move_error": "Falha ao mover",
"paste_success": "Colado com sucesso",
"paste_error": "Falha ao colar",
"new_text_file": "Novo ficheiro de texto",
"file_name": "Nome do ficheiro",
"new_text_file": "Novo arquivo de texto",
"file_name": "Nome do arquivo",
"retry": "Tentar novamente",
"refresh": "Atualizar",
"toggle_favorite": "Alternar favorito",
"duplicate": "Duplicar",
"duplicate_success": "Duplicado com sucesso",
"duplicate_error": "Falha ao duplicar",
"create_file_success": "Ficheiro criado",
"create_file_error": "Falha ao criar ficheiro",
"create_file_success": "Arquivo criado",
"create_file_error": "Falha ao criar arquivo",
"favorites": "Favoritos",
"recent": "Recentes",
"properties": "Propriedades",
"open_folder": "Abrir pasta",
"upload_folder": "Carregar pasta",
"file_too_large": "\"{name}\" excede o tamanho máximo do ficheiro ({max})",
"file_too_large": "\"{name}\" excede o tamanho máximo do arquivo ({max})",
"undo": "Desfazer",
"undo_success": "Ação desfeita",
"undo_error": "Falha ao desfazer",
"toolbar": "Ações de arquivo",
"file_list": "Ficheiros e pastas",
"file_list": "Arquivos e pastas",
"context_menu": "Ações",
"settings_title": "Configurações de arquivos",
"settings_display": "Exibição",
@@ -2570,7 +2578,7 @@
"settings_show_hidden": "Mostrar arquivos ocultos",
"settings_show_hidden_desc": "Exibir arquivos e pastas que começam com um ponto",
"settings_folder_layout": "Navegação de pastas",
"settings_folder_layout_desc": "Escolha como as pastas são apresentadas: integradas com os ficheiros ou numa árvore na barra lateral",
"settings_folder_layout_desc": "Escolha como as pastas são apresentadas: integradas com os arquivos ou numa árvore na barra lateral",
"settings_folder_layout_inline": "Integrado",
"settings_folder_layout_sidebar": "Barra lateral",
"disabled_title": "O recurso de arquivos foi desativado pelo seu administrador",
@@ -2695,16 +2703,39 @@
"event_modal_desc": "Aqui está o formulário do evento. Preencha o título, escolha uma data e hora, adicione um local ou participantes. Clique em salvar quando terminar - ou feche e siga em frente.",
"contacts_list_title": "Seus contatos",
"contacts_list_desc": "Aqui estão seus contatos. Clique em qualquer contato para ver seus detalhes à direita. Você também pode criar novos contatos, importar vCards ou organizar contatos em grupos.",
"files_title": "Armazenamento de ficheiros",
"files_title": "Armazenamento de arquivos",
"settings_tabs_title": "Menu de configurações",
"settings_tabs_desc": "Aqui estão todas as categorias de configurações. Personalize a aparência, gerencie identidades, configure filtros de e-mail, ajuste o calendário e muito mais.",
"files_desc": "O navegador de ficheiros permite carregar, organizar e partilhar ficheiros - como uma nuvem pessoal integrada no seu e-mail.",
"demo_banner_title": "Controlos de demonstração",
"files_desc": "O navegador de arquivos permite carregar, organizar e compartilhar arquivos - como uma nuvem pessoal integrada no seu e-mail.",
"demo_banner_title": "Controles de demonstração",
"demo_banner_desc": "Está no modo de demonstração - tudo permanece no seu navegador. Clique em 'Repor Demonstração' a qualquer momento para recomeçar com dados limpos.",
"quota_title": "Utilização do armazenamento",
"quota_desc": "Acompanhe o tamanho da sua caixa de correio aqui. O círculo preenche-se à medida que utiliza mais espaço."
},
"unified_mailbox": {
"search_unavailable": "A pesquisa não está disponível na vista unificada"
},
"sharing": {
"title": "Compartilhar \"{name}\"",
"description": "Conceda acesso a outros usuários ou grupos neste servidor. As alterações têm efeito imediato.",
"no_shares": "Ainda não compartilhado.",
"add_person": "Adicionar pessoa ou grupo",
"search_placeholder": "Buscar por nome ou e-mail…",
"loading_principals": "Carregando usuários…",
"no_principals": "Nenhum outro usuário ou grupo encontrado.",
"no_match": "Sem resultados.",
"remove": "Remover acesso",
"group": "Grupo",
"share_added": "Acesso concedido",
"share_updated": "Acesso atualizado",
"share_removed": "Acesso removido",
"share_failed": "Falha ao atualizar o compartilhamento",
"preset": {
"freeBusy": "Apenas disponibilidade",
"read": "Somente leitura",
"readWrite": "Leitura e escrita",
"manager": "Gerente",
"custom": "Personalizado"
}
}
}
}
+33 -2
View File
@@ -1815,7 +1815,13 @@
"renamed": "Адресная книга переименована",
"rename_failed": "Не удалось переименовать адресную книгу",
"default": "По умолчанию",
"manage": "Управление адресными книгами"
"manage": "Управление адресными книгами",
"share": "Поделиться адресной книгой",
"new_contact_in_book": "Новый контакт в этой адресной книге",
"delete": "Удалить адресную книгу",
"confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.",
"deleted": "Адресная книга удалена",
"delete_failed": "Не удалось удалить адресную книгу"
},
"detail": {
"emails": "Адреса электронной почты",
@@ -2340,7 +2346,9 @@
"error_delete": "Не удалось удалить календарь",
"caldav_url": "URL CalDAV",
"copy_url": "Скопировать CalDAV URL",
"url_copied": "CalDAV URL скопирован в буфер обмена"
"url_copied": "CalDAV URL скопирован в буфер обмена",
"share": "Поделиться календарём",
"new_event_in_calendar": "Новое событие в этом календаре"
},
"subscription": {
"title": "Подписка iCal",
@@ -2706,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "Поиск недоступен в объединённом представлении"
},
"sharing": {
"title": "Поделиться «{name}»",
"description": "Предоставьте доступ другим пользователям или группам на этом сервере. Изменения вступают в силу немедленно.",
"no_shares": "Пока никому не предоставлен доступ.",
"add_person": "Добавить пользователя или группу",
"search_placeholder": "Искать по имени или email…",
"loading_principals": "Загрузка пользователей…",
"no_principals": "Других пользователей или групп не найдено.",
"no_match": "Нет совпадений.",
"remove": "Отозвать доступ",
"group": "Группа",
"share_added": "Доступ предоставлен",
"share_updated": "Доступ обновлён",
"share_removed": "Доступ отозван",
"share_failed": "Не удалось обновить общий доступ",
"preset": {
"freeBusy": "Только занятость",
"read": "Только чтение",
"readWrite": "Чтение и запись",
"manager": "Управляющий",
"custom": "Пользовательский"
}
}
}
+33 -2
View File
@@ -1815,7 +1815,13 @@
"renamed": "Адресну книгу перейменовано",
"rename_failed": "Не вдалося перейменувати адресну книгу",
"default": "За замовчуванням",
"manage": "Керуйте адресними книгами"
"manage": "Керуйте адресними книгами",
"share": "Поділитися адресною книгою",
"new_contact_in_book": "Новий контакт у цій адресній книзі",
"delete": "Видалити адресну книгу",
"confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.",
"deleted": "Адресну книгу видалено",
"delete_failed": "Не вдалося видалити адресну книгу"
},
"detail": {
"emails": "Адреси електронної пошти",
@@ -2340,7 +2346,9 @@
"error_delete": "Не вдалося видалити календар",
"caldav_url": "URL-адреса CalDAV",
"copy_url": "Скопіюйте URL-адресу CalDAV",
"url_copied": "URL-адресу CalDAV скопійовано в буфер обміну"
"url_copied": "URL-адресу CalDAV скопійовано в буфер обміну",
"share": "Поділитися календарем",
"new_event_in_calendar": "Нова подія в цьому календарі"
},
"subscription": {
"title": "Підписка iCal",
@@ -2706,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "Пошук недоступний в об'єднаному перегляді"
},
"sharing": {
"title": "Поділитися «{name}»",
"description": "Надайте доступ іншим користувачам або групам на цьому сервері. Зміни набувають чинності негайно.",
"no_shares": "Поки що ні з ким не поділено.",
"add_person": "Додати людину або групу",
"search_placeholder": "Шукати за іменем або email…",
"loading_principals": "Завантаження користувачів…",
"no_principals": "Інших користувачів або груп не знайдено.",
"no_match": "Збігів немає.",
"remove": "Видалити доступ",
"group": "Група",
"share_added": "Доступ надано",
"share_updated": "Доступ оновлено",
"share_removed": "Доступ видалено",
"share_failed": "Не вдалося оновити спільний доступ",
"preset": {
"freeBusy": "Лише зайнятість",
"read": "Лише читання",
"readWrite": "Читання та запис",
"manager": "Керівник",
"custom": "Власне"
}
}
}
+33 -2
View File
@@ -1815,7 +1815,13 @@
"renamed": "地址簿已重命名",
"rename_failed": "重命名地址簿失败",
"default": "默认",
"manage": "管理地址簿"
"manage": "管理地址簿",
"share": "共享通讯录",
"new_contact_in_book": "在此通讯录中新建联系人",
"delete": "删除通讯录",
"confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。",
"deleted": "通讯录已删除",
"delete_failed": "删除通讯录失败"
},
"detail": {
"emails": "邮箱地址",
@@ -2340,7 +2346,9 @@
"error_delete": "删除日历失败",
"caldav_url": "CalDAV URL",
"copy_url": "复制 CalDAV URL",
"url_copied": "CalDAV URL 已复制到剪贴板"
"url_copied": "CalDAV URL 已复制到剪贴板",
"share": "共享日历",
"new_event_in_calendar": "在此日历中新建事件"
},
"subscription": {
"title": "iCal 订阅",
@@ -2706,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "统一视图中无法使用搜索"
},
"sharing": {
"title": "共享「{name}」",
"description": "向此服务器上的其他用户或群组授予访问权限。更改会立即生效。",
"no_shares": "尚未共享。",
"add_person": "添加用户或群组",
"search_placeholder": "按姓名或邮箱搜索…",
"loading_principals": "正在加载用户…",
"no_principals": "未找到其他用户或群组。",
"no_match": "无匹配项。",
"remove": "取消访问",
"group": "群组",
"share_added": "已授予访问权限",
"share_updated": "已更新访问权限",
"share_removed": "已取消访问权限",
"share_failed": "更新共享失败",
"preset": {
"freeBusy": "仅显示忙/闲",
"read": "只读",
"readWrite": "读写",
"manager": "管理员",
"custom": "自定义"
}
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.5.1",
"version": "1.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.5.1",
"version": "1.5.2",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.5.1",
"version": "1.5.2",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+21 -6
View File
@@ -1,10 +1,21 @@
import { type NextRequest, NextResponse } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
const intlMiddleware = createIntlMiddleware(routing);
export function proxy(request: NextRequest) {
// Next 16's Proxy always runs on Node.js runtime and route-segment config
// (e.g. `export const config = { matcher }`) is no longer allowed in the
// proxy file. We replicate the previous matcher inline by short-circuiting
// requests for API routes, Next internals and static assets.
const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/;
export async function proxy(request: NextRequest) {
if (PROXY_SKIP_PATTERN.test(request.nextUrl.pathname)) {
return NextResponse.next();
}
const nonce = crypto.randomUUID();
const isDev = process.env.NODE_ENV === "development";
@@ -16,6 +27,14 @@ export function proxy(request: NextRequest) {
const frameAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
// Plugins may declare iframe origins they need (e.g. for embedded video).
// Each origin is validated at install time and re-validated here.
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
const frameSrc =
pluginFrameOrigins.length > 0
? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
: `frame-src 'self' blob:`;
const csp = [
`default-src 'self'`,
`script-src ${scriptSrc}`,
@@ -23,7 +42,7 @@ export function proxy(request: NextRequest) {
`img-src 'self' data: blob: https:`,
`font-src 'self'`,
`connect-src ${connectSrc}`,
`frame-src 'self' blob:`,
frameSrc,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
@@ -78,7 +97,3 @@ export function proxy(request: NextRequest) {
return response;
}
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)"],
};
+25 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from '@/lib/jmap/types';
import { debug } from '@/lib/debug';
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
import { parseDuration } from '@/components/calendar/event-card';
@@ -125,6 +125,7 @@ interface CalendarStore {
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
createCalendar: (client: IJMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
removeCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
clearCalendarEvents: (client: IJMAPClient, calendarId: string) => Promise<number>;
@@ -653,6 +654,29 @@ export const useCalendarStore = create<CalendarStore>()(
}
},
shareCalendar: async (client, calendarId, principalId, rights) => {
set({ error: null });
try {
const cal = get().calendars.find(c => c.id === calendarId);
const realId = cal?.originalId || calendarId;
const targetAccountId = cal?.accountId;
await client.setCalendarShare(realId, principalId, rights, targetAccountId);
set((state) => ({
calendars: state.calendars.map(c => {
if (c.id !== calendarId) return c;
const next = { ...(c.shareWith ?? {}) };
if (rights === null) delete next[principalId];
else next[principalId] = rights;
return { ...c, shareWith: next };
}),
}));
} catch (error) {
debug.error('Failed to share calendar:', error);
set({ error: 'Failed to share calendar' });
throw error;
}
},
createCalendar: async (client, calendar) => {
set({ error: null });
try {
+42 -1
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
import type { ContactCard, AddressBook, AddressBookRights, ContactName } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { generateUUID } from '@/lib/utils';
import { debug } from '@/lib/debug';
@@ -101,6 +101,8 @@ interface ContactStore {
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise<void>;
removeAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise<void>;
shareAddressBook: (client: IJMAPClient, addressBook: AddressBook, principalId: string, rights: AddressBookRights | null) => Promise<void>;
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
@@ -655,6 +657,45 @@ export const useContactStore = create<ContactStore>()(
}
},
removeAddressBook: async (client, addressBook) => {
set({ error: null });
try {
const originalId = addressBook.originalId || addressBook.id;
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
await client.deleteAddressBook(originalId, accountId);
set((state) => ({
addressBooks: state.addressBooks.filter(b => b.id !== addressBook.id),
contacts: state.contacts.filter(c => !c.addressBookIds?.[addressBook.id]),
}));
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to delete address book';
set({ error: msg });
throw error;
}
},
shareAddressBook: async (client, addressBook, principalId, rights) => {
set({ error: null });
try {
const originalId = addressBook.originalId || addressBook.id;
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
await client.setAddressBookShare(originalId, principalId, rights, accountId);
set((state) => ({
addressBooks: state.addressBooks.map(b => {
if (b.id !== addressBook.id) return b;
const next = { ...(b.shareWith ?? {}) };
if (rights === null) delete next[principalId];
else next[principalId] = rights;
return { ...b, shareWith: next };
}),
}));
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to share address book';
set({ error: msg });
throw error;
}
},
renameKeyword: async (client, oldKeyword, newKeyword) => {
set({ error: null });
const oldKw = oldKeyword.trim();
+1 -1
View File
@@ -20,7 +20,7 @@ import { apiFetch } from '@/lib/browser-navigation';
// ─── Slot State ──────────────────────────────────────────────
const SLOT_NAMES: SlotName[] = [
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', 'composer-sidebar', 'composer-sidebar-right',
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
'calendar-event-actions', 'admin-plugin-page',
];