Compare commits

...
35 Commits
Author SHA1 Message Date
Linus Rath 92c7f74420 feat: add right-click context menu to contacts 2026-04-21 19:33:00 +02:00
Linus Rath c2e4518cfa feat: add account addition button to navigation rail 2026-04-21 19:20:50 +02:00
Linus Rath 00f33afdf9 feat: add contact activity component with recent emails and upcoming events 2026-04-21 19:18:36 +02:00
Linus Rath e566cfe687 feat: add API Keys management and IP allowlist for App Passwords 2026-04-21 18:59:47 +02:00
Linus Rath 6b7c849332 refactor: remove Stalwart API URL configuration 2026-04-21 17:50:18 +02:00
Linus Rath 30c4afb977 i18n: add missing translation keys 2026-04-21 17:42:52 +02:00
Linus Rath 1f60671886 fix: clean up unused imports and improve TOTP QR code rendering 2026-04-21 17:30:00 +02:00
Linus Rath 794001fdbd feat: migrate Stalwart management API to JMAP x: methods (0.16)
Drops the 0.15 REST management API and routes all account/auth/crypto/
principal operations through Stalwart 0.16's schema-driven JMAP
endpoint via a single passthrough (/api/account/stalwart/jmap).

- New client helper `stalwartJmap` + typed `requireResult`
- account-security-store rewritten against x:AccountPassword, x:AppPassword,
  x:AccountSettings, x:Account (with currentSecret for TOTP ops)
- Client-side TOTP setup via `otpauth`; server-generated app password
  secrets shown once on create
- Admin check switched to /api/account permissions
  (sysAccountQuery/sysTenantQuery/sysSystemSettingsGet)
- Removed sieve vacation-overwrite workaround (fixed upstream #1251)
- Deleted old REST routes, StalwartClient, stale tests; added new
  tests for passthrough + store
2026-04-21 17:29:23 +02:00
Linus Rath 9ad2facad3 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-21 14:12:03 +02:00
Linus Rath 89d8282846 fix: include original attachments when forwarding an email #214 2026-04-21 13:58:02 +02:00
Denis TeyssierandLinus Rath c3960a99be fix: removed allow-same-origin to avoid anti-pattern permissions on iframe sandbox #209 2026-04-20 14:55:50 +02:00
Denis TeyssierandLinus Rath e73ffa7449 fix: added sandbox allow-scripts to PDF file preview modal #209 2026-04-20 14:55:50 +02:00
Denis TeyssierandLinus Rath 37bd490072 fix: added media-src CSP rule for media attachments #209 2026-04-20 14:55:50 +02:00
Linus Rath 24c53e5ce7 fix: apply archive context-menu action to all selected emails # 212 2026-04-20 14:35:56 +02:00
Linus Rath aa7f886795 refactor: remove deprecated push notification API routes and related logic 2026-04-20 12:41:38 +02:00
Linus Rath 00dec8c5a0 fix: correct regex escape for hyphen in FCM token validation 2026-04-20 12:08:41 +02:00
Linus Rath 578e60c0bc feat: nevermind, migrate push notification handling from UnifiedPush to FCM 2026-04-20 12:06:30 +02:00
Linus Rath 8b21851353 feat: migrate to UnifiedPush 2026-04-20 10:54:26 +02:00
Linus Rath 15006086d2 feat: implement JMAP push notification handling and subscription management 2026-04-20 08:26:09 +02:00
Linus Rath bc3b923945 fix: apply mobile toolbar spacing fix to email viewer action bar 2026-04-19 17:59:58 +02:00
Linus Rath 76ba9e5f85 fix: retry session fetch with Authorization after redirect strips it #210 2026-04-19 16:40:55 +02:00
Linus Rath 44eb5fced2 fix: stop birthday calendar from re-showing after manual hide #204 2026-04-19 15:04:27 +02:00
Linus Rath 172d8267ef fix: persist typed calendar participant on save without requiring Enter #206 2026-04-19 13:47:17 +02:00
Linus Rath f162f1e3d4 feat: refresh JMAP on reload gestures, fix mobile bottom nav spacing 2026-04-19 13:43:44 +02:00
Linus Rath 6fa0029d0b feat: add right-click context menu on calendar events 2026-04-18 13:46:49 +02:00
nesgarboandLinus Rath 028e78a0c9 fix: serve PWA manifest dynamically to respect runtime APP_NAME env var
Delete static public/manifest.json (hardcoded "Bulwark Webmail") and mark
app/manifest.ts as force-dynamic so Next.js evaluates APP_NAME at request
time instead of build time, fixing the native browser install prompt.

Closes #207
2026-04-18 13:20:14 +02:00
Linus Rath 440a4e919a Revert "perf: make root HTML cacheable by dropping per-request CSP nonce"
This reverts commit 522bf6a019.
2026-04-18 13:10:42 +02:00
Linus Rath b8f39198e1 fix: allow blob: in frame-src CSP for attachment previews #209 2026-04-18 13:04:25 +02:00
Linus Rath 966bbe3957 fix: add public JavaScript files to ESLint ignore list 2026-04-18 13:01:52 +02:00
Linus Rath 522bf6a019 perf: make root HTML cacheable by dropping per-request CSP nonce 2026-04-18 13:01:12 +02:00
Linus Rath 1689315c3a feat: add setting to hide inline images from attachment list 2026-04-18 01:07:34 +02:00
Linus Rath d4f7ae522e fix: use cid references for inline images #163 2026-04-18 00:57:46 +02:00
Linus Rath f05f70a9e5 fix: prevent XSS via quote injection in plain-text email linkifier 2026-04-18 00:35:21 +02:00
Linus Rath 850ee73048 fix: preserve Nextcloud Mail filter markers across saves #201 2026-04-17 02:08:04 +02:00
Linus Rath 5842f3f914 fix: preserve birthday calendar visibility across navigation #204 2026-04-17 01:54:08 +02:00
85 changed files with 3965 additions and 1946 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"WebFetch(domain:github.com)"
]
}
}
-5
View File
@@ -27,11 +27,6 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Set to "false" to disable if using a non-Stalwart JMAP server.
# STALWART_FEATURES=true
# If your reverse proxy doesn't forward Stalwart management API paths
# (/api/account/*, /api/principal/*), set this to the URL where Stalwart's
# HTTP listener is directly reachable. Defaults to JMAP_SERVER_URL if not set.
# STALWART_API_URL=https://admin.example.com
# =============================================================================
# OAuth / OpenID Connect (optional)
# =============================================================================
-1
View File
@@ -386,7 +386,6 @@ Enables the admin marketplace for browsing and installing plugins and themes.
```env
STALWART_FEATURES=true # Password change, sieve filters, etc.
STALWART_API_URL=https://admin.example.com # If reverse proxy doesn't forward /api/*
LOG_FORMAT=text # "text" or "json"
LOG_LEVEL=info # "error", "warn", "info", "debug"
+124 -2
View File
@@ -30,6 +30,10 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { EventContextMenu } from "@/components/calendar/event-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { downloadEventICS } from "@/lib/calendar-ics-export";
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
@@ -161,9 +165,14 @@ export default function CalendarPage() {
return () => clearInterval(interval);
}, [client, refreshAllSubscriptions]);
// Auto-add birthday calendar to selected IDs when enabled
// Auto-add birthday calendar to selected IDs only when the setting flips
// off→on. Firing on every mount would undo a user's manual hide via the
// sidebar each time they navigate back to the calendar (see #204).
const prevShowBirthdayRef = useRef(showBirthdayCalendar);
useEffect(() => {
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
const wasShown = prevShowBirthdayRef.current;
prevShowBirthdayRef.current = showBirthdayCalendar;
if (!wasShown && showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
}
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -357,6 +366,18 @@ export default function CalendarPage() {
openEditModal(event);
}, [closeDetail, openEditModal]);
const {
contextMenu: eventContextMenu,
openContextMenu: openEventContextMenu,
closeContextMenu: closeEventContextMenu,
menuRef: eventContextMenuRef,
} = useContextMenu<CalendarEvent>();
const handleContextMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
closeDetail();
openEventContextMenu(e, event);
}, [closeDetail, openEventContextMenu]);
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (calendarHoverPreview === 'off') return;
@@ -417,6 +438,20 @@ export default function CalendarPage() {
}
}, [client, fetchEvents]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh calendar data via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client,
onRefresh: async () => {
if (!client) return;
await Promise.all([
fetchCalendars(client),
refetchCurrentRange(),
refreshAllSubscriptions(client),
]);
},
});
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
if (!event.start) {
return;
@@ -732,6 +767,73 @@ export default function CalendarPage() {
}
}, [detailEvent, client, updateEvent, t]);
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
const start = parseISO(event.start);
const newStart = addDays(start, 1);
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
title: event.title,
description: event.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
duration: event.duration,
timeZone: event.timeZone,
showWithoutTime: event.showWithoutTime,
calendarIds: { ...event.calendarIds },
status: "confirmed",
freeBusyStatus: event.freeBusyStatus,
privacy: event.privacy,
});
if (event.locations) data.locations = structuredClone(event.locations);
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants);
try {
const created = await createEvent(client, data);
if (created) {
toast.success(t("notifications.event_duplicated"));
openEditModal(created);
}
} catch {
toast.error(t("notifications.event_error"));
}
}, [client, createEvent, openEditModal, t]);
const handleExportICS = useCallback((event: CalendarEvent) => {
try {
downloadEventICS(event);
toast.success(t("notifications.event_exported"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [t]);
const handleCopyTitle = useCallback(async (event: CalendarEvent) => {
try {
await navigator.clipboard.writeText(event.title || "");
toast.success(t("notifications.title_copied"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [t]);
const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => {
const uri = event.virtualLocations
? Object.values(event.virtualLocations).find((v) => v.uri)?.uri
: undefined;
if (!uri) return;
try {
await navigator.clipboard.writeText(uri);
toast.success(t("notifications.link_copied"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [t]);
const handleDeleteContextMenu = useCallback((event: CalendarEvent) => {
const hasParticipants = event.participants && Object.keys(event.participants).length > 0;
handleDeleteEvent(event.id, hasParticipants || undefined);
}, [handleDeleteEvent]);
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
if (!detailEvent || !client) return;
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
@@ -841,6 +943,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
@@ -857,6 +960,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat}
@@ -875,6 +979,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal}
timeFormat={timeFormat}
isMobile={isMobile}
@@ -892,6 +997,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
timeFormat={timeFormat}
/>
);
@@ -1106,6 +1212,22 @@ export default function CalendarPage() {
</div>
)}
{eventContextMenu.data && (
<EventContextMenu
event={eventContextMenu.data}
position={eventContextMenu.position}
isOpen={eventContextMenu.isOpen}
onClose={closeEventContextMenu}
menuRef={eventContextMenuRef}
onEdit={() => openEditModal(eventContextMenu.data!)}
onDuplicate={() => handleDuplicateContextMenu(eventContextMenu.data!)}
onExportICS={() => handleExportICS(eventContextMenu.data!)}
onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)}
onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)}
onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)}
/>
)}
{detailEvent && detailAnchorRect && (
<EventDetailPopover
event={detailEvent}
+42 -6
View File
@@ -26,6 +26,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
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";
type View =
@@ -123,6 +124,16 @@ export default function ContactsPage() {
}
}, [client, supportsSync, fetchContacts]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh contacts via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client && supportsSync,
onRefresh: async () => {
if (!client) return;
await fetchContacts(client);
},
});
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
@@ -248,9 +259,7 @@ export default function ContactsPage() {
setView("edit");
};
const handleDelete = async () => {
if (!selectedContact) return;
const deleteContactById = useCallback(async (contactId: string) => {
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("delete_confirm"),
@@ -261,18 +270,42 @@ export default function ContactsPage() {
try {
if (supportsSync && client) {
await deleteContact(client, selectedContact.id);
await deleteContact(client, contactId);
} else {
deleteLocalContact(selectedContact.id);
deleteLocalContact(contactId);
}
toast.success(t("toast.deleted"));
setView("list");
if (selectedContactId === contactId) setView("list");
} catch (error) {
console.error('Failed to delete contact:', error);
toast.error(t("toast.error_delete"));
}
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
const handleDelete = async () => {
if (!selectedContact) return;
await deleteContactById(selectedContact.id);
};
const handleEditContact = useCallback((id: string) => {
setSelectedContact(id);
setView("edit");
}, [setSelectedContact]);
const handleDeleteContact = useCallback((contact: ContactCard) => {
void deleteContactById(contact.id);
}, [deleteContactById]);
const handleAddContactToGroup = useCallback((id: string) => {
clearSelection();
toggleContactSelection(id);
if (groups.length === 0) {
setView("group-create");
return;
}
setView("bulk-add-to-group");
}, [clearSelection, toggleContactSelection, groups.length]);
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
if (supportsSync && client) {
await createContact(client, data);
@@ -679,6 +712,9 @@ export default function ContactsPage() {
onBulkDelete={handleBulkDelete}
onBulkAddToGroup={handleBulkAddToGroup}
onBulkExport={handleBulkExport}
onEditContact={handleEditContact}
onDeleteContact={handleDeleteContact}
onAddContactToGroup={handleAddContactToGroup}
/>
</div>
+10
View File
@@ -17,6 +17,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { usePolicyStore } from "@/stores/policy-store";
import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
@@ -127,6 +128,15 @@ export default function FilesPage() {
}
}, [isAuthenticated, client, initClient]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh files via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client && supportsFiles === true,
onRefresh: async () => {
await refresh();
},
});
// Check support and load root after client is initialized
const storeClient = useFileStore(s => s.client);
useEffect(() => {
+18 -2
View File
@@ -21,6 +21,7 @@ import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug";
@@ -408,6 +409,20 @@ export default function Home() {
handlers: keyboardHandlers,
});
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh mail data via JMAP instead of reloading the page.
useRefreshGesture({
enabled: isAuthenticated && !!client,
onRefresh: async () => {
if (!client) return;
const state = useEmailStore.getState();
await Promise.all([
state.fetchMailboxes(client),
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client),
]);
},
});
// Update page title based on context
useEffect(() => {
let title = appName;
@@ -644,7 +659,7 @@ export default function Home() {
fromEmail?: string;
fromName?: string;
identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
}) => {
if (!client) return;
@@ -1804,7 +1819,8 @@ export default function Home() {
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
receivedAt: selectedEmail.receivedAt
receivedAt: selectedEmail.receivedAt,
attachments: selectedEmail.attachments,
} : undefined)}
initialDraftText={composerDraftText}
initialData={pendingDraft}
-1
View File
@@ -122,7 +122,6 @@ export default function AdminSettingsPage() {
</div>
)}
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
-87
View File
@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/auth
* Proxy to Stalwart GET /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
const detail = parseStalwartError(text);
logger.warn('Stalwart auth info failed', { status: response.status, detail });
return NextResponse.json(
{ error: detail || 'Failed to fetch auth info' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/auth
* Proxy to Stalwart POST /api/account/auth
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart auth update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
-87
View File
@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/crypto
* Proxy to Stalwart GET /api/account/crypto
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
const detail = parseStalwartError(text);
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
return NextResponse.json(
{ error: detail || 'Failed to fetch crypto info' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/crypto
* Proxy to Stalwart POST /api/account/crypto
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart crypto update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* POST /api/account/stalwart/jmap
*
* Passthrough to Stalwart's JMAP endpoint using the stored basic-auth
* context so the browser does not need access to the user's credentials.
*
* Body: standard JMAP request `{ using: string[], methodCalls: [...] }`
*
* In Stalwart 0.16 all management operations (password change, app
* passwords, API keys, account settings, etc.) are exposed as JMAP
* methods under the `x:` namespace on the same endpoint.
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.text();
const response = await fetch(`${creds.serverUrl}/jmap/`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body,
});
const responseText = await response.text();
return new NextResponse(responseText, {
status: response.status,
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
});
} catch (error) {
logger.error('Stalwart JMAP passthrough error', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -1,93 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_COOKIE_MAX_AGE,
};
/**
* POST /api/account/stalwart/password
* Change user password via Stalwart PATCH /api/principal/{name}
*
* Body: { currentPassword: string, newPassword: string }
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (newPassword.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
// Verify current password by attempting to authenticate
const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`;
const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': verifyAuth },
});
if (!verifyResponse.ok) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 });
}
// Change password via Stalwart principal API
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify([
{ action: 'set', field: 'secrets', value: newPassword },
]),
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart password change failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to change password', details: text },
{ status: response.status }
);
}
// If session cookie exists, update it with the new password
const cookieStore = await cookies();
if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
}
if (creds.authHeader.startsWith('Basic ')) {
setStalwartAuthContextInStore(cookieStore, creds.slot, {
serverUrl: creds.serverUrl,
username: creds.username,
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
});
}
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -1,96 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Parse Stalwart error response to extract meaningful error message
*/
function parseStalwartError(responseText: string): string {
try {
const error = JSON.parse(responseText);
if (error.detail) return error.detail;
if (error.error) return error.error;
return `HTTP ${error.status || 'Error'}`;
} catch {
return responseText;
}
}
/**
* GET /api/account/stalwart/principal
* Proxy to Stalwart GET /api/principal/{username}
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
const detail = parseStalwartError(text);
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
return NextResponse.json(
{ error: detail || 'Failed to fetch principal' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/account/stalwart/principal
* Proxy to Stalwart PATCH /api/principal/{username}
* Body: PrincipalUpdateAction[] (array of {action, field, value})
*/
export async function PATCH(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
// Prevent secrets field from being changed through this endpoint (use /password instead)
if (Array.isArray(body)) {
const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets');
if (hasSecrets) {
return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 });
}
}
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart principal update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
-44
View File
@@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/account/stalwart/probe
* Detect whether the JMAP server is Stalwart by probing /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwart: false });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return NextResponse.json({ isStalwart: false });
}
const data = await response.json();
const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean';
return NextResponse.json({ isStalwart });
} catch {
clearTimeout(timeout);
return NextResponse.json({ isStalwart: false });
}
} catch (error) {
logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ isStalwart: false });
}
}
+22 -4
View File
@@ -7,20 +7,38 @@ import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Check if the current user is a Stalwart admin by probing an admin-only endpoint.
* Permissions that indicate Stalwart admin privileges.
* If the authenticated user has at least one of these, they can manage
* system-level resources and are considered an admin.
*/
const ADMIN_PERMISSIONS = [
'sysAccountQuery',
'sysTenantQuery',
'sysSystemSettingsGet',
];
/**
* Check if the current user is a Stalwart admin by inspecting the
* permissions list returned by Stalwart's /api/account endpoint.
*/
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
try {
const creds = await getStalwartCredentials(request);
if (!creds) return false;
// Probe admin-only endpoint: listing principals requires admin privileges
const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, {
const response = await fetch(`${creds.serverUrl}/api/account`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
const isAdmin = response.ok;
if (!response.ok) {
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin: false });
return false;
}
const data = await response.json() as { permissions?: string[] };
const permissions = Array.isArray(data.permissions) ? data.permissions : [];
const isAdmin = ADMIN_PERMISSIONS.some(p => permissions.includes(p));
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin });
return isAdmin;
} catch (error) {
-41
View File
@@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/admin/stalwart-check
* Check if the currently logged-in user is a Stalwart admin.
* Probes the admin-only principal-list endpoint - if the user can access it, they're an admin.
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
// Probe an admin-only endpoint: listing principals requires admin privileges.
// Use limit=1 to minimize payload.
const url = `${creds.apiUrl}/api/principal?limit=1`;
const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
const isStalwartAdmin = response.ok;
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
return NextResponse.json({ isStalwartAdmin }, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Stalwart admin check error', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
}
const davPath = request.headers.get('X-WebDAV-Path') || '/';
const baseUrl = creds.apiUrl.replace(/\/$/, '');
const baseUrl = creds.serverUrl.replace(/\/$/, '');
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
// Build headers for the upstream request
+5 -1
View File
@@ -209,10 +209,14 @@ body {
}
.email-content-text a {
color: var(--color-primary);
color: #2563eb;
text-decoration: underline;
}
.dark .email-content-text a {
color: #60a5fa;
}
.email-content {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
+2
View File
@@ -1,5 +1,7 @@
import type { MetadataRoute } from "next";
export const dynamic = "force-dynamic";
export default function manifest(): MetadataRoute.Manifest {
const appName =
process.env.APP_NAME ||
@@ -17,6 +17,7 @@ interface CalendarAgendaViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
timeFormat?: "12h" | "24h";
}
@@ -33,6 +34,7 @@ export function CalendarAgendaView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
timeFormat = "24h",
}: CalendarAgendaViewProps) {
const t = useTranslations("calendar");
@@ -157,6 +159,7 @@ export function CalendarAgendaView({
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onHoverLeave?.()}
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
@@ -19,6 +19,7 @@ interface CalendarDayViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
@@ -37,6 +38,7 @@ export function CalendarDayView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onCreateAtTime,
timeFormat = "24h",
isMobile,
@@ -157,6 +159,7 @@ export function CalendarDayView({
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
/>
);
})}
@@ -264,6 +267,7 @@ export function CalendarDayView({
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
draggable
/>
<div
@@ -23,6 +23,7 @@ interface CalendarMonthViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onCreateAtTime?: (date: Date) => void;
firstDayOfWeek?: number;
isMobile?: boolean;
@@ -37,6 +38,7 @@ export function CalendarMonthView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onCreateAtTime,
firstDayOfWeek = 1,
isMobile,
@@ -279,6 +281,7 @@ export function CalendarMonthView({
onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
draggable
/>
</div>
@@ -22,6 +22,7 @@ interface CalendarWeekViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number;
timeFormat?: "12h" | "24h";
@@ -42,6 +43,7 @@ export function CalendarWeekView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onCreateAtTime,
firstDayOfWeek = 1,
timeFormat = "24h",
@@ -242,6 +244,7 @@ export function CalendarWeekView({
onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
/>
</div>
);
@@ -402,6 +405,7 @@ export function CalendarWeekView({
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
draggable
/>
<div
+7 -1
View File
@@ -17,6 +17,7 @@ interface EventCardProps {
onClick?: (anchorRect: DOMRect) => void;
onMouseEnter?: (anchorRect: DOMRect) => void;
onMouseLeave?: () => void;
onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void;
isSelected?: boolean;
draggable?: boolean;
continuesBefore?: boolean;
@@ -65,7 +66,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el;
}
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
@@ -109,12 +110,15 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
"aria-roledescription": "draggable event",
} : {};
const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined;
if (variant === "chip") {
return (
<button
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
aria-label={ariaLabel}
{...dragProps}
className={cn(
@@ -142,6 +146,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
aria-label={ariaLabel}
{...dragProps}
className={cn(
@@ -172,6 +177,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
aria-label={ariaLabel}
{...dragProps}
data-calendar-event
@@ -0,0 +1,93 @@
"use client";
import { useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import {
Pencil,
Copy,
Download,
ClipboardCopy,
Link as LinkIcon,
Trash2,
} from "lucide-react";
import type { CalendarEvent } from "@/lib/jmap/types";
interface Position {
x: number;
y: number;
}
interface EventContextMenuProps {
event: CalendarEvent;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
onEdit: () => void;
onDuplicate: () => void;
onExportICS: () => void;
onCopyTitle: () => void;
onCopyMeetingLink?: () => void;
onDelete: () => void;
}
export function EventContextMenu({
event,
position,
isOpen,
onClose,
menuRef,
onEdit,
onDuplicate,
onExportICS,
onCopyTitle,
onCopyMeetingLink,
onDelete,
}: EventContextMenuProps) {
const t = useTranslations("calendar");
const handle = (fn: () => void) => () => {
fn();
onClose();
};
const hasMeetingLink = !!(
event.virtualLocations && Object.values(event.virtualLocations).some((v) => v.uri)
);
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />
<ContextMenuItem icon={Copy} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
<ContextMenuSeparator />
<ContextMenuItem
icon={Download}
label={t("events.export_ics")}
onClick={handle(onExportICS)}
/>
<ContextMenuItem
icon={ClipboardCopy}
label={t("events.copy_title")}
onClick={handle(onCopyTitle)}
/>
{hasMeetingLink && onCopyMeetingLink && (
<ContextMenuItem
icon={LinkIcon}
label={t("events.copy_link")}
onClick={handle(onCopyMeetingLink)}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("events.delete")}
onClick={handle(onDelete)}
destructive
/>
</ContextMenu>
);
}
+14 -5
View File
@@ -9,7 +9,7 @@ import { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput } from "./participant-input";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
import {
isOrganizer,
getUserParticipantId,
@@ -233,6 +233,7 @@ export function EventModal({
.map(p => ({ name: p.name, email: p.email }));
});
const [sendInvitations, setSendInvitations] = useState(true);
const participantInputRef = useRef<ParticipantInputHandle>(null);
// Report live preview to parent for grid outline
useEffect(() => {
@@ -264,6 +265,9 @@ export function EventModal({
if (!trimmedTitle || isSaving) return;
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
const pendingAttendee = participantInputRef.current?.flush() ?? null;
const effectiveAttendees = pendingAttendee ? [...attendees, pendingAttendee] : attendees;
const startStr = allDay
? `${startDate}T00:00:00`
: `${startDate}T${startTime}:00`;
@@ -301,6 +305,10 @@ export function EventModal({
privacy: "public",
};
if (!event) {
data.uid = generateUUID();
}
if (location.trim()) {
data.locations = {
loc1: {
@@ -373,20 +381,20 @@ export function EventModal({
data.alerts = null;
}
if (attendees.length > 0 && currentUserEmails.length > 0) {
if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
const organizerEmail = currentUserEmails[0];
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
data.participants = buildParticipantMap(
{ name: organizerName, email: organizerEmail },
attendees
effectiveAttendees
) as Record<string, CalendarParticipant>;
data.replyTo = { imip: `mailto:${organizerEmail}` };
} else if (attendees.length === 0 && event?.participants) {
} else if (effectiveAttendees.length === 0 && event?.participants) {
data.participants = null;
data.replyTo = null;
}
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
setIsSaving(true);
try {
await onSave(data, shouldSendScheduling);
@@ -839,6 +847,7 @@ export function EventModal({
</span>
</label>
<ParticipantInput
ref={participantInputRef}
participants={attendees}
onAdd={handleAddAttendee}
onRemove={handleRemoveAttendee}
+29 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from "react";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
@@ -18,9 +18,13 @@ interface ParticipantInputProps {
disabled?: boolean;
}
export interface ParticipantInputHandle {
flush: () => Participant | null;
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function ParticipantInput({ participants, onAdd, onRemove, disabled }: ParticipantInputProps) {
export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled }, ref) {
const t = useTranslations("calendar.participants");
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Participant[]>([]);
@@ -86,8 +90,28 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
const handleBlur = useCallback(() => {
setTimeout(() => setShowSuggestions(false), 200);
}, []);
setTimeout(() => {
setShowSuggestions(false);
const trimmed = query.trim();
if (trimmed && EMAIL_REGEX.test(trimmed)) {
addParticipant({ name: "", email: trimmed });
}
}, 200);
}, [query, addParticipant]);
useImperativeHandle(ref, () => ({
flush: () => {
const trimmed = query.trim();
if (!trimmed || !EMAIL_REGEX.test(trimmed)) return null;
if (participants.some(e => e.email.toLowerCase() === trimmed.toLowerCase())) return null;
const p = { name: "", email: trimmed };
onAdd(p);
setQuery("");
setSuggestions([]);
setShowSuggestions(false);
return p;
},
}), [query, participants, onAdd]);
return (
<div className="space-y-2">
@@ -160,4 +184,4 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
)}
</div>
);
}
});
@@ -45,6 +45,9 @@ const defaultProps = {
onBulkDelete: vi.fn(),
onBulkAddToGroup: vi.fn(),
onBulkExport: vi.fn(),
onEditContact: vi.fn(),
onDeleteContact: vi.fn(),
onAddContactToGroup: vi.fn(),
};
describe('ContactList', () => {
+280
View File
@@ -0,0 +1,280 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Mail, CalendarDays, Loader2 } from "lucide-react";
import { useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { cn } from "@/lib/utils";
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
const EMAIL_LIMIT = 5;
const EVENT_LIMIT = 5;
const EVENT_LOOKAHEAD_DAYS = 365;
interface ContactActivityProps {
contact: ContactCard;
}
function getContactEmails(contact: ContactCard): string[] {
if (!contact.emails) return [];
const seen = new Set<string>();
const result: string[] = [];
for (const e of Object.values(contact.emails)) {
const addr = e.address?.trim().toLowerCase();
if (addr && !seen.has(addr)) {
seen.add(addr);
result.push(addr);
}
}
return result;
}
function buildEmailFilter(addresses: string[]): Record<string, unknown> {
const conditions: Record<string, unknown>[] = [];
for (const addr of addresses) {
conditions.push({ from: addr });
conditions.push({ to: addr });
}
if (conditions.length === 1) return conditions[0];
return { operator: "OR", conditions };
}
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
if (!event.participants) return false;
for (const p of Object.values(event.participants)) {
const email = p.email?.trim().toLowerCase();
if (email && addresses.has(email)) return true;
if (p.sendTo) {
for (const target of Object.values(p.sendTo)) {
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
}
}
}
if (event.organizerCalendarAddress) {
const m = event.organizerCalendarAddress.match(/mailto:(.+)/i);
const org = m ? m[1].trim().toLowerCase() : event.organizerCalendarAddress.trim().toLowerCase();
if (addresses.has(org)) return true;
}
return false;
}
export function ContactActivity({ contact }: ContactActivityProps) {
const t = useTranslations("contacts.activity");
const format = useFormatter();
const router = useRouter();
const client = useAuthStore((s) => s.client);
const selectEmail = useEmailStore((s) => s.selectEmail);
const setSelectedEventId = useCalendarStore((s) => s.setSelectedEventId);
const setSelectedDate = useCalendarStore((s) => s.setSelectedDate);
const [emails, setEmails] = useState<Email[] | null>(null);
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
const [emailsLoading, setEmailsLoading] = useState(false);
const [eventsLoading, setEventsLoading] = useState(false);
const [emailsError, setEmailsError] = useState(false);
const [eventsError, setEventsError] = useState(false);
const addresses = getContactEmails(contact);
const addressKey = addresses.join(",");
useEffect(() => {
if (!client || addresses.length === 0) {
setEmails([]);
return;
}
let cancelled = false;
setEmailsLoading(true);
setEmailsError(false);
client
.advancedSearchEmails(buildEmailFilter(addresses), undefined, EMAIL_LIMIT, 0)
.then((res) => {
if (cancelled) return;
setEmails(res.emails);
})
.catch(() => {
if (cancelled) return;
setEmailsError(true);
setEmails([]);
})
.finally(() => {
if (!cancelled) setEmailsLoading(false);
});
return () => {
cancelled = true;
};
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!client || addresses.length === 0) {
setEvents([]);
return;
}
let cancelled = false;
setEventsLoading(true);
setEventsError(false);
const now = new Date();
const before = new Date(now.getTime() + EVENT_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000);
const addrSet = new Set(addresses);
client
.queryAllCalendarEvents(
{ after: now.toISOString(), before: before.toISOString() },
[{ property: "start", isAscending: true }],
500,
)
.then((all) => {
if (cancelled) return;
const matching = all
.filter((e) => eventInvolvesContact(e, addrSet))
.sort((a, b) => a.start.localeCompare(b.start))
.slice(0, EVENT_LIMIT);
setEvents(matching);
})
.catch(() => {
if (cancelled) return;
setEventsError(true);
setEvents([]);
})
.finally(() => {
if (!cancelled) setEventsLoading(false);
});
return () => {
cancelled = true;
};
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
if (addresses.length === 0) return null;
const handleOpenEmail = (email: Email) => {
selectEmail(email);
router.push("/mail");
};
const handleOpenEvent = (event: CalendarEvent) => {
const start = new Date(event.start);
if (!isNaN(start.getTime())) setSelectedDate(start);
setSelectedEventId(event.id);
router.push("/calendar");
};
const formatEmailDate = (dateStr: string) => {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
return format.dateTime(d, sameYear
? { month: "short", day: "numeric" }
: { year: "numeric", month: "short", day: "numeric" });
};
const formatEventDate = (event: CalendarEvent) => {
const d = new Date(event.start);
if (isNaN(d.getTime())) return event.start;
if (event.showWithoutTime) {
return format.dateTime(d, { weekday: "short", month: "short", day: "numeric" });
}
return format.dateTime(d, {
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
return (
<>
<ActivitySection icon={Mail} title={t("recent_emails")}>
{emailsLoading ? (
<LoadingRow />
) : emailsError ? (
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
) : !emails || emails.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("no_emails")}</p>
) : (
emails.map((email) => (
<button
key={email.id}
type="button"
onClick={() => handleOpenEmail(email)}
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium truncate">
{email.subject || t("no_subject")}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatEmailDate(email.receivedAt)}
</span>
</div>
{email.preview && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{email.preview}
</p>
)}
</button>
))
)}
</ActivitySection>
<ActivitySection icon={CalendarDays} title={t("upcoming_events")}>
{eventsLoading ? (
<LoadingRow />
) : eventsError ? (
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
) : !events || events.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("no_events")}</p>
) : (
events.map((event) => (
<button
key={event.id}
type="button"
onClick={() => handleOpenEvent(event)}
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium truncate">
{event.title || t("no_title")}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatEventDate(event)}
</span>
</div>
</button>
))
)}
</ActivitySection>
</>
);
}
function ActivitySection({
icon: Icon,
title,
children,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
children: React.ReactNode;
}) {
return (
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", "border-l-rose-400 dark:border-l-rose-500")}>
<div className="flex items-center gap-2 mb-2.5">
<Icon className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
</div>
<div className="space-y-1 pl-6">{children}</div>
</div>
);
}
function LoadingRow() {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
</div>
);
}
@@ -0,0 +1,160 @@
"use client";
import { useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import {
Eye,
Pencil,
Mail,
ClipboardCopy,
Download,
Users,
Trash2,
} from "lucide-react";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactPrimaryEmail } from "@/stores/contact-store";
import { exportContact } from "./contact-export";
import { toast } from "@/stores/toast-store";
interface Position {
x: number;
y: number;
}
interface ContactContextMenuProps {
contact: ContactCard;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
isMultiSelect?: boolean;
selectedCount?: number;
onOpen: () => void;
onEdit: () => void;
onDelete: () => void;
onAddToGroup: () => void;
onBatchExport?: () => void;
onBatchAddToGroup?: () => void;
onBatchDelete?: () => void;
}
export function ContactContextMenu({
contact,
position,
isOpen,
onClose,
menuRef,
isMultiSelect = false,
selectedCount = 1,
onOpen,
onEdit,
onDelete,
onAddToGroup,
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
const email = getContactPrimaryEmail(contact);
const showBatchActions = isMultiSelect && selectedCount > 1;
const handle = (fn: () => void) => () => {
fn();
onClose();
};
const handleSendEmail = () => {
if (!email) return;
window.location.href = `mailto:${email}`;
};
const handleCopyEmail = async () => {
if (!email) return;
try {
await navigator.clipboard.writeText(email);
toast.success(t("detail.copied"));
} catch {
toast.error(t("detail.copy_failed"));
}
};
const handleExport = () => {
exportContact(contact);
toast.success(t("export.success", { count: 1 }));
};
if (showBatchActions) {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuHeader>
{t("bulk.selected", { count: selectedCount })}
</ContextMenuHeader>
<ContextMenuItem
icon={Users}
label={t("bulk.add_to_group")}
onClick={handle(() => onBatchAddToGroup?.())}
disabled={!onBatchAddToGroup}
/>
<ContextMenuItem
icon={Download}
label={t("bulk.export")}
onClick={handle(() => onBatchExport?.())}
disabled={!onBatchExport}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("bulk.delete")}
onClick={handle(() => onBatchDelete?.())}
disabled={!onBatchDelete}
destructive
/>
</ContextMenu>
);
}
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
{email && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={Mail}
label={t("context_menu.send_email")}
onClick={handle(handleSendEmail)}
/>
<ContextMenuItem
icon={ClipboardCopy}
label={t("detail.copy_email")}
onClick={handle(handleCopyEmail)}
/>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Users}
label={t("context_menu.add_to_group")}
onClick={handle(onAddToGroup)}
/>
<ContextMenuItem
icon={Download}
label={t("context_menu.export_vcard")}
onClick={handle(handleExport)}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("context_menu.delete")}
onClick={handle(onDelete)}
destructive
/>
</ContextMenu>
);
}
+3
View File
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity";
import { useSmimeStore } from "@/stores/smime-store";
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
import type { CertificateInfo } from "@/lib/smime/types";
@@ -204,6 +205,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
<div className="px-6 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<ContactActivity contact={contact} />
{/* Contact info */}
{emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")} category="contact">
+3 -1
View File
@@ -17,9 +17,10 @@ interface ContactListItemProps {
selectedContactIds: Set<string>;
onClick: (e: React.MouseEvent) => void;
onCheckboxClick: (e: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent, contact: ContactCard) => void;
}
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick }: ContactListItemProps) {
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu }: ContactListItemProps) {
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const org = contact.organizations
@@ -56,6 +57,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
draggable
onDragStart={handleDragStart}
onClick={onClick}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, contact) : undefined}
className={cn(
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
isSelected
+29
View File
@@ -6,6 +6,8 @@ import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Sq
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
@@ -28,6 +30,9 @@ interface ContactListProps {
onBulkDelete: () => void;
onBulkAddToGroup: () => void;
onBulkExport: () => void;
onEditContact: (id: string) => void;
onDeleteContact: (contact: ContactCard) => void;
onAddContactToGroup: (id: string) => void;
}
export function ContactList({
@@ -47,9 +52,13 @@ export function ContactList({
onBulkDelete,
onBulkAddToGroup,
onBulkExport,
onEditContact,
onDeleteContact,
onAddContactToGroup,
}: ContactListProps) {
const t = useTranslations("contacts");
const density = useSettingsStore((state) => state.density);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
const filtered = useMemo(() => {
if (!searchQuery) return contacts;
@@ -210,11 +219,31 @@ export function ContactList({
e.stopPropagation();
onToggleSelection(contact.id);
}}
onContextMenu={(e, c) => openContextMenu(e, c)}
/>
))}
</div>
)}
</div>
{contextMenu.data && (
<ContactContextMenu
contact={contextMenu.data}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onClose={closeContextMenu}
menuRef={menuRef}
isMultiSelect={selectedContactIds.has(contextMenu.data.id)}
selectedCount={selectedContactIds.size}
onOpen={() => onSelectContact(contextMenu.data!.id)}
onEdit={() => onEditContact(contextMenu.data!.id)}
onDelete={() => onDeleteContact(contextMenu.data!)}
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
onBatchExport={onBulkExport}
onBatchAddToGroup={onBulkAddToGroup}
onBatchDelete={onBulkDelete}
/>
)}
</div>
);
}
+132 -30
View File
@@ -6,7 +6,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
import { cn, formatFileSize, formatDateTime } from "@/lib/utils";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
@@ -66,7 +66,7 @@ interface EmailComposerProps {
fromEmail?: string;
fromName?: string;
identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
}) => void | Promise<void>;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
@@ -86,9 +86,21 @@ interface EmailComposerProps {
htmlBody?: string;
receivedAt?: string;
accountId?: string;
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
};
}
type ComposerAttachment = {
file?: File;
name: string;
type: string;
size: number;
blobId?: string;
uploading?: boolean;
error?: boolean;
abortController?: AbortController;
};
export function EmailComposer({
onSend,
onClose,
@@ -201,7 +213,22 @@ export function EmailComposer({
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>("");
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
if (mode === 'forward' && replyTo?.attachments?.length) {
return replyTo.attachments
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
// (matches the viewer's hideInlineImageAttachments logic).
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
.map(att => ({
name: att.name || 'attachment',
type: att.type || 'application/octet-stream',
size: att.size,
blobId: att.blobId,
}));
}
return [];
});
const inlineImagesRef = useRef<Array<{ cid: string; blobId: string; type: string; name: string; size: number; dataUrl: string }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
const [shakeField, setShakeField] = useState<string | null>(null);
@@ -324,7 +351,7 @@ export function EmailComposer({
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
// Track initial values for dirty detection (captured once on first render)
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: 0 });
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
const isDirtyRef = useRef(false);
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
@@ -523,9 +550,16 @@ export function EmailComposer({
const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return;
const newAttachments = files.map(file => {
const newAttachments: ComposerAttachment[] = files.map(file => {
const controller = new AbortController();
return { file, uploading: true, abortController: controller };
return {
file,
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
uploading: true,
abortController: controller,
};
});
setAttachments(prev => [...prev, ...newAttachments]);
@@ -560,18 +594,38 @@ export function EmailComposer({
}
}, [client, t]);
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
reader.onerror = () => {
debug.error(`Failed to read inline image ${file.name}`);
toast.error(t('upload_failed', { filename: file.name }));
resolve(null);
};
reader.readAsDataURL(file);
});
}, [t]);
const handleImageUpload = useCallback(async (
file: File,
): Promise<{ src: string; cid: string } | null> => {
if (!client) return null;
try {
const readAsDataUrl = new Promise<string | null>((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
const [{ blobId }, dataUrl] = await Promise.all([
client.uploadBlob(file),
readAsDataUrl,
]);
if (!dataUrl) throw new Error('Failed to read image as data URL');
const cid = `${generateUUID()}@webmail`;
inlineImagesRef.current.push({
cid,
blobId,
type: file.type || 'application/octet-stream',
name: file.name,
size: file.size,
dataUrl,
});
return { src: dataUrl, cid };
} catch (error) {
debug.error(`Failed to upload inline image ${file.name}:`, error);
toast.error(t('upload_failed', { filename: file.name }));
return null;
}
}, [client, t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
@@ -648,9 +702,9 @@ export function EmailComposer({
.filter(att => att.blobId && !att.uploading)
.map(att => ({
blobId: att.blobId!,
name: att.file.name,
type: att.file.type,
size: att.file.size,
name: att.name,
type: att.type,
size: att.size,
}));
// Create a hash of current data to compare with last saved
@@ -751,6 +805,41 @@ export function EmailComposer({
return undefined;
};
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
// references so recipient clients that strip data URIs can still render them.
const rewriteInlineImages = (html: string): {
html: string;
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
} => {
const known = inlineImagesRef.current;
if (known.length === 0) return { html, attachments: [] };
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
const used = new Map<string, typeof known[number]>();
doc.querySelectorAll('img[data-cid]').forEach((img) => {
const cid = img.getAttribute('data-cid');
if (!cid) return;
const entry = known.find((e) => e.cid === cid);
if (!entry) return;
img.setAttribute('src', `cid:${cid}`);
img.removeAttribute('data-cid');
used.set(cid, entry);
});
return {
html: doc.body.innerHTML,
attachments: Array.from(used.values()).map((e) => ({
blobId: e.blobId,
name: e.name,
type: e.type,
size: e.size,
disposition: 'inline' as const,
cid: e.cid,
})),
};
};
const handleSend = async (skipAttachmentCheck = false) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
@@ -821,9 +910,11 @@ export function EmailComposer({
? appendPlainTextSignature(body, currentIdentity)
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
const finalHtmlBody = plainTextMode
? undefined
: `<div>${body}</div>${buildSignatureHtml()}`;
: `<div>${rewritten!.html}</div>${buildSignatureHtml()}`;
const inlineAttachments = rewritten?.attachments ?? [];
try {
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
@@ -852,19 +943,29 @@ export function EmailComposer({
for (const att of attachments) {
if (att.error || att.uploading) continue;
let content: ArrayBuffer;
if (att.file.size > 0) {
if (att.file && att.file.size > 0) {
content = await att.file.arrayBuffer();
} else if (att.blobId && client) {
content = await client.fetchBlobArrayBuffer(att.blobId, att.file.name, att.file.type);
content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
} else {
continue;
}
mimeAttachments.push({
filename: att.file.name,
contentType: att.file.type || 'application/octet-stream',
filename: att.name,
contentType: att.type || 'application/octet-stream',
content,
});
}
for (const inline of inlineAttachments) {
if (!client) break;
const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type);
mimeAttachments.push({
filename: inline.name,
contentType: inline.type,
content,
cid: inline.cid,
});
}
// 4. Build canonical MIME
const mimeBytes = buildMimeMessage({
@@ -924,9 +1025,10 @@ export function EmailComposer({
} else {
// Standard JMAP send path
// Collect uploaded attachment blobIds for the send request
const uploadedAttachments = attachments
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments
.filter(att => att.blobId && !att.uploading && !att.error)
.map(att => ({ blobId: att.blobId!, name: att.file.name, type: att.file.type || 'application/octet-stream', size: att.file.size }));
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
uploadedAttachments.push(...inlineAttachments);
await onSend?.({
to: toAddresses,
@@ -1306,9 +1408,9 @@ export function EmailComposer({
) : (
<Paperclip className="w-3 h-3 flex-shrink-0" />
)}
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.file.name}</span>
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
({formatFileSize(att.file.size)})
({formatFileSize(att.size)})
</span>
<button
onClick={() => removeAttachment(index)}
+6 -2
View File
@@ -66,6 +66,7 @@ interface EmailContextMenuProps {
// Batch actions
onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void;
onBatchArchive?: () => void;
onBatchMoveToMailbox?: (mailboxId: string) => void;
onBatchMarkAsSpam?: () => void;
onBatchUndoSpam?: () => void;
@@ -127,6 +128,7 @@ export function EmailContextMenu({
onUndoSpam,
onBatchMarkAsRead,
onBatchDelete,
onBatchArchive,
onBatchMoveToMailbox,
onBatchMarkAsSpam,
onBatchUndoSpam,
@@ -235,8 +237,10 @@ export function EmailContextMenu({
<ContextMenuItem
icon={Archive}
label={t("archive")}
onClick={() => handleAction(onArchive!)}
disabled={!onArchive}
onClick={() =>
handleAction(showBatchActions ? onBatchArchive! : onArchive!)
}
disabled={showBatchActions ? !onBatchArchive : !onArchive}
/>
{/* Delete */}
+8
View File
@@ -496,6 +496,14 @@ export function EmailList({
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => {
if (!onArchive) return;
const selected = emails.filter((e) => selectedEmailIds.has(e.id));
for (const email of selected) {
await onArchive(email);
}
clearSelection();
}}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
onBatchMarkAsSpam={async () => {
if (client) {
+32 -49
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
import ReactDOM from "react-dom";
import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
@@ -896,6 +896,7 @@ export function EmailViewer({
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
@@ -2074,14 +2075,16 @@ export function EmailViewer({
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
if (smimeDecryptedAttachments.length > 0) {
return smimeDecryptedAttachments.map((attachment, index) => ({
id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
name: attachment.filename,
type: attachment.mimeType || 'application/octet-stream',
size: getPostalMimeAttachmentSize(attachment),
cid: attachment.contentId,
decryptedAttachment: attachment,
}));
return smimeDecryptedAttachments
.filter(att => !(hideInlineImageAttachments && att.contentId && (att.mimeType || '').startsWith('image/')))
.map((attachment, index) => ({
id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
name: attachment.filename,
type: attachment.mimeType || 'application/octet-stream',
size: getPostalMimeAttachmentSize(attachment),
cid: attachment.contentId,
decryptedAttachment: attachment,
}));
}
const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email);
@@ -2093,6 +2096,9 @@ export function EmailViewer({
// Hide calendar MIME parts (text/calendar, application/ics) when the invitation
// banner is shown - prevents raw ICS files appearing as spurious attachments.
.filter(att => !hasCalInvitation || !isCalendarMimeType(att.type))
// Hide inline cid-referenced images when the user has opted to keep them
// out of the attachment list (default on): these are embedded in the body.
.filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
.map((attachment, index) => ({
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
name: attachment.name || null,
@@ -2123,7 +2129,7 @@ export function EmailViewer({
}));
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled]);
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]);
// Generate email source for viewing
const generateEmailSource = (email: Email): string => {
@@ -2397,16 +2403,8 @@ export function EmailViewer({
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const textContent = email.bodyValues[email.textBody[0].partId].value;
// Convert plain text to HTML with proper formatting
// Uses white-space: pre-wrap on the container to preserve newlines/whitespace
const htmlFromText = textContent
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return {
html: htmlFromText,
html: plainTextToSafeHtml(textContent),
isHtml: false
};
}
@@ -2444,12 +2442,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true };
}
if (smimeDecryptedText) {
const htmlFromText = smimeDecryptedText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { html: htmlFromText, isHtml: false };
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false };
}
// TNEF (winmail.dat) extracted content
if (tnefHtml) {
@@ -2457,12 +2450,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true };
}
if (tnefText) {
const htmlFromText = tnefText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { html: htmlFromText, isHtml: false };
return { html: plainTextToSafeHtml(tnefText), isHtml: false };
}
// Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) {
@@ -2470,12 +2458,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true };
}
if (embeddedEmailText) {
const htmlFromText = embeddedEmailText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return { html: htmlFromText, isHtml: false };
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false };
}
return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
@@ -4791,61 +4774,61 @@ export function EmailViewer({
onClick={onNavigatePrev}
disabled={!onNavigatePrev}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)}
aria-label={t('tooltips.previous')}
>
<ChevronLeft className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('previous')}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('previous')}</span>
</button>
{isDraft && onEditDraft ? (
<button
onClick={() => onEditDraft()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary active:text-primary/80 transition-colors duration-150"
aria-label={t('tooltips.edit_draft')}
>
<EditIcon className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('edit_draft')}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('edit_draft')}</span>
</button>
) : (
<>
<button
onClick={() => onReply?.()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply')}
>
<Reply className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('reply')}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply')}</span>
</button>
<button
onClick={onReplyAll}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply_all')}
>
<ReplyAll className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('reply_all')}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply_all')}</span>
</button>
<button
onClick={onForward}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.forward')}
>
<Forward className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('forward')}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('forward')}</span>
</button>
</>)}
<button
onClick={onNavigateNext}
disabled={!onNavigateNext}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)}
aria-label={t('tooltips.next')}
>
<ChevronRight className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('next')}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('next')}</span>
</button>
</div>
</nav>
+5
View File
@@ -113,6 +113,11 @@ export const ResizableImage = Node.create({
alt: { default: null },
title: { default: null },
width: { default: null },
cid: {
default: null,
parseHTML: (el) => el.getAttribute("data-cid"),
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
},
};
},
+12 -7
View File
@@ -31,10 +31,15 @@ import {
Heading2,
} from "lucide-react";
export interface InlineImageUpload {
src: string;
cid?: string;
}
interface RichTextEditorProps {
content: string;
onChange: (html: string) => void;
onImageUpload?: (file: File) => Promise<string | null>;
onImageUpload?: (file: File) => Promise<InlineImageUpload | null>;
placeholder?: string;
className?: string;
hasError?: boolean;
@@ -120,11 +125,11 @@ export function RichTextEditor({
event.preventDefault();
event.stopPropagation();
for (const file of imageFiles) {
upload(file).then((url) => {
if (url) {
upload(file).then((result) => {
if (result) {
const { state } = view;
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
view.dispatch(tr);
}
@@ -141,10 +146,10 @@ export function RichTextEditor({
if (imageFiles.length === 0) return false;
event.preventDefault();
for (const file of imageFiles) {
upload(file).then((url) => {
if (url) {
upload(file).then((result) => {
if (result) {
const { state } = view;
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
const tr = state.tr.replaceSelectionWith(node);
view.dispatch(tr);
}
+11 -10
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store";
@@ -237,6 +237,7 @@ function EmailCard({
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const density = useSettingsStore((state) => state.density);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
const sender = email.from?.[0];
const isUnread = !email.keywords?.$seen;
@@ -419,12 +420,7 @@ function EmailCard({
// Plain text fallback
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const text = email.bodyValues[email.textBody[0].partId].value;
const htmlEscaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
return { html: htmlEscaped, isHtml: false };
return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false };
}
}
@@ -549,10 +545,14 @@ function EmailCard({
</div>
{/* Attachments */}
{email.attachments && email.attachments.length > 0 && (
{(() => {
const visibleAttachments = (email.attachments ?? []).filter(
att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))
);
return visibleAttachments.length > 0 && (
<div className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{email.attachments.map((attachment, idx) => {
{visibleAttachments.map((attachment, idx) => {
const Icon = getFileIcon(attachment.name, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
@@ -581,7 +581,8 @@ function EmailCard({
})}
</div>
</div>
)}
);
})()}
{/* Action Buttons */}
<div className="px-4 pb-4 flex gap-2">
+1 -1
View File
@@ -223,7 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
{!loading && !error && fileType === "pdf" && objectUrl && (
<iframe
src={objectUrl}
sandbox="allow-same-origin"
sandbox="allow-scripts"
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
/>
+25 -13
View File
@@ -7,7 +7,7 @@ import { AccountSwitcher } from "./account-switcher";
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store";
import { usePathname, Link } from "@/i18n/navigation";
import { usePathname, Link, useRouter } from "@/i18n/navigation";
import NextLink from "next/link";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
@@ -18,7 +18,7 @@ import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials } from "@/lib/account-utils";
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -163,6 +163,7 @@ export function NavigationRail({
}: NavigationRailProps) {
const t = useTranslations("sidebar");
const pathname = usePathname();
const router = useRouter();
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const { supportsCalendar } = useCalendarStore();
@@ -223,11 +224,12 @@ export function NavigationRail({
let cancelled = false;
const headers = getActiveAccountSlotHeaders();
if (!headers['X-JMAP-Cookie-Slot']) return;
apiFetch('/api/admin/stalwart-check', { headers })
apiFetch('/api/admin/auth', { headers })
.then(res => res.json())
.then(data => {
if (!cancelled && data.isStalwartAdmin) {
setIsStalwartAdmin(true);
if (cancelled || !data.stalwartAdmin) return;
setIsStalwartAdmin(true);
if (!data.authenticated) {
// Pre-create admin session so /admin works even after full page navigation
apiFetch('/api/admin/auth', {
method: 'POST',
@@ -275,7 +277,7 @@ export function NavigationRail({
href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
isActive
? "text-primary"
@@ -294,7 +296,7 @@ export function NavigationRail({
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight">{t(item.labelKey)}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t(item.labelKey)}</span>
</Link>
);
})}
@@ -316,7 +318,7 @@ export function NavigationRail({
}
}}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
isActive
? "text-primary"
@@ -329,7 +331,7 @@ export function NavigationRail({
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-[64px]">{app.name}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{app.name}</span>
</button>
);
})}
@@ -339,13 +341,13 @@ export function NavigationRail({
<NextLink
href="/admin"
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
"text-muted-foreground hover:text-foreground"
)}
>
<Shield className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t("admin") || "Admin"}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
</NextLink>
)}
@@ -354,7 +356,7 @@ export function NavigationRail({
href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
isSettingsActive
? "text-primary"
@@ -368,7 +370,7 @@ export function NavigationRail({
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight">{t("settings")}</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("settings")}</span>
</Link>
</nav>
);
@@ -605,6 +607,16 @@ export function NavigationRail({
</button>
);
})}
{accounts.length < MAX_ACCOUNTS && (
<button
onClick={() => router.push(`/login?mode=add-account` as never)}
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
title={t("add_account")}
aria-label={t("add_account")}
>
<Plus className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Logout button with popover */}
+300 -150
View File
@@ -1,12 +1,14 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor } from 'lucide-react';
import QRCode from 'qrcode';
import * as OTPAuth from 'otpauth';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { useAccountSecurityStore } from '@/stores/account-security-store';
import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type AppCredentialInput } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
@@ -172,37 +174,95 @@ function DisplayNameSection() {
);
}
function generateTotp(accountLabel: string): { totp: OTPAuth.TOTP; url: string } {
const totp = new OTPAuth.TOTP({
issuer: 'Stalwart',
label: accountLabel || 'account',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: new OTPAuth.Secret({ size: 20 }),
});
return { totp, url: totp.toString() };
}
function TotpSection() {
const t = useTranslations('settings.security');
const { otpEnabled, enableTotp, disableTotp, isSaving, isLoadingAuth } = useAccountSecurityStore();
const [totpUrl, setTotpUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { client } = useAuthStore();
const [setupUrl, setSetupUrl] = useState<string | null>(null);
const [setupTotp, setSetupTotp] = useState<OTPAuth.TOTP | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [password, setPassword] = useState('');
const [otpCode, setOtpCode] = useState('');
const [setupError, setSetupError] = useState<string | null>(null);
const [disableOpen, setDisableOpen] = useState(false);
useEffect(() => {
if (!setupUrl) { setQrDataUrl(null); return; }
let cancelled = false;
QRCode.toDataURL(setupUrl, { width: 220, margin: 1 })
.then((url) => { if (!cancelled) setQrDataUrl(url); })
.catch(() => { /* ignore */ });
return () => { cancelled = true; };
}, [setupUrl]);
const startSetup = () => {
const { totp, url } = generateTotp(client?.getUsername() ?? 'account');
setSetupTotp(totp);
setSetupUrl(url);
setPassword('');
setOtpCode('');
setSetupError(null);
};
const cancelSetup = () => {
setSetupTotp(null);
setSetupUrl(null);
setPassword('');
setOtpCode('');
setSetupError(null);
};
const confirmSetup = async () => {
if (!setupTotp || !setupUrl) return;
if (!password) { setSetupError(t('totp.password_required')); return; }
if (!otpCode.trim()) { setSetupError(t('totp.code_required')); return; }
if (setupTotp.validate({ token: otpCode.trim(), window: 1 }) === null) {
setSetupError(t('totp.code_invalid'));
return;
}
const handleToggle = async (enable: boolean) => {
try {
if (enable) {
const url = await enableTotp();
setTotpUrl(url);
toast.success(t('totp.enabled'));
} else {
await disableTotp();
setTotpUrl(null);
toast.success(t('totp.disabled'));
}
await enableTotp(password, setupUrl, otpCode.trim());
cancelSetup();
toast.success(t('totp.enabled'));
} catch (err) {
toast.error(
enable ? t('totp.enable_error') : t('totp.disable_error'),
err instanceof Error ? err.message : undefined
);
setSetupError(err instanceof Error ? err.message : t('totp.enable_error'));
}
};
const handleCopyUrl = () => {
if (totpUrl) {
navigator.clipboard.writeText(totpUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
const handleDisable = async () => {
if (!password) { setSetupError(t('totp.password_required')); return; }
try {
await disableTotp(password);
setDisableOpen(false);
setPassword('');
setSetupError(null);
toast.success(t('totp.disabled'));
} catch (err) {
setSetupError(err instanceof Error ? err.message : t('totp.disable_error'));
}
};
const handleToggle = (enable: boolean) => {
setSetupError(null);
if (enable) {
startSetup();
} else {
setDisableOpen(true);
setPassword('');
}
};
@@ -218,30 +278,65 @@ function TotpSection() {
<div className="space-y-3">
<SettingItem label={t('totp.label')} description={t('totp.description')}>
<div className="flex items-center gap-2">
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<ToggleSwitch
checked={otpEnabled}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<ToggleSwitch
checked={otpEnabled || !!setupUrl}
onChange={handleToggle}
disabled={isSaving}
/>
<span className={cn('text-xs font-medium', otpEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{otpEnabled ? t('totp.active') : t('totp.inactive')}
</span>
</div>
</SettingItem>
{totpUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
{setupUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-3">
<p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p>
{qrDataUrl && (
<div className="flex justify-center">
<img src={qrDataUrl} alt="TOTP QR code" className="rounded bg-white p-2" />
</div>
)}
<div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">
{totpUrl}
</code>
<Button variant="outline" size="sm" onClick={handleCopyUrl}>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">{setupUrl}</code>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('password.current')}</label>
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('totp.verification_code')}</label>
<Input value={otpCode} onChange={(e) => setOtpCode(e.target.value)} inputMode="numeric" maxLength={6} />
</div>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={confirmSetup} disabled={isSaving || !password || !otpCode}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('totp.confirm')}
</Button>
<Button size="sm" variant="ghost" onClick={cancelSetup}>{t('app_passwords.cancel')}</Button>
</div>
</div>
)}
{disableOpen && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{t('totp.disable_confirm_prompt')}</p>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('password.current')}
autoComplete="current-password"
/>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" variant="destructive" onClick={handleDisable} disabled={isSaving || !password}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('totp.disable')}
</Button>
<Button size="sm" variant="ghost" onClick={() => { setDisableOpen(false); setPassword(''); setSetupError(null); }}>
{t('app_passwords.cancel')}
</Button>
</div>
</div>
@@ -250,58 +345,113 @@ function TotpSection() {
);
}
function AppPasswordsSection() {
const t = useTranslations('settings.security');
const { appPasswords, addAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore();
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState('');
const [newPassword, setNewPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
function parseIpList(raw: string): string[] {
return raw
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
const generatePassword = useCallback(() => {
const chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let result = '';
const array = new Uint8Array(24);
crypto.getRandomValues(array);
for (const byte of array) {
result += chars[byte % chars.length];
}
// Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx
return result.match(/.{1,4}/g)?.join('-') ?? result;
}, []);
function CredentialRow({ entry, onRemove, isSaving }: { entry: AppPasswordInfo | ApiKeyInfo; onRemove: (id: string) => void; isSaving: boolean }) {
return (
<div className="flex items-start justify-between py-2 px-3 bg-muted/50 rounded-md gap-2">
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm text-foreground truncate">{entry.description || entry.id}</span>
{entry.createdAt && (
<span className="text-xs text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString()}
{entry.expiresAt ? ` · expires ${new Date(entry.expiresAt).toLocaleDateString()}` : ''}
</span>
)}
{entry.allowedIps.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{entry.allowedIps.map((ip) => (
<span
key={ip}
className="text-[10px] font-mono bg-background border border-border rounded px-1.5 py-0.5 text-muted-foreground"
>
{ip}
</span>
))}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onRemove(entry.id)}
disabled={isSaving}
className="text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
);
}
interface CredentialSectionProps {
icon: typeof Smartphone;
i18nNamespace: 'app_passwords' | 'api_keys';
entries: Array<AppPasswordInfo | ApiKeyInfo>;
onCreate: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
onRemove: (id: string) => Promise<void>;
}
function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRemove }: CredentialSectionProps) {
const t = useTranslations('settings.security');
const tk = (key: string) => t(`${i18nNamespace}.${key}`);
const { isSaving, isLoadingAuth } = useAccountSecurityStore();
const [showAdd, setShowAdd] = useState(false);
const [newDescription, setNewDescription] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [allowedIpsRaw, setAllowedIpsRaw] = useState('');
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
const password = newPassword || generatePassword();
if (!newDescription.trim()) return;
try {
await addAppPassword(newName.trim(), password);
setNewName('');
setNewPassword('');
const result = await onCreate({
description: newDescription.trim(),
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
allowedIps: parseIpList(allowedIpsRaw),
});
setCreatedSecret(result.secret);
setNewDescription('');
setExpiresAt('');
setAllowedIpsRaw('');
setShowAdd(false);
toast.success(t('app_passwords.added'));
toast.success(tk('added'));
} catch (err) {
toast.error(t('app_passwords.add_error'), err instanceof Error ? err.message : undefined);
toast.error(tk('add_error'), err instanceof Error ? err.message : undefined);
}
};
const handleRemove = async (name: string) => {
const handleRemove = async (id: string) => {
try {
await removeAppPassword(name);
toast.success(t('app_passwords.removed'));
await onRemove(id);
toast.success(tk('removed'));
} catch (err) {
toast.error(t('app_passwords.remove_error'), err instanceof Error ? err.message : undefined);
toast.error(tk('remove_error'), err instanceof Error ? err.message : undefined);
}
};
const handleCopySecret = () => {
if (!createdSecret) return;
navigator.clipboard.writeText(createdSecret).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
if (isLoadingAuth) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2">
<Smartphone className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
<Icon className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
</div>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
@@ -312,53 +462,61 @@ function AppPasswordsSection() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Smartphone className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
<Icon className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
</div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 mr-1" />
{t('app_passwords.add')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('app_passwords.description')}</p>
<p className="text-xs text-muted-foreground">{tk('description')}</p>
{createdSecret && (
<div className="p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{tk('copy_now_warning')}</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 font-mono break-all">
{createdSecret}
</code>
<Button variant="outline" size="sm" onClick={handleCopySecret}>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
<Button variant="ghost" size="sm" onClick={() => setCreatedSecret(null)}>
{t('app_passwords.done')}
</Button>
</div>
)}
{showAdd && (
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.name_label')}</label>
<label className="text-xs text-muted-foreground mb-1 block">{tk('name_label')}</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('app_passwords.name_placeholder')}
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder={tk('name_placeholder')}
required
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.password_label')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t('app_passwords.password_placeholder')}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setNewPassword(generatePassword())}>
{t('app_passwords.generate')}
</Button>
</div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.expires_label')}</label>
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.allowed_ips_label')}</label>
<textarea
value={allowedIpsRaw}
onChange={(e) => setAllowedIpsRaw(e.target.value)}
placeholder={t('app_passwords.allowed_ips_placeholder')}
rows={2}
className="w-full text-xs font-mono px-3 py-2 rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="text-[10px] text-muted-foreground mt-1">{t('app_passwords.allowed_ips_hint')}</p>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSaving || !newName.trim()}>
<Button type="submit" size="sm" disabled={isSaving || !newDescription.trim()}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('app_passwords.create')}
</Button>
@@ -369,47 +527,48 @@ function AppPasswordsSection() {
</form>
)}
{appPasswords.length > 0 ? (
{entries.length > 0 ? (
<div className="space-y-1">
{appPasswords.map((name) => (
<div key={name} className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md">
<span className="text-sm text-foreground">{name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(name)}
disabled={isSaving}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
{entries.map((entry) => (
<CredentialRow key={entry.id} entry={entry} onRemove={handleRemove} isSaving={isSaving} />
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">{t('app_passwords.none')}</p>
<p className="text-xs text-muted-foreground italic">{tk('none')}</p>
)}
</div>
);
}
function AppPasswordsSection() {
const { appPasswords, createAppPassword, removeAppPassword } = useAccountSecurityStore();
return (
<CredentialSection
icon={Smartphone}
i18nNamespace="app_passwords"
entries={appPasswords}
onCreate={createAppPassword}
onRemove={removeAppPassword}
/>
);
}
function ApiKeysSection() {
const { apiKeys, createApiKey, removeApiKey } = useAccountSecurityStore();
return (
<CredentialSection
icon={Terminal}
i18nNamespace="api_keys"
entries={apiKeys}
onCreate={createApiKey}
onRemove={removeApiKey}
/>
);
}
function EncryptionSection() {
const t = useTranslations('settings.security');
const { encryptionType, updateEncryption, isSaving, isLoadingCrypto } = useAccountSecurityStore();
const handleToggle = async (enabled: boolean) => {
try {
if (enabled) {
await updateEncryption({ type: 'pgp', algo: 'Aes256' });
toast.success(t('encryption.enabled'));
} else {
await updateEncryption({ type: 'disabled' });
toast.success(t('encryption.disabled_success'));
}
} catch (err) {
toast.error(t('encryption.error'), err instanceof Error ? err.message : undefined);
}
};
const { encryptionType, isLoadingCrypto } = useAccountSecurityStore();
if (isLoadingCrypto) {
return (
@@ -419,24 +578,12 @@ function EncryptionSection() {
);
}
const isEnabled = encryptionType !== 'disabled';
const isEnabled = encryptionType !== 'Disabled';
return (
<SettingItem label={t('encryption.label')} description={t('encryption.description')}>
<div className="flex items-center gap-2">
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<ToggleSwitch
checked={isEnabled}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{isEnabled ? t('encryption.active', { type: encryptionType.toUpperCase() }) : t('encryption.inactive')}
</span>
</div>
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{isEnabled ? t('encryption.active', { type: encryptionType }) : t('encryption.inactive')}
</span>
</SettingItem>
);
}
@@ -446,7 +593,7 @@ function EmailClientSection() {
const { client } = useAuthStore();
const [copied, setCopied] = useState(false);
const jmapUsername = client?.getUsername() || '';
const jmapUsername = useMemo(() => client?.getUsername() || '', [client]);
const handleCopy = () => {
navigator.clipboard.writeText(jmapUsername).then(() => {
@@ -551,6 +698,9 @@ export function AccountSecuritySettings() {
<AppPasswordsSection />
<div className="border-t border-border" />
<ApiKeysSection />
{isOAuth && (
<>
<div className="border-t border-border" />
+9
View File
@@ -135,6 +135,7 @@ export function EmailSettings() {
trustedSendersAddressBook,
attachmentReminderEnabled,
attachmentReminderKeywords,
hideInlineImageAttachments,
updateSetting,
} = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
@@ -401,6 +402,14 @@ export function EmailSettings() {
</div>
)}
{/* Hide inline images from attachment list */}
<SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
<ToggleSwitch
checked={hideInlineImageAttachments}
onChange={(checked) => updateSetting('hideInlineImageAttachments', checked)}
/>
</SettingItem>
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
-14
View File
@@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { useVacationStore } from '@/stores/vacation-store';
import { useFilterStore } from '@/stores/filter-store';
import { useAuthStore } from '@/stores/auth-store';
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
import { toast } from '@/stores/toast-store';
@@ -104,19 +103,6 @@ export function VacationSettings() {
textBody: localTextBody,
});
// Re-save the filter script to preserve metadata and include vacation block.
// This prevents the server from injecting vacation Sieve code that destroys
// the metadata comment the visual filter builder relies on.
try {
await useFilterStore.getState().syncVacationToScript(client, {
isEnabled: localEnabled,
subject: localSubject,
textBody: localTextBody,
});
} catch {
// Non-critical: vacation was saved via JMAP, script sync is best-effort
}
toast.success(tNotifications('vacation_saved'));
} catch (error) {
console.error('Failed to save vacation response:', error);
+1
View File
@@ -72,6 +72,7 @@ export default [
"node_modules/**",
"repos/**",
"data/admin/plugins/**",
"public/**/*.js",
"*.config.js",
"*.config.mjs",
"e2e/**",
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useRef } from "react";
export interface UseRefreshGestureOptions {
onRefresh: () => void | Promise<void>;
enabled?: boolean;
}
/**
* Capture browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) and run
* a JMAP-level refresh instead of reloading the full page.
*
* Pull-to-refresh is only active when the document is already scrolled to the
* top, so normal touch scrolling is unaffected.
*/
export function useRefreshGesture({ onRefresh, enabled = true }: UseRefreshGestureOptions) {
const onRefreshRef = useRef(onRefresh);
const runningRef = useRef(false);
useEffect(() => {
onRefreshRef.current = onRefresh;
}, [onRefresh]);
useEffect(() => {
if (!enabled) return;
const trigger = () => {
if (runningRef.current) return;
runningRef.current = true;
Promise.resolve(onRefreshRef.current()).finally(() => {
runningRef.current = false;
});
};
const handleKeyDown = (event: KeyboardEvent) => {
const isReloadKey =
event.key === "F5" ||
((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "r");
if (!isReloadKey) return;
event.preventDefault();
event.stopPropagation();
trigger();
};
let touchStartY = 0;
let tracking = false;
let triggered = false;
const handleTouchStart = (event: TouchEvent) => {
if (event.touches.length !== 1) {
tracking = false;
return;
}
const atTop = window.scrollY <= 0 && document.documentElement.scrollTop <= 0;
if (!atTop) {
tracking = false;
return;
}
touchStartY = event.touches[0].clientY;
tracking = true;
triggered = false;
};
const handleTouchMove = (event: TouchEvent) => {
if (!tracking || triggered) return;
const dy = event.touches[0].clientY - touchStartY;
// Require a deliberate pull of ~80px from the very top of the page.
if (dy > 80) {
triggered = true;
tracking = false;
trigger();
}
};
const handleTouchEnd = () => {
tracking = false;
triggered = false;
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
window.addEventListener("touchstart", handleTouchStart, { passive: true });
window.addEventListener("touchmove", handleTouchMove, { passive: true });
window.addEventListener("touchend", handleTouchEnd, { passive: true });
window.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
window.removeEventListener("touchstart", handleTouchStart);
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchend", handleTouchEnd);
window.removeEventListener("touchcancel", handleTouchEnd);
};
}, [enabled]);
}
+52
View File
@@ -5,6 +5,7 @@ import {
sanitizeSignatureHtml,
parseHtmlSafely,
hasRichFormatting,
plainTextToSafeHtml,
EMAIL_SANITIZE_CONFIG,
} from '../email-sanitization';
@@ -252,4 +253,55 @@ describe('email-sanitization', () => {
expect(clean).toContain('data:image/gif');
});
});
describe('plainTextToSafeHtml', () => {
it('escapes HTML-special characters in surrounding text', () => {
const result = plainTextToSafeHtml('<script>alert(1)</script> & "q" \'q\'');
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
expect(result).toContain('&amp;');
expect(result).toContain('&quot;');
expect(result).toContain('&#39;');
});
it('linkifies http(s) URLs', () => {
const result = plainTextToSafeHtml('visit http://example.com/path now');
expect(result).toContain('<a href="http://example.com/path"');
expect(result).toContain('target="_blank"');
expect(result).toContain('rel="noopener noreferrer"');
});
it('prevents attribute breakout via quote in URL (CVE regression)', () => {
const payload = 'http://evil.tld/"onmouseover="alert(1)"x="';
const result = plainTextToSafeHtml(payload);
// The anchor tag must not contain any unescaped attribute beyond href/target/rel.
expect(result).not.toMatch(/<a [^>]*onmouseover/i);
expect(result).not.toMatch(/<a [^>]*style=/i);
// Quotes from the payload must be entity-encoded wherever they land.
expect(result).toContain('&quot;');
});
it('prevents attribute breakout via style injection', () => {
const payload = 'http://evil.tld/"style="background:red"x="';
const result = plainTextToSafeHtml(payload);
expect(result).not.toMatch(/href="[^"]*"[^>]*style=/);
});
it('terminates URL at quote, keeping rest as escaped text', () => {
const result = plainTextToSafeHtml('http://evil.tld/"injected');
expect(result).toContain('<a href="http://evil.tld/"');
expect(result).toContain('&quot;injected');
});
it('applies linkClass when provided and escapes it', () => {
const result = plainTextToSafeHtml('http://x.com', 'text-primary hover:underline');
expect(result).toContain('class="text-primary hover:underline"');
});
it('does not linkify non-http schemes', () => {
const result = plainTextToSafeHtml('try javascript:alert(1) or file:///etc/passwd');
expect(result).not.toContain('<a ');
expect(result).toContain('javascript:alert(1)');
});
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(),
}));
vi.mock('@/lib/auth/active-account-slot', () => ({
getActiveAccountSlotHeaders: vi.fn(() => ({ 'X-JMAP-Cookie-Slot': '0' })),
}));
import { stalwartJmap, requireResult, STALWART_JMAP_USING } from '@/lib/stalwart/jmap-passthrough';
import { apiFetch } from '@/lib/browser-navigation';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('stalwartJmap', () => {
beforeEach(() => {
mockedFetch.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('POSTs to /api/account/stalwart/jmap with the standard using array', async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: [] }));
await stalwartJmap([['x:Account/get', { accountId: 'a', ids: ['a'] }, '0']]);
expect(mockedFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockedFetch.mock.calls[0];
expect(url).toBe('/api/account/stalwart/jmap');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body as string);
expect(body.using).toEqual(STALWART_JMAP_USING);
expect(body.methodCalls).toEqual([['x:Account/get', { accountId: 'a', ids: ['a'] }, '0']]);
});
it('forwards the active account slot header', async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: [] }));
await stalwartJmap([['x:Account/get', {}, '0']]);
const init = mockedFetch.mock.calls[0][1];
expect(init.headers['X-JMAP-Cookie-Slot']).toBe('0');
expect(init.headers['Content-Type']).toBe('application/json');
});
it('returns methodResponses on success', async () => {
const responses = [['x:AccountPassword/get', { list: [{ id: 'singleton' }] }, '0']];
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: responses }));
const result = await stalwartJmap([['x:AccountPassword/get', { accountId: 'a', ids: ['singleton'] }, '0']]);
expect(result).toEqual(responses);
});
it('throws with status and message when the passthrough returns non-OK', async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(401, { error: 'Not authenticated' }));
await expect(stalwartJmap([['x:Account/get', {}, '0']])).rejects.toMatchObject({
status: 401,
message: 'Not authenticated',
});
});
it('throws with HTTP fallback message when error body is unparseable', async () => {
mockedFetch.mockResolvedValueOnce(new Response('oh no', { status: 500 }));
await expect(stalwartJmap([['x:Account/get', {}, '0']])).rejects.toMatchObject({
status: 500,
message: 'HTTP 500',
});
});
it('throws when first method response is a JMAP-level error', async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(200, {
methodResponses: [['error', { type: 'forbidden', description: 'Current secret must be provided' }, '0']],
}));
await expect(stalwartJmap([['x:AccountPassword/set', {}, '0']])).rejects.toMatchObject({
status: 200,
message: 'Current secret must be provided',
methodError: { type: 'forbidden', description: 'Current secret must be provided' },
});
});
it('falls back to error type when description is absent', async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(200, {
methodResponses: [['error', { type: 'unknownMethod' }, '0']],
}));
await expect(stalwartJmap([['x:Nope/get', {}, '0']])).rejects.toMatchObject({
methodError: { type: 'unknownMethod' },
message: 'unknownMethod',
});
});
});
describe('requireResult', () => {
it('returns the arguments of the matching method', () => {
const responses: Array<[string, Record<string, unknown>, string]> = [
['x:Account/get', { list: [{ id: 'a' }] }, '0'],
['x:AppPassword/query', { ids: ['p1'] }, '1'],
];
const result = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query');
expect(result.ids).toEqual(['p1']);
});
it('throws when the expected method is missing', () => {
const responses: Array<[string, Record<string, unknown>, string]> = [
['x:Account/get', {}, '0'],
];
expect(() => requireResult(responses, 'x:AppPassword/query')).toThrow(/x:AppPassword\/query/);
});
});
-246
View File
@@ -1,246 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { StalwartClient } from '../stalwart/client';
function mockFetchResponse(status: number, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('StalwartClient', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
let client: StalwartClient;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
client = new StalwartClient('https://mail.example.com/', 'Basic dXNlcjpwYXNz');
});
afterEach(() => {
fetchSpy.mockRestore();
});
describe('constructor', () => {
it('strips trailing slash from server URL', () => {
const c = new StalwartClient('https://mail.example.com/', 'Basic abc');
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: [] } }));
c.getAuthInfo();
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/api/account/auth',
expect.anything()
);
});
});
describe('probe', () => {
it('returns true when server responds with data field', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false } }));
const result = await client.probe();
expect(result).toBe(true);
});
it('returns true when server returns 401 (API exists but needs auth)', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(401));
const result = await client.probe();
expect(result).toBe(true);
});
it('returns false when server returns 404', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(404));
const result = await client.probe();
expect(result).toBe(false);
});
it('returns false on network error', async () => {
fetchSpy.mockRejectedValueOnce(new TypeError('Network error'));
const result = await client.probe();
expect(result).toBe(false);
});
it('returns false when response has no data field', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { something: 'else' }));
const result = await client.probe();
expect(result).toBe(false);
});
});
describe('getAuthInfo', () => {
it('returns auth info on success', async () => {
const authInfo = { otpEnabled: true, isAdminApp: false, appPasswords: ['app1'] };
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: authInfo }));
const result = await client.getAuthInfo();
expect(result).toEqual(authInfo);
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/api/account/auth',
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Basic dXNlcjpwYXNz',
}),
})
);
});
it('throws on non-ok response', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403, { detail: 'Forbidden' }));
await expect(client.getAuthInfo()).rejects.toThrow('Forbidden');
});
it('throws with HTTP status when error body is unparseable', async () => {
fetchSpy.mockResolvedValueOnce(new Response('not json', { status: 500 }));
await expect(client.getAuthInfo()).rejects.toThrow('HTTP 500');
});
});
describe('enableTotp', () => {
it('sends enableOtpAuth action and returns TOTP URL', async () => {
const totpUrl = 'otpauth://totp/user@example.com?secret=ABC123';
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: totpUrl }));
const result = await client.enableTotp();
expect(result).toBe(totpUrl);
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'enableOtpAuth' }]);
});
});
describe('disableTotp', () => {
it('sends disableOtpAuth action', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.disableTotp();
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'disableOtpAuth' }]);
});
});
describe('addAppPassword', () => {
it('sends addAppPassword action with name and password', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.addAppPassword('Thunderbird', 'secret123');
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'addAppPassword', name: 'Thunderbird', password: 'secret123' }]);
});
});
describe('removeAppPassword', () => {
it('sends removeAppPassword action with name', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.removeAppPassword('Thunderbird');
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'removeAppPassword', name: 'Thunderbird' }]);
});
});
describe('getCryptoInfo', () => {
it('returns crypto info on success', async () => {
const cryptoInfo = { type: 'pgp' as const };
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: cryptoInfo }));
const result = await client.getCryptoInfo();
expect(result).toEqual(cryptoInfo);
});
});
describe('updateCrypto', () => {
it('sends crypto settings', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.updateCrypto({ type: 'pgp' });
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual({ type: 'pgp' });
});
});
describe('getPrincipal', () => {
it('returns principal data on success', async () => {
const principal = {
id: 1, type: 'individual', name: 'testuser',
description: 'Test User', emails: ['test@example.com'],
secrets: [], quota: 1000000, roles: ['user'], lists: [],
};
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: principal }));
const result = await client.getPrincipal('testuser');
expect(result).toEqual(principal);
});
it('encodes special characters in username', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
await client.getPrincipal('user@example.com');
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/api/principal/user%40example.com',
expect.anything()
);
});
});
describe('updatePrincipal', () => {
it('sends PATCH with action array', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.updatePrincipal('testuser', [
{ action: 'set', field: 'description', value: 'New Name' },
]);
const call = fetchSpy.mock.calls[0];
expect(call[0]).toBe('https://mail.example.com/api/principal/testuser');
expect(call[1]?.method).toBe('PATCH');
const body = JSON.parse(call[1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'description', value: 'New Name' }]);
});
});
describe('changePassword', () => {
it('sends set secrets action via updatePrincipal', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.changePassword('testuser', 'newPassword123');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'secrets', value: 'newPassword123' }]);
});
});
describe('updateDisplayName', () => {
it('sends set description action via updatePrincipal', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.updateDisplayName('testuser', 'John Doe');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'description', value: 'John Doe' }]);
});
});
describe('request error handling', () => {
it('parses error.detail from response body', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { detail: 'Invalid request format' }));
await expect(client.getAuthInfo()).rejects.toThrow('Invalid request format');
});
it('parses error.details from response body', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { details: 'Bad stuff' }));
await expect(client.getAuthInfo()).rejects.toThrow('Bad stuff');
});
it('parses error.error from response body', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { error: 'Something wrong' }));
await expect(client.getAuthInfo()).rejects.toThrow('Something wrong');
});
it('falls back to HTTP status code on non-JSON error', async () => {
fetchSpy.mockResolvedValueOnce(new Response('plain text', { status: 502 }));
await expect(client.getAuthInfo()).rejects.toThrow('HTTP 502');
});
});
});
-1
View File
@@ -110,7 +110,6 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: '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 },
stalwartApiUrl: { envVar: 'STALWART_API_URL', type: 'url', defaultValue: '' },
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
+187
View File
@@ -0,0 +1,187 @@
import type { CalendarEvent } from "@/lib/jmap/types";
const MAX_LINE_OCTETS = 74;
function foldLine(line: string): string {
if (line.length <= MAX_LINE_OCTETS) return line;
const chunks: string[] = [line.slice(0, MAX_LINE_OCTETS)];
let pos = MAX_LINE_OCTETS;
while (pos < line.length) {
chunks.push(" " + line.slice(pos, pos + MAX_LINE_OCTETS - 1));
pos += MAX_LINE_OCTETS - 1;
}
return chunks.join("\r\n");
}
// RFC 5545 §3.3.11 - escape backslash, semicolon, comma, and newline in TEXT values.
function escapeText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\r\n|\r|\n/g, "\\n");
}
function stripDateSeparators(value: string): string {
return value.replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
function dateOnly(value: string): string {
return value.replace(/-/g, "").substring(0, 8);
}
function formatNow(): string {
return stripDateSeparators(new Date().toISOString().replace(/\.\d{3}/, ""));
}
function pushDateProp(
lines: string[],
prop: "DTSTART" | "DTEND",
value: string,
showWithoutTime: boolean,
tz?: string | null,
): void {
if (showWithoutTime) {
lines.push(`${prop};VALUE=DATE:${dateOnly(value)}`);
return;
}
if (value.endsWith("Z")) {
lines.push(`${prop}:${stripDateSeparators(value)}`);
return;
}
const basic = stripDateSeparators(value);
if (tz) {
lines.push(`${prop};TZID=${tz}:${basic}`);
} else {
lines.push(`${prop}:${basic}`);
}
}
function pushFrequencyRule(lines: string[], event: CalendarEvent): void {
const rule = event.recurrenceRules?.[0];
if (!rule) return;
const parts: string[] = [`FREQ=${rule.frequency.toUpperCase()}`];
if (rule.interval && rule.interval > 1) parts.push(`INTERVAL=${rule.interval}`);
if (rule.count != null) parts.push(`COUNT=${rule.count}`);
if (rule.until) parts.push(`UNTIL=${stripDateSeparators(rule.until)}`);
if (rule.byDay?.length) {
const days = rule.byDay
.map((d) => `${d.nthOfPeriod ?? ""}${d.day.toUpperCase()}`)
.join(",");
parts.push(`BYDAY=${days}`);
}
if (rule.byMonthDay?.length) parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`);
if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(",")}`);
lines.push(`RRULE:${parts.join(";")}`);
}
function pushAlerts(lines: string[], event: CalendarEvent): void {
if (!event.alerts) return;
for (const alert of Object.values(event.alerts)) {
const trigger = alert.trigger;
if (!trigger) continue;
lines.push("BEGIN:VALARM");
lines.push(`ACTION:${(alert.action || "display").toUpperCase()}`);
if (trigger["@type"] === "OffsetTrigger") {
const related = trigger.relativeTo === "end" ? ";RELATED=END" : "";
lines.push(`TRIGGER${related}:${trigger.offset}`);
} else if (trigger["@type"] === "AbsoluteTrigger") {
lines.push(`TRIGGER;VALUE=DATE-TIME:${stripDateSeparators(trigger.when)}`);
}
lines.push(`DESCRIPTION:${escapeText(event.title || "Reminder")}`);
lines.push("END:VALARM");
}
}
export function eventToICS(event: CalendarEvent): string {
const now = formatNow();
const lines: string[] = [
"BEGIN:VCALENDAR",
"PRODID:-//JMAP-Webmail//EN",
"VERSION:2.0",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${event.uid}`,
`DTSTAMP:${now}`,
];
if (event.created) lines.push(`CREATED:${stripDateSeparators(event.created)}`);
if (event.updated) lines.push(`LAST-MODIFIED:${stripDateSeparators(event.updated)}`);
if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`);
if (event.start) {
pushDateProp(lines, "DTSTART", event.start, event.showWithoutTime, event.timeZone);
}
if (event.utcEnd) {
pushDateProp(lines, "DTEND", event.utcEnd, event.showWithoutTime, event.timeZone);
} else if (event.duration) {
lines.push(`DURATION:${event.duration}`);
}
if (event.title) lines.push(`SUMMARY:${escapeText(event.title)}`);
if (event.description) lines.push(`DESCRIPTION:${escapeText(event.description)}`);
if (event.status) lines.push(`STATUS:${event.status.toUpperCase()}`);
if (event.privacy) lines.push(`CLASS:${event.privacy.toUpperCase()}`);
if (event.freeBusyStatus) {
lines.push(`TRANSP:${event.freeBusyStatus === "free" ? "TRANSPARENT" : "OPAQUE"}`);
}
if (event.locations) {
const first = Object.values(event.locations)[0];
if (first?.name) lines.push(`LOCATION:${escapeText(first.name)}`);
}
if (event.virtualLocations) {
for (const loc of Object.values(event.virtualLocations)) {
if (loc.uri) lines.push(`URL:${loc.uri}`);
}
}
if (event.participants) {
const organizer = Object.values(event.participants).find((p) => p.roles?.owner);
if (organizer) {
const email = organizer.email || organizer.sendTo?.imip?.replace("mailto:", "");
if (email) {
const cn = organizer.name ? `;CN=${escapeText(organizer.name)}` : "";
lines.push(`ORGANIZER${cn}:mailto:${email}`);
}
}
for (const p of Object.values(event.participants)) {
if (p.roles?.owner) continue;
const email = p.email || p.sendTo?.imip?.replace("mailto:", "");
if (!email) continue;
const cn = p.name ? `;CN=${escapeText(p.name)}` : "";
const partstat = p.participationStatus
? `;PARTSTAT=${p.participationStatus.toUpperCase()}`
: ";PARTSTAT=NEEDS-ACTION";
const rsvp = p.expectReply ? ";RSVP=TRUE" : "";
lines.push(`ATTENDEE${cn}${partstat}${rsvp}:mailto:${email}`);
}
}
pushFrequencyRule(lines, event);
pushAlerts(lines, event);
lines.push("END:VEVENT");
lines.push("END:VCALENDAR");
return lines.map(foldLine).join("\r\n") + "\r\n";
}
function sanitizeFilename(name: string): string {
const cleaned = name.replace(/[\\/:*?"<>|]/g, "_").trim();
return cleaned || "event";
}
export function downloadEventICS(event: CalendarEvent): void {
const ics = eventToICS(event);
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${sanitizeFilename(event.title || "event")}.ics`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
+6 -2
View File
@@ -49,6 +49,10 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
hasAccountCapability(_capability: string, _accountId?: string): boolean {
return false;
}
getCapabilities(): Record<string, unknown> {
return {
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
@@ -317,7 +321,7 @@ export class DemoJMAPClient implements IJMAPClient {
_identityId?: string,
_fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
_fromName?: string,
): Promise<string> {
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
@@ -365,7 +369,7 @@ export class DemoJMAPClient implements IJMAPClient {
draftId?: string,
_fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
): Promise<void> {
// Remove draft if updating
if (draftId) {
+34
View File
@@ -80,6 +80,40 @@ export function hasRichFormatting(html: string): boolean {
);
}
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
}
/**
* Render a plain-text email body as HTML, HTML-escaping all content and
* linkifying http(s) URLs. URLs terminate at whitespace or any character that
* would break an attribute (`"`, `'`, `<`, `>`), so attribute-escaping is
* enforced even if escaping has bugs.
*/
export function plainTextToSafeHtml(text: string, linkClass = ''): string {
const urlRegex = /(https?:\/\/[^\s<>"']+)/g;
const classAttr = linkClass ? ` class="${escapeHtml(linkClass)}"` : '';
let result = '';
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = urlRegex.exec(text)) !== null) {
result += escapeHtml(text.slice(lastIndex, match.index));
const url = escapeHtml(match[0]);
result += `<a href="${url}" target="_blank" rel="noopener noreferrer"${classAttr}>${url}</a>`;
lastIndex = match.index + match[0].length;
}
result += escapeHtml(text.slice(lastIndex));
return result;
}
/**
* Collapse empty containers left behind when external images are blocked.
* Walks up from each blocked img to find the nearest table cell or wrapper div
+3 -2
View File
@@ -27,6 +27,7 @@ export interface IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
getCapabilities(): Record<string, unknown>;
hasAccountCapability(capability: string, accountId?: string): boolean;
getMaxSizeUpload(): number;
getMaxCallsInRequest(): number;
getMaxObjectsInGet(): number;
@@ -103,7 +104,7 @@ export interface IJMAPClient {
identityId?: string,
fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
fromName?: string,
): Promise<string>;
@@ -118,7 +119,7 @@ export interface IJMAPClient {
draftId?: string,
fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
): Promise<void>;
sendImipReply(opts: {
+57 -11
View File
@@ -446,12 +446,39 @@ export class JMAPClient implements IJMAPClient {
return response;
}
private async refreshSession(): Promise<void> {
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
const response = await fetch(sessionUrl, {
/**
* Fetch the JMAP session, transparently handling servers that redirect
* /.well-known/jmap to a canonical session URL (e.g. Stalwart /jmap/session).
*
* Safari strips the Authorization header on cross-origin redirects even when
* the redirect destination is same-origin as the original request, and some
* reverse-auth proxies (e.g. Pangolin) admit the redirected request via a
* cookie without the Authorization header. Stalwart responds to an
* unauthenticated /jmap/session with 200 + empty accounts rather than 401,
* so the drop is silent and downstream parsing fails with "No mail account
* found in session".
*
* Detect that case (response.redirected, empty accounts, empty username)
* and retry directly against the final URL so we can re-send Authorization.
*/
private async fetchSessionResponse(): Promise<Response> {
const discoveryUrl = `${this.serverUrl}/.well-known/jmap`;
const response = await this.authenticatedFetch(discoveryUrl, { method: 'GET' });
if (!response.ok || !response.redirected) return response;
const peek = await response.clone().json().catch(() => null);
const hasAccounts = peek && Object.keys(peek.accounts || {}).length > 0;
const hasUsername = typeof peek?.username === 'string' && peek.username.length > 0;
if (hasAccounts || hasUsername) return response;
return fetch(response.url, {
method: 'GET',
headers: { 'Authorization': this.authHeader },
});
}
private async refreshSession(): Promise<void> {
const response = await this.fetchSessionResponse();
if (!response.ok) {
throw new Error(`Session refresh failed: ${response.status}`);
@@ -470,9 +497,7 @@ export class JMAPClient implements IJMAPClient {
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
try {
const sessionResponse = await this.authenticatedFetch(sessionUrl, {
method: 'GET',
});
const sessionResponse = await this.fetchSessionResponse();
if (!sessionResponse.ok) {
if (sessionResponse.status === 401) {
@@ -1701,7 +1726,7 @@ export class JMAPClient implements IJMAPClient {
identityId?: string,
fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
fromName?: string
): Promise<string> {
const mailboxes = await this.getMailboxes();
@@ -1722,7 +1747,7 @@ export class JMAPClient implements IJMAPClient {
mailboxIds: Record<string, boolean>;
bodyValues: Record<string, { value: string }>;
textBody: { partId: string }[];
attachments?: { blobId: string; type: string; name: string; disposition: string }[];
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
}
const emailData: EmailDraft = {
@@ -1742,7 +1767,8 @@ export class JMAPClient implements IJMAPClient {
blobId: att.blobId,
type: att.type,
name: att.name,
disposition: "attachment",
disposition: att.disposition ?? "attachment",
...(att.cid ? { cid: att.cid } : {}),
}));
}
@@ -1795,7 +1821,7 @@ export class JMAPClient implements IJMAPClient {
draftId?: string,
fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>
): Promise<void> {
const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes();
@@ -1865,7 +1891,8 @@ export class JMAPClient implements IJMAPClient {
blobId: att.blobId,
type: att.type,
name: att.name,
disposition: "attachment",
disposition: att.disposition ?? "attachment",
...(att.cid ? { cid: att.cid } : {}),
}));
}
@@ -1947,6 +1974,10 @@ export class JMAPClient implements IJMAPClient {
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
identityId?: string;
}): Promise<void> {
if (!opts.uid) {
debug.warn('calendar', '[iMIP] sendImipReply aborted: missing UID');
return;
}
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (!sentMailbox) {
@@ -2124,6 +2155,10 @@ export class JMAPClient implements IJMAPClient {
*/
async sendImipInvitation(event: CalendarEvent): Promise<void> {
if (!event.participants) return;
if (!event.uid) {
debug.warn('calendar', '[iMIP] sendImipInvitation aborted: event has no UID', { eventId: event.id });
return;
}
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
@@ -2290,6 +2325,10 @@ export class JMAPClient implements IJMAPClient {
*/
async sendImipCancellation(event: CalendarEvent): Promise<void> {
if (!event.participants) return;
if (!event.uid) {
debug.warn('calendar', '[iMIP] sendImipCancellation aborted: event has no UID', { eventId: event.id });
return;
}
if (event.status && event.status !== 'cancelled') {
debug.warn('calendar', 'sendImipCancellation called on non-cancelled event, status:', event.status);
}
@@ -2519,6 +2558,13 @@ export class JMAPClient implements IJMAPClient {
return capability in this.capabilities;
}
/** Check whether a capability is present on the primary account. */
hasAccountCapability(capability: string, accountId?: string): boolean {
const id = accountId || this.accountId;
const caps = this.session?.accounts?.[id]?.accountCapabilities;
return !!caps && capability in caps;
}
getMaxSizeUpload(): number {
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxSizeUpload?: number } | undefined;
return coreCapability?.maxSizeUpload || 0;
@@ -264,4 +264,94 @@ describe('external rule preservation (issue #201)', () => {
expect(externalAfter[0].conditions[0]).toMatchObject({ field: 'header', headerName: 'X-Spam' });
});
});
describe('Nextcloud Mail marker regions', () => {
const NEXTCLOUD_MARKER = "### Nextcloud Mail: Filters ### DON'T EDIT ###";
const nextcloudScript = [
NEXTCLOUD_MARKER,
'require ["imap4flags", "fileinto"];',
NEXTCLOUD_MARKER,
'',
NEXTCLOUD_MARKER,
'# FILTER: [{"name":"Wetterwarnungen"}]',
'if header :contains "From" "weather@x.com" {',
' fileinto "Weather";',
'}',
'',
'# PayPal',
'if header :contains "From" "paypal.de" {',
' addflag "$paypal";',
'}',
NEXTCLOUD_MARKER,
'',
].join('\n');
it('collapses a Nextcloud marker-pair region into one opaque rule labeled "Nextcloud"', () => {
const result = parseScript(nextcloudScript);
const nextcloud = result.rules.filter(r => r.originLabel === 'Nextcloud');
expect(nextcloud).toHaveLength(1);
expect(nextcloud[0].origin).toBe('opaque');
// Every marker that wrapped actual rule content survives in the rawBlock.
const markerCount = (nextcloud[0].rawBlock || '').split(NEXTCLOUD_MARKER).length - 1;
expect(markerCount).toBe(2);
// Inner if-blocks are not exposed as separate external rules.
expect(result.rules.filter(r => r.originLabel === 'External')).toHaveLength(0);
});
it('merges require tokens from inside Nextcloud regions into externalRequires', () => {
const result = parseScript(nextcloudScript);
expect(result.externalRequires).toEqual(expect.arrayContaining(['imap4flags', 'fileinto']));
});
it('drops require-only Nextcloud regions (no separate opaque rule for them)', () => {
const script = [
NEXTCLOUD_MARKER,
'require ["fileinto"];',
NEXTCLOUD_MARKER,
'',
].join('\n');
const result = parseScript(script);
expect(result.rules).toHaveLength(0);
expect(result.externalRequires).toContain('fileinto');
});
it('round-trips a Nextcloud region verbatim through parse → generate → parse', () => {
const bulwark = makeBulwarkRule({ name: 'Test' });
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
const parsed = parseScript(initial);
const regenerated = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
});
// Both wrapping markers remain, in the right positions.
const firstMarker = regenerated.indexOf(NEXTCLOUD_MARKER);
const lastMarker = regenerated.lastIndexOf(NEXTCLOUD_MARKER);
expect(firstMarker).toBeGreaterThanOrEqual(0);
expect(lastMarker).toBeGreaterThan(firstMarker);
expect(regenerated).toContain('# FILTER: [{"name":"Wetterwarnungen"}]');
expect(regenerated).toContain('# PayPal');
const reparsed = parseScript(regenerated);
expect(reparsed.rules.filter(r => r.originLabel === 'Nextcloud')).toHaveLength(1);
expect(reparsed.rules.filter(r => r.originLabel === 'External')).toHaveLength(0);
});
it('does not emit duplicate "External rules" headers across repeated saves', () => {
const bulwark = makeBulwarkRule({ name: 'Test' });
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
let script = initial;
for (let i = 0; i < 3; i++) {
const parsed = parseScript(script);
script = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
});
}
const headerCount = (script.match(/# --- External rules \(managed outside Bulwark\) ---/g) || []).length;
expect(headerCount).toBe(1);
});
});
});
+133 -12
View File
@@ -21,6 +21,11 @@ const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
const METADATA_BEGIN = '/* @metadata:begin';
const METADATA_END = '@metadata:end */';
const NEXTCLOUD_BLOCK_MARKER = "### Nextcloud Mail: Filters ### DON'T EDIT ###";
const BULWARK_EXTERNAL_HEADER_RE =
/^[ \t]*#[ \t]*---[ \t]*External rules \(managed outside Bulwark\)[ \t]*---[ \t]*\r?\n/m;
const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
from: 'from',
to: 'to',
@@ -496,6 +501,103 @@ function makeOpaqueRule(block: TopBlock, idPrefix: string, index: number): Filte
};
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Nextcloud Mail wraps its managed filter region with a pair of
* `### Nextcloud Mail: Filters ### DON'T EDIT ###` markers and typically
* emits two such regions - one enclosing its own `require [...]` line and
* another enclosing the if-blocks it generates from its `# FILTER: [...]`
* JSON comments. Parsing the interior blocks individually loses the outer
* markers (causing later rules to fall back to "External") and mis-attaches
* them to neighboring blocks on round-trip.
*
* Treat each marker-pair as one opaque external rule so the whole region
* round-trips verbatim, merge any require tokens into the top-level list,
* and drop regions that carry nothing but a require (their extensions are
* already represented in the merged require line).
*/
function extractNextcloudRegions(content: string): {
cleaned: string;
rules: FilterRule[];
requires: string[];
} {
const marker = NEXTCLOUD_BLOCK_MARKER;
const positions: number[] = [];
let searchFrom = 0;
while (true) {
const idx = content.indexOf(marker, searchFrom);
if (idx === -1) break;
const atLineStart = idx === 0 || content[idx - 1] === '\n';
if (atLineStart) positions.push(idx);
searchFrom = idx + marker.length;
}
if (positions.length < 2) {
return { cleaned: content, rules: [], requires: [] };
}
const rules: FilterRule[] = [];
const requires: string[] = [];
const markerRe = new RegExp(escapeRegex(marker), 'g');
let cleaned = '';
let cursor = 0;
for (let i = 0; i + 1 < positions.length; i += 2) {
const start = positions[i];
const closeStart = positions[i + 1];
const lineEnd = content.indexOf('\n', closeStart + marker.length);
const end = lineEnd === -1 ? content.length : lineEnd + 1;
cleaned += content.slice(cursor, start);
const raw = content.slice(start, end);
for (const m of raw.matchAll(/require\s+\[([\s\S]*?)\]\s*;/g)) {
for (const tok of m[1].matchAll(/"([^"]+)"/g)) {
if (!requires.includes(tok[1])) requires.push(tok[1]);
}
}
const singleReq = /require\s+"([^"]+)"\s*;/.exec(raw);
if (singleReq && !requires.includes(singleReq[1])) requires.push(singleReq[1]);
const stripped = raw
.replace(markerRe, '')
.replace(/require\s+\[[\s\S]*?\]\s*;/g, '')
.replace(/require\s+"[^"]+"\s*;/g, '')
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
.replace(/#[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim();
if (stripped.length > 0) {
rules.push({
id: `nextcloud-${rules.length}`,
name: 'Nextcloud Mail filters',
enabled: true,
matchType: 'all',
conditions: [],
actions: [],
stopProcessing: false,
origin: 'opaque',
originLabel: 'Nextcloud',
rawBlock: raw,
});
}
cursor = end;
}
cleaned += content.slice(cursor);
return { cleaned, rules, requires };
}
function stripBulwarkExternalHeader(content: string): string {
return content.replace(BULWARK_EXTERNAL_HEADER_RE, '');
}
function parseExternalRules(
content: string,
idPrefix: string,
@@ -557,17 +659,25 @@ export function parseScript(content: string): ParseResult {
if (!isValidRule(rule)) return OPAQUE;
}
// Scan the portion AFTER the metadata block for external rules.
const afterMetadata = content.slice(endIdx + METADATA_END.length);
const external = parseExternalRules(afterMetadata, 'ext');
// Scan the portion AFTER the metadata block for external rules. A prior
// Bulwark save may have emitted its "External rules" header here; strip
// it so it does not get re-attached to the first external rule's rawBlock
// and written out twice on the next save.
const afterMetadata = stripBulwarkExternalHeader(
content.slice(endIdx + METADATA_END.length),
);
const nextcloud = extractNextcloudRegions(afterMetadata);
const external = parseExternalRules(nextcloud.cleaned, 'ext');
// Parsed bulwark rules intentionally omit an explicit `origin` field so
// round-trip equality with metadata-only callers holds. Absence of origin
// is treated as 'bulwark' everywhere downstream.
const bulwarkRules: FilterRule[] = metadata.rules;
// Exclude requires and the vacation line that we emit ourselves from externalRequires.
const externalRequires = external.externalRequires;
const externalRequires = [
...external.externalRequires,
...nextcloud.requires.filter(r => !external.externalRequires.includes(r)),
];
// Drop any external "rules" that are really the bulwark-managed if-blocks or vacation.
// Recognizable by the leading comment "# Rule: <name>" or "# Vacation auto-reply".
@@ -584,7 +694,7 @@ export function parseScript(content: string): ParseResult {
});
return {
rules: [...bulwarkRules, ...filteredExternal],
rules: [...bulwarkRules, ...nextcloud.rules, ...filteredExternal],
isOpaque: false,
vacation: metadata.vacation,
externalRequires,
@@ -595,18 +705,29 @@ export function parseScript(content: string): ParseResult {
const vacationOnly = detectVacationOnlyScript(content);
if (vacationOnly) return vacationOnly;
// Try to parse the whole script as external rules.
const external = parseExternalRules(content, 'ext');
// Extract Nextcloud-managed marker regions first so their interior is not
// parsed as a series of loose if-blocks (which would lose the outer markers
// and mis-label later blocks as generic "External").
const nextcloud = extractNextcloudRegions(content);
const external = parseExternalRules(nextcloud.cleaned, 'ext');
if (!external.hasContent) {
const allRules = [...nextcloud.rules, ...external.rules];
const allRequires = [
...external.externalRequires,
...nextcloud.requires.filter(r => !external.externalRequires.includes(r)),
];
if (!external.hasContent && allRules.length === 0) {
// Entirely empty or whitespace/comments only - treat as empty, editable.
return { rules: [], isOpaque: false, externalRequires: [] };
// Preserve any require tokens lifted out of Nextcloud marker regions so
// they can be re-emitted in the top-level require line.
return { rules: [], isOpaque: false, externalRequires: allRequires };
}
// If at least one block parsed into a structured rule, expose them as external.
const anyParsed = external.rules.some(r => r.origin === 'external');
if (anyParsed || external.rules.length > 0) {
return { rules: external.rules, isOpaque: false, externalRequires: external.externalRequires };
if (anyParsed || allRules.length > 0) {
return { rules: allRules, isOpaque: false, externalRequires: allRequires };
}
return OPAQUE;
-185
View File
@@ -1,185 +0,0 @@
/**
* Stalwart Management API Client
*
* Provides typed access to Stalwart's /api/ endpoints for user self-service:
* - Password change (PATCH /principal/{name})
* - Display name update (PATCH /principal/{name})
* - App passwords (POST /account/auth)
* - TOTP 2FA management (POST /account/auth)
* - Encryption-at-rest (GET/POST /account/crypto)
* - Account auth info (GET /account/auth)
*/
export interface StalwartAuthInfo {
otpEnabled: boolean;
isAdminApp: boolean;
appPasswords: string[];
}
export interface StalwartCryptoInfo {
type: 'disabled' | 'pgp' | 'smime';
}
export interface StalwartPrincipal {
id: number;
type: string;
name: string;
description: string;
emails: string | string[];
secrets: string | string[];
quota: number;
roles: string[];
lists: string[];
}
export interface PrincipalUpdateAction {
action: 'set' | 'addItem' | 'removeItem';
field: string;
value: string | number;
}
export interface StalwartApiError {
error: string;
details: string;
reason?: string | null;
}
export class StalwartClient {
private baseUrl: string;
private authHeader: string;
constructor(serverUrl: string, authHeader: string) {
this.baseUrl = serverUrl.replace(/\/$/, '') + '/api';
this.authHeader = authHeader;
}
// eslint-disable-next-line no-undef
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
...init,
headers: {
'Authorization': this.authHeader,
'Content-Type': 'application/json',
...init?.headers,
},
});
if (!response.ok) {
let errorDetail = `HTTP ${response.status}`;
try {
const body = await response.json();
if (body.detail) errorDetail = body.detail;
else if (body.details) errorDetail = body.details;
else if (body.error) errorDetail = body.error;
} catch { /* use status code */ }
throw new Error(errorDetail);
}
return response.json();
}
/** Probe whether this server exposes Stalwart's management API */
async probe(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/account/auth`, {
method: 'GET',
headers: { 'Authorization': this.authHeader },
});
if (response.status === 401) return true; // API exists but needs auth
if (!response.ok) return false;
const data = await response.json();
return data.data !== undefined;
} catch {
return false;
}
}
/** GET /account/auth - Fetch 2FA and app password status */
async getAuthInfo(): Promise<StalwartAuthInfo> {
const result = await this.request<{ data: StalwartAuthInfo }>('/account/auth');
return result.data;
}
/** POST /account/auth - Update auth settings (TOTP, app passwords) */
async updateAuth(actions: Array<{ type: string; name?: string; password?: string; url?: string }>): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify(actions),
});
}
/** Enable TOTP - returns the TOTP URL for QR code generation */
async enableTotp(): Promise<string> {
const result = await this.request<{ data: string }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'enableOtpAuth' }]),
});
return result.data;
}
/** Disable TOTP */
async disableTotp(): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'disableOtpAuth' }]),
});
}
/** Add an app password */
async addAppPassword(name: string, password: string): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'addAppPassword', name, password }]),
});
}
/** Remove an app password */
async removeAppPassword(name: string): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'removeAppPassword', name }]),
});
}
/** GET /account/crypto - Fetch encryption-at-rest settings */
async getCryptoInfo(): Promise<StalwartCryptoInfo> {
const result = await this.request<{ data: StalwartCryptoInfo }>('/account/crypto');
return result.data;
}
/** POST /account/crypto - Update encryption-at-rest settings */
async updateCrypto(settings: { type: string; algo?: string; certs?: string }): Promise<void> {
await this.request<{ data: unknown }>('/account/crypto', {
method: 'POST',
body: JSON.stringify(settings),
});
}
/** GET /principal/{name} - Fetch principal details */
async getPrincipal(name: string): Promise<StalwartPrincipal> {
const result = await this.request<{ data: StalwartPrincipal }>(`/principal/${encodeURIComponent(name)}`);
return result.data;
}
/** PATCH /principal/{name} - Update principal fields */
async updatePrincipal(name: string, actions: PrincipalUpdateAction[]): Promise<void> {
await this.request<{ data: unknown }>(`/principal/${encodeURIComponent(name)}`, {
method: 'PATCH',
body: JSON.stringify(actions),
});
}
/** Change password via PATCH /principal/{name} */
async changePassword(name: string, newPassword: string): Promise<void> {
await this.updatePrincipal(name, [
{ action: 'set', field: 'secrets', value: newPassword },
]);
}
/** Update display name via PATCH /principal/{name} */
async updateDisplayName(name: string, displayName: string): Promise<void> {
await this.updatePrincipal(name, [
{ action: 'set', field: 'description', value: displayName },
]);
}
}
+2 -25
View File
@@ -4,9 +4,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
export interface StalwartCredentials {
/** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */
apiUrl: string;
/** URL of the JMAP server (for JMAP operations like password verification) */
/** URL of the JMAP server (used for JMAP + management method calls) */
serverUrl: string;
authHeader: string;
username: string;
@@ -14,26 +12,6 @@ export interface StalwartCredentials {
slot: number;
}
/**
* Resolve the base URL for Stalwart management API requests.
*
* When the JMAP server sits behind a reverse proxy that only forwards
* JMAP paths, the `/api/account/*` and `/api/principal/*` management
* endpoints may not be exposed. In that case, operators can set
* `STALWART_API_URL` to point directly at the Stalwart HTTP listener
* (e.g. `https://admin.example.com`).
*/
function getStalwartApiUrl(jmapServerUrl: string): string {
const url = process.env.STALWART_API_URL || jmapServerUrl;
return url.replace(/\/+$/, '');
}
/**
* Extract credentials from the incoming request.
*
* Credentials are read from a verified, httpOnly auth-context cookie that is
* populated after a successful JMAP login or token refresh.
*/
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
const slot = parseInt(raw, 10);
@@ -55,8 +33,7 @@ export async function getStalwartCredentials(request: NextRequest): Promise<Stal
if (!context) continue;
return {
apiUrl: getStalwartApiUrl(context.serverUrl),
serverUrl: context.serverUrl,
serverUrl: context.serverUrl.replace(/\/+$/, ''),
authHeader: context.authHeader,
username: context.username,
hasSessionCookie: !!cookieStore.get(sessionCookieName(slot))?.value,
+66
View File
@@ -0,0 +1,66 @@
import { apiFetch } from '@/lib/browser-navigation';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
export type JmapMethodCall = [string, Record<string, unknown>, string];
export type JmapMethodResponse = [string, Record<string, unknown>, string];
export const STALWART_JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
export interface StalwartJmapError extends Error {
status: number;
methodError?: { type: string; description?: string };
}
function buildError(message: string, status: number, methodError?: StalwartJmapError['methodError']): StalwartJmapError {
const err = new Error(message) as StalwartJmapError;
err.status = status;
if (methodError) err.methodError = methodError;
return err;
}
/**
* Send a JMAP request to Stalwart via the server-side passthrough.
* The passthrough injects the stored basic-auth header so credentials
* stay in an httpOnly cookie.
*/
export async function stalwartJmap(methodCalls: JmapMethodCall[]): Promise<JmapMethodResponse[]> {
const response = await apiFetch('/api/account/stalwart/jmap', {
method: 'POST',
headers: { ...getActiveAccountSlotHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ using: STALWART_JMAP_USING, methodCalls }),
});
if (!response.ok) {
let message = `HTTP ${response.status}`;
try {
const body = await response.json();
if (body?.error) message = body.error;
} catch { /* ignore */ }
throw buildError(message, response.status);
}
const data = await response.json();
const responses = (data.methodResponses ?? []) as JmapMethodResponse[];
const first = responses[0];
if (first && first[0] === 'error') {
const result = first[1] as { type?: string; description?: string };
throw buildError(result.description || result.type || 'JMAP error', 200, {
type: result.type || 'unknown',
description: result.description,
});
}
return responses;
}
export function requireResult<T = Record<string, unknown>>(
responses: JmapMethodResponse[],
expectedMethod: string,
): T {
const match = responses.find(r => r[0] === expectedMethod);
if (!match) {
throw buildError(`Expected method ${expectedMethod} in response`, 200);
}
return match[1] as T;
}
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "Ordner",
"mail": "E-Mail",
"nav_label": "Navigation",
"add_app": "Apps"
"add_app": "Apps",
"shared": "Geteilt"
},
"sidebar_apps": {
"modal_title": "Sidebar-Apps",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "Gemeinsames Postfach",
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen"
},
"colorful_sidebar_icons": {
"label": "Farbige Seitenleistensymbole",
"description": "Ordner- und Tag-Symbole nach Typ einfärben (blauer Posteingang, roter Spam, grüner Gesendet usw.). Für eine monochrome Seitenleiste deaktivieren."
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "Schlüsselwort hinzufügen...",
"add": "Hinzufügen",
"remove": "Entfernen"
},
"hide_inline_image_attachments": {
"label": "Eingebettete Bilder in Anhangsliste ausblenden",
"description": "Im Nachrichtentext eingebettete Bilder werden nicht als separate Anhänge aufgeführt"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "Zwei-Faktor-Authentifizierung deaktiviert",
"enable_error": "2FA konnte nicht aktiviert werden",
"disable_error": "2FA konnte nicht deaktiviert werden",
"setup_instructions": "Kopieren Sie diese URL in Ihre Authenticator-App (Google Authenticator, Authy, etc.):"
"setup_instructions": "Kopieren Sie diese URL in Ihre Authenticator-App (Google Authenticator, Authy, etc.):",
"verification_code": "Verifizierungscode",
"confirm": "Bestätigen",
"disable": "Deaktivieren",
"disable_confirm_prompt": "Geben Sie Ihr Passwort ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren.",
"password_required": "Passwort ist erforderlich",
"code_required": "Verifizierungscode ist erforderlich",
"code_invalid": "Ungültiger Verifizierungscode. Überprüfen Sie Ihre Authenticator-App und versuchen Sie es erneut."
},
"app_passwords": {
"title": "App-Passwörter",
@@ -1088,7 +1104,25 @@
"removed": "App-Passwort entfernt",
"add_error": "App-Passwort konnte nicht erstellt werden",
"remove_error": "App-Passwort konnte nicht entfernt werden",
"none": "Keine App-Passwörter konfiguriert"
"none": "Keine App-Passwörter konfiguriert",
"done": "Fertig",
"expires_label": "Läuft ab (optional)",
"copy_now_warning": "Kopieren Sie dieses Passwort jetzt - es wird nicht erneut angezeigt.",
"allowed_ips_label": "Erlaubte IPs (optional)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Komma- oder leerzeichengetrennt. Leer lassen, um jede IP zuzulassen."
},
"api_keys": {
"title": "API-Schlüssel",
"description": "Erstellen Sie API-Schlüssel für Skripte und Integrationen, die direkt mit dem Server kommunizieren",
"name_label": "Schlüsselname",
"name_placeholder": "z.B. Backup-Skript, CI-Runner",
"copy_now_warning": "Kopieren Sie diesen API-Schlüssel jetzt - er wird nicht erneut angezeigt.",
"added": "API-Schlüssel erstellt",
"removed": "API-Schlüssel entfernt",
"add_error": "API-Schlüssel konnte nicht erstellt werden",
"remove_error": "API-Schlüssel konnte nicht entfernt werden",
"none": "Keine API-Schlüssel konfiguriert"
},
"encryption": {
"section_title": "Verschlüsselung im Ruhezustand",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# Bedingung} other {# Bedingungen}}",
"actions_count": "{count, plural, one {# Aktion} other {# Aktionen}}"
}
},
"origin_external": "Extern",
"managed_by_tooltip": "Verwaltet von {source}. Bearbeiten Sie sie in dieser App oder verwenden Sie den rohen Sieve-Editor."
},
"templates": {
"title": "E-Mail-Vorlagen",
@@ -1780,6 +1816,15 @@
"cert_imported": "Zertifikat importiert",
"cert_import_failed": "Import des Zertifikats fehlgeschlagen"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "Neuer Kontakt",
"edit_title": "Kontakt bearbeiten",
@@ -1968,7 +2013,10 @@
"resize": "Termingröße ändern",
"duplicate": "Duplizieren",
"today_header": "Heute",
"tomorrow_header": "Morgen"
"tomorrow_header": "Morgen",
"export_ics": "Als .ics exportieren",
"copy_title": "Titel kopieren",
"copy_link": "Meeting-Link kopieren"
},
"detail": {
"add_note": "Notiz hinzufügen...",
@@ -2115,7 +2163,10 @@
"rsvp_error": "Antwort konnte nicht aktualisiert werden",
"event_duplicated": "Termin dupliziert",
"event_error": "Termin konnte nicht gespeichert werden",
"task_due": "Aufgabe fällig"
"task_due": "Aufgabe fällig",
"event_exported": "Termin exportiert",
"title_copied": "Titel kopiert",
"link_copied": "Link kopiert"
},
"status": {
"loading_calendars": "Kalender werden geladen...",
+55 -3
View File
@@ -966,6 +966,10 @@
"add_placeholder": "Add keyword...",
"add": "Add",
"remove": "Remove"
},
"hide_inline_image_attachments": {
"label": "Hide inline images from attachments",
"description": "Images embedded in the message body are not listed as separate attachments"
}
},
"composer": {
@@ -1076,7 +1080,14 @@
"disabled": "Two-factor authentication disabled",
"enable_error": "Failed to enable 2FA",
"disable_error": "Failed to disable 2FA",
"setup_instructions": "Copy this URL into your authenticator app (Google Authenticator, Authy, etc.):"
"setup_instructions": "Copy this URL into your authenticator app (Google Authenticator, Authy, etc.):",
"verification_code": "Verification code",
"confirm": "Confirm",
"disable": "Disable",
"disable_confirm_prompt": "Enter your password to disable two-factor authentication.",
"password_required": "Password is required",
"code_required": "Verification code is required",
"code_invalid": "Invalid verification code. Check your authenticator app and try again."
},
"app_passwords": {
"title": "App Passwords",
@@ -1084,17 +1095,35 @@
"add": "Add",
"create": "Create",
"cancel": "Cancel",
"done": "Done",
"generate": "Generate",
"name_label": "App Name",
"name_placeholder": "e.g. Thunderbird, iPhone Mail",
"expires_label": "Expires (optional)",
"allowed_ips_label": "Allowed IPs (optional)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Comma- or space-separated. Leave empty to allow any IP.",
"password_label": "Password (leave empty to auto-generate)",
"password_placeholder": "Auto-generated if empty",
"copy_now_warning": "Copy this password now - it will not be shown again.",
"added": "App password created",
"removed": "App password removed",
"add_error": "Failed to create app password",
"remove_error": "Failed to remove app password",
"none": "No app passwords configured"
},
"api_keys": {
"title": "API Keys",
"description": "Create API keys for scripts and integrations that talk to the server directly",
"name_label": "Key Name",
"name_placeholder": "e.g. Backup script, CI runner",
"copy_now_warning": "Copy this API key now - it will not be shown again.",
"added": "API key created",
"removed": "API key removed",
"add_error": "Failed to create API key",
"remove_error": "Failed to remove API key",
"none": "No API keys configured"
},
"encryption": {
"section_title": "Encryption at Rest",
"label": "Email Encryption",
@@ -1787,6 +1816,15 @@
"scheduling_uri": "Scheduling URL",
"freebusy_uri": "Free/Busy URL"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "New Contact",
"edit_title": "Edit Contact",
@@ -1941,6 +1979,14 @@
"error_create": "Failed to create contact",
"error_update": "Failed to update contact",
"error_delete": "Failed to delete contact"
},
"context_menu": {
"open": "Open",
"edit": "Edit",
"send_email": "Send email",
"add_to_group": "Add to group",
"export_vcard": "Export as vCard",
"delete": "Delete"
}
},
"calendar": {
@@ -1975,7 +2021,10 @@
"resize": "Resize event",
"duplicate": "Duplicate",
"today_header": "Today",
"tomorrow_header": "Tomorrow"
"tomorrow_header": "Tomorrow",
"export_ics": "Export as .ics",
"copy_title": "Copy title",
"copy_link": "Copy meeting link"
},
"detail": {
"add_note": "Add a note...",
@@ -2122,7 +2171,10 @@
"rsvp_error": "Failed to update response",
"event_duplicated": "Event duplicated",
"event_error": "Failed to save event",
"task_due": "Task due"
"task_due": "Task due",
"event_exported": "Event exported",
"title_copied": "Title copied",
"link_copied": "Link copied"
},
"status": {
"loading_calendars": "Loading calendars...",
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "Carpetas",
"mail": "Correo",
"nav_label": "Navegación",
"add_app": "Apps"
"add_app": "Apps",
"shared": "Compartido"
},
"sidebar_apps": {
"modal_title": "Aplicaciones de la barra lateral",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "Buzón unificado",
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas"
},
"colorful_sidebar_icons": {
"label": "Iconos de barra lateral a color",
"description": "Colorea los iconos de carpetas y etiquetas según su tipo (azul para Bandeja de entrada, rojo para Spam, verde para Enviados, etc.). Desactívalo para una barra lateral monocroma."
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "Añadir palabra clave...",
"add": "Add",
"remove": "Eliminar"
},
"hide_inline_image_attachments": {
"label": "Ocultar imágenes incrustadas de los adjuntos",
"description": "Las imágenes incrustadas en el cuerpo del mensaje no se listan como adjuntos separados"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "Autenticación de dos factores deshabilitada",
"enable_error": "No se pudo habilitar 2FA",
"disable_error": "No se pudo deshabilitar 2FA",
"setup_instructions": "Copie esta URL en su aplicación de autenticación (Google Authenticator, Authy, etc.):"
"setup_instructions": "Copie esta URL en su aplicación de autenticación (Google Authenticator, Authy, etc.):",
"verification_code": "Código de verificación",
"confirm": "Confirmar",
"disable": "Desactivar",
"disable_confirm_prompt": "Introduce tu contraseña para desactivar la autenticación de dos factores.",
"password_required": "Se requiere la contraseña",
"code_required": "Se requiere el código de verificación",
"code_invalid": "Código de verificación no válido. Revisa tu aplicación de autenticación e inténtalo de nuevo."
},
"app_passwords": {
"title": "Contraseñas de aplicación",
@@ -1088,7 +1104,25 @@
"removed": "Contraseña de aplicación eliminada",
"add_error": "No se pudo crear la contraseña de aplicación",
"remove_error": "No se pudo eliminar la contraseña de aplicación",
"none": "No hay contraseñas de aplicación configuradas"
"none": "No hay contraseñas de aplicación configuradas",
"done": "Hecho",
"expires_label": "Caduca (opcional)",
"copy_now_warning": "Copia esta contraseña ahora - no se volverá a mostrar.",
"allowed_ips_label": "IPs permitidas (opcional)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Separadas por coma o espacio. Dejar vacío para permitir cualquier IP."
},
"api_keys": {
"title": "Claves API",
"description": "Crea claves API para scripts e integraciones que se comunican directamente con el servidor",
"name_label": "Nombre de la clave",
"name_placeholder": "p. ej. Script de copia de seguridad, CI runner",
"copy_now_warning": "Copia esta clave API ahora - no se mostrará de nuevo.",
"added": "Clave API creada",
"removed": "Clave API eliminada",
"add_error": "No se pudo crear la clave API",
"remove_error": "No se pudo eliminar la clave API",
"none": "No hay claves API configuradas"
},
"encryption": {
"section_title": "Cifrado en reposo",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condición} other {# condiciones}}",
"actions_count": "{count, plural, one {# acción} other {# acciones}}"
}
},
"origin_external": "Externo",
"managed_by_tooltip": "Gestionado por {source}. Edítalo en esa aplicación o usa el editor Sieve sin procesar."
},
"templates": {
"title": "Plantillas de correo",
@@ -1780,6 +1816,15 @@
"cert_imported": "Certificado importado",
"cert_import_failed": "Error al importar el certificado"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "Nuevo contacto",
"edit_title": "Editar contacto",
@@ -1968,7 +2013,10 @@
"resize": "Redimensionar evento",
"duplicate": "Duplicar",
"today_header": "Hoy",
"tomorrow_header": "Mañana"
"tomorrow_header": "Mañana",
"export_ics": "Exportar como .ics",
"copy_title": "Copiar título",
"copy_link": "Copiar enlace de reunión"
},
"detail": {
"add_note": "Añadir una nota...",
@@ -2115,7 +2163,10 @@
"rsvp_error": "Error al actualizar la respuesta",
"event_duplicated": "Evento duplicado",
"event_error": "Error al guardar el evento",
"task_due": "Tarea vencida"
"task_due": "Tarea vencida",
"event_exported": "Evento exportado",
"title_copied": "Título copiado",
"link_copied": "Enlace copiado"
},
"status": {
"loading_calendars": "Cargando calendarios...",
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "Dossiers",
"mail": "Messagerie",
"nav_label": "Navigation",
"add_app": "Apps"
"add_app": "Apps",
"shared": "Partagé"
},
"sidebar_apps": {
"modal_title": "Applications de la barre latérale",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "Boîte aux lettres unifiée",
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés"
},
"colorful_sidebar_icons": {
"label": "Icônes colorées dans la barre latérale",
"description": "Colore les icônes de dossiers et d'étiquettes par type (Boîte de réception en bleu, Indésirable en rouge, Envoyés en vert, etc.). Désactivez pour une barre latérale monochrome."
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "Ajouter un mot-clé...",
"add": "Add",
"remove": "Supprimer"
},
"hide_inline_image_attachments": {
"label": "Masquer les images intégrées des pièces jointes",
"description": "Les images intégrées au corps du message ne sont pas listées comme pièces jointes séparées"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "Authentification à deux facteurs désactivée",
"enable_error": "Impossible d'activer la 2FA",
"disable_error": "Impossible de désactiver la 2FA",
"setup_instructions": "Copiez cette URL dans votre application d'authentification (Google Authenticator, Authy, etc.) :"
"setup_instructions": "Copiez cette URL dans votre application d'authentification (Google Authenticator, Authy, etc.) :",
"verification_code": "Code de vérification",
"confirm": "Confirmer",
"disable": "Désactiver",
"disable_confirm_prompt": "Saisissez votre mot de passe pour désactiver l'authentification à deux facteurs.",
"password_required": "Le mot de passe est requis",
"code_required": "Le code de vérification est requis",
"code_invalid": "Code de vérification non valide. Vérifiez votre application d'authentification et réessayez."
},
"app_passwords": {
"title": "Mots de passe d'application",
@@ -1088,7 +1104,25 @@
"removed": "Mot de passe d'application supprimé",
"add_error": "Impossible de créer le mot de passe d'application",
"remove_error": "Impossible de supprimer le mot de passe d'application",
"none": "Aucun mot de passe d'application configuré"
"none": "Aucun mot de passe d'application configuré",
"done": "Terminé",
"expires_label": "Expire (facultatif)",
"copy_now_warning": "Copiez ce mot de passe maintenant - il ne sera plus affiché.",
"allowed_ips_label": "IP autorisées (facultatif)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Séparées par virgule ou espace. Laisser vide pour autoriser toute IP."
},
"api_keys": {
"title": "Clés API",
"description": "Créez des clés API pour les scripts et intégrations qui communiquent directement avec le serveur",
"name_label": "Nom de la clé",
"name_placeholder": "ex. Script de sauvegarde, CI runner",
"copy_now_warning": "Copiez cette clé API maintenant - elle ne sera plus affichée.",
"added": "Clé API créée",
"removed": "Clé API supprimée",
"add_error": "Échec de la création de la clé API",
"remove_error": "Échec de la suppression de la clé API",
"none": "Aucune clé API configurée"
},
"encryption": {
"section_title": "Chiffrement au repos",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condition} other {# conditions}}",
"actions_count": "{count, plural, one {# action} other {# actions}}"
}
},
"origin_external": "Externe",
"managed_by_tooltip": "Géré par {source}. Modifiez-le dans cette application, ou utilisez l'éditeur Sieve brut."
},
"templates": {
"title": "Modèles d'e-mails",
@@ -1780,6 +1816,15 @@
"cert_imported": "Certificat importé",
"cert_import_failed": "Échec de l'import du certificat"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "Nouveau contact",
"edit_title": "Modifier le contact",
@@ -1968,7 +2013,10 @@
"resize": "Redimensionner l'événement",
"duplicate": "Dupliquer",
"today_header": "Aujourd'hui",
"tomorrow_header": "Demain"
"tomorrow_header": "Demain",
"export_ics": "Exporter en .ics",
"copy_title": "Copier le titre",
"copy_link": "Copier le lien de réunion"
},
"detail": {
"add_note": "Ajouter une note...",
@@ -2115,7 +2163,10 @@
"rsvp_error": "Échec de la mise à jour de la réponse",
"event_duplicated": "Événement dupliqué",
"event_error": "Échec de l'enregistrement de l'événement",
"task_due": "Tâche à échéance"
"task_due": "Tâche à échéance",
"event_exported": "Événement exporté",
"title_copied": "Titre copié",
"link_copied": "Lien copié"
},
"status": {
"loading_calendars": "Chargement des calendriers...",
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "Cartelle",
"mail": "Posta",
"nav_label": "Navigazione",
"add_app": "App"
"add_app": "App",
"shared": "Condiviso"
},
"sidebar_apps": {
"modal_title": "App della barra laterale",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "Casella di posta unificata",
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati"
},
"colorful_sidebar_icons": {
"label": "Icone colorate nella barra laterale",
"description": "Colora le icone di cartelle ed etichette per tipo (Posta in arrivo blu, Spam rosso, Inviati verde, ecc.). Disattiva per una barra laterale monocromatica."
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "Aggiungi parola chiave...",
"add": "Add",
"remove": "Rimuovi"
},
"hide_inline_image_attachments": {
"label": "Nascondi le immagini inline dagli allegati",
"description": "Le immagini incorporate nel corpo del messaggio non vengono elencate come allegati separati"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "Autenticazione a due fattori disabilitata",
"enable_error": "Impossibile abilitare la 2FA",
"disable_error": "Impossibile disabilitare la 2FA",
"setup_instructions": "Copia questo URL nella tua app di autenticazione (Google Authenticator, Authy, ecc.):"
"setup_instructions": "Copia questo URL nella tua app di autenticazione (Google Authenticator, Authy, ecc.):",
"verification_code": "Codice di verifica",
"confirm": "Conferma",
"disable": "Disattiva",
"disable_confirm_prompt": "Inserisci la tua password per disattivare l'autenticazione a due fattori.",
"password_required": "La password è obbligatoria",
"code_required": "Il codice di verifica è obbligatorio",
"code_invalid": "Codice di verifica non valido. Controlla la tua app di autenticazione e riprova."
},
"app_passwords": {
"title": "Password per le app",
@@ -1088,7 +1104,25 @@
"removed": "Password per l'app rimossa",
"add_error": "Impossibile creare la password per l'app",
"remove_error": "Impossibile rimuovere la password per l'app",
"none": "Nessuna password per le app configurata"
"none": "Nessuna password per le app configurata",
"done": "Fatto",
"expires_label": "Scadenza (facoltativa)",
"copy_now_warning": "Copia questa password ora - non verrà più mostrata.",
"allowed_ips_label": "IP consentiti (opzionale)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Separati da virgola o spazio. Lasciare vuoto per consentire qualsiasi IP."
},
"api_keys": {
"title": "Chiavi API",
"description": "Crea chiavi API per script e integrazioni che comunicano direttamente con il server",
"name_label": "Nome chiave",
"name_placeholder": "es. Script di backup, CI runner",
"copy_now_warning": "Copia subito questa chiave API - non verrà mostrata di nuovo.",
"added": "Chiave API creata",
"removed": "Chiave API rimossa",
"add_error": "Impossibile creare la chiave API",
"remove_error": "Impossibile rimuovere la chiave API",
"none": "Nessuna chiave API configurata"
},
"encryption": {
"section_title": "Crittografia a riposo",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condizione} other {# condizioni}}",
"actions_count": "{count, plural, one {# azione} other {# azioni}}"
}
},
"origin_external": "Esterno",
"managed_by_tooltip": "Gestito da {source}. Modificalo in quell'app o usa l'editor Sieve grezzo."
},
"templates": {
"title": "Modelli email",
@@ -1780,6 +1816,15 @@
"cert_imported": "Certificato importato",
"cert_import_failed": "Importazione del certificato non riuscita"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "Nuovo contatto",
"edit_title": "Modifica contatto",
@@ -1968,7 +2013,10 @@
"resize": "Ridimensiona evento",
"duplicate": "Duplica",
"today_header": "Oggi",
"tomorrow_header": "Domani"
"tomorrow_header": "Domani",
"export_ics": "Esporta come .ics",
"copy_title": "Copia titolo",
"copy_link": "Copia link riunione"
},
"detail": {
"add_note": "Aggiungi una nota...",
@@ -2115,7 +2163,10 @@
"rsvp_error": "Impossibile aggiornare la risposta",
"event_duplicated": "Evento duplicato",
"event_error": "Salvataggio dell'evento non riuscito",
"task_due": "Attività in scadenza"
"task_due": "Attività in scadenza",
"event_exported": "Evento esportato",
"title_copied": "Titolo copiato",
"link_copied": "Link copiato"
},
"status": {
"loading_calendars": "Caricamento calendari...",
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "フォルダ",
"mail": "メール",
"nav_label": "ナビゲーション",
"add_app": "アプリ"
"add_app": "アプリ",
"shared": "共有"
},
"sidebar_apps": {
"modal_title": "サイドバーアプリ",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "統合メールボックス",
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示"
},
"colorful_sidebar_icons": {
"label": "カラフルなサイドバーアイコン",
"description": "フォルダーとタグのアイコンを種類別に色分けします(受信トレイは青、迷惑メールは赤、送信済みは緑など)。モノクロのサイドバーにするには無効にしてください。"
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "キーワードを追加...",
"add": "Add",
"remove": "削除"
},
"hide_inline_image_attachments": {
"label": "添付ファイル一覧からインライン画像を隠す",
"description": "本文に埋め込まれた画像を個別の添付ファイルとして表示しません"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "二要素認証が無効になりました",
"enable_error": "2FAを有効にできませんでした",
"disable_error": "2FAを無効にできませんでした",
"setup_instructions": "このURLを認証アプリ(Google Authenticator、Authyなど)にコピーしてください:"
"setup_instructions": "このURLを認証アプリ(Google Authenticator、Authyなど)にコピーしてください:",
"verification_code": "確認コード",
"confirm": "確認",
"disable": "無効化",
"disable_confirm_prompt": "二要素認証を無効にするにはパスワードを入力してください。",
"password_required": "パスワードが必要です",
"code_required": "確認コードが必要です",
"code_invalid": "確認コードが無効です。認証アプリを確認してもう一度お試しください。"
},
"app_passwords": {
"title": "アプリパスワード",
@@ -1088,7 +1104,25 @@
"removed": "アプリパスワードが削除されました",
"add_error": "アプリパスワードを作成できませんでした",
"remove_error": "アプリパスワードを削除できませんでした",
"none": "アプリパスワードは設定されていません"
"none": "アプリパスワードは設定されていません",
"done": "完了",
"expires_label": "有効期限(任意)",
"copy_now_warning": "今すぐこのパスワードをコピーしてください - 再表示されません。",
"allowed_ips_label": "許可するIP(任意)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "カンマまたは空白で区切ります。空欄の場合、すべてのIPを許可します。"
},
"api_keys": {
"title": "APIキー",
"description": "サーバーと直接通信するスクリプトや連携用にAPIキーを作成します",
"name_label": "キー名",
"name_placeholder": "例: バックアップスクリプト、CIランナー",
"copy_now_warning": "このAPIキーを今すぐコピーしてください - 二度と表示されません。",
"added": "APIキーを作成しました",
"removed": "APIキーを削除しました",
"add_error": "APIキーの作成に失敗しました",
"remove_error": "APIキーの削除に失敗しました",
"none": "APIキーは設定されていません"
},
"encryption": {
"section_title": "保存時の暗号化",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "{count, plural, other {#個の条件}}",
"actions_count": "{count, plural, other {#個のアクション}}"
}
},
"origin_external": "外部",
"managed_by_tooltip": "{source} によって管理されています。そのアプリで編集するか、生の Sieve エディターを使用してください。"
},
"templates": {
"title": "メールテンプレート",
@@ -1780,6 +1816,15 @@
"cert_imported": "証明書をインポートしました",
"cert_import_failed": "証明書のインポートに失敗しました"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "新しい連絡先",
"edit_title": "連絡先を編集",
@@ -1968,7 +2013,10 @@
"resize": "イベントのサイズ変更",
"duplicate": "複製",
"today_header": "今日",
"tomorrow_header": "明日"
"tomorrow_header": "明日",
"export_ics": ".icsとしてエクスポート",
"copy_title": "タイトルをコピー",
"copy_link": "会議リンクをコピー"
},
"detail": {
"add_note": "メモを追加...",
@@ -2115,7 +2163,10 @@
"rsvp_error": "回答の更新に失敗しました",
"event_duplicated": "予定を複製しました",
"event_error": "予定の保存に失敗しました",
"task_due": "タスクの期限です"
"task_due": "タスクの期限です",
"event_exported": "イベントをエクスポートしました",
"title_copied": "タイトルをコピーしました",
"link_copied": "リンクをコピーしました"
},
"status": {
"loading_calendars": "カレンダーを読み込み中...",
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "폴더",
"mail": "메일",
"nav_label": "내비게이션",
"add_app": "앱"
"add_app": "앱",
"shared": "공유됨"
},
"sidebar_apps": {
"modal_title": "사이드바 앱",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "통합 메일함",
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다"
},
"colorful_sidebar_icons": {
"label": "컬러풀한 사이드바 아이콘",
"description": "폴더와 태그 아이콘을 유형별로 색상 표시합니다(받은편지함 파란색, 스팸 빨간색, 보낸편지함 녹색 등). 모노크롬 사이드바를 원하면 비활성화하세요."
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "키워드 추가...",
"add": "Add",
"remove": "삭제"
},
"hide_inline_image_attachments": {
"label": "첨부 파일 목록에서 인라인 이미지 숨기기",
"description": "메시지 본문에 포함된 이미지는 별도의 첨부 파일로 표시되지 않습니다"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "2단계 인증이 꺼졌어요",
"enable_error": "2단계 인증을 켜지 못했어요",
"disable_error": "2단계 인증을 끄지 못했어요",
"setup_instructions": "이 URL을 인증 앱(Google Authenticator, Authy 등)에 복사해 주세요:"
"setup_instructions": "이 URL을 인증 앱(Google Authenticator, Authy 등)에 복사해 주세요:",
"verification_code": "확인 코드",
"confirm": "확인",
"disable": "비활성화",
"disable_confirm_prompt": "2단계 인증을 비활성화하려면 비밀번호를 입력하세요.",
"password_required": "비밀번호가 필요합니다",
"code_required": "확인 코드가 필요합니다",
"code_invalid": "유효하지 않은 확인 코드입니다. 인증 앱을 확인하고 다시 시도하세요."
},
"app_passwords": {
"title": "앱 비밀번호",
@@ -1088,7 +1104,25 @@
"removed": "앱 비밀번호가 삭제되었어요",
"add_error": "앱 비밀번호를 만들지 못했어요",
"remove_error": "앱 비밀번호를 삭제하지 못했어요",
"none": "설정된 앱 비밀번호가 없어요"
"none": "설정된 앱 비밀번호가 없어요",
"done": "완료",
"expires_label": "만료 (선택 사항)",
"copy_now_warning": "지금 이 비밀번호를 복사하세요 - 다시 표시되지 않습니다.",
"allowed_ips_label": "허용된 IP(선택)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "쉼표 또는 공백으로 구분합니다. 비워두면 모든 IP가 허용됩니다."
},
"api_keys": {
"title": "API 키",
"description": "서버와 직접 통신하는 스크립트 및 통합용 API 키를 만듭니다",
"name_label": "키 이름",
"name_placeholder": "예: 백업 스크립트, CI 러너",
"copy_now_warning": "이 API 키를 지금 복사하세요 - 다시 표시되지 않습니다.",
"added": "API 키가 생성되었습니다",
"removed": "API 키가 삭제되었습니다",
"add_error": "API 키 생성에 실패했습니다",
"remove_error": "API 키 삭제에 실패했습니다",
"none": "구성된 API 키가 없습니다"
},
"encryption": {
"section_title": "저장 데이터 암호화",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "조건 {count}개",
"actions_count": "동작 {count}개"
}
},
"origin_external": "외부",
"managed_by_tooltip": "{source}에서 관리됩니다. 해당 앱에서 편집하거나 원시 Sieve 편집기를 사용하세요."
},
"templates": {
"title": "이메일 템플릿",
@@ -1780,6 +1816,15 @@
"scheduling_uri": "스케줄링 URL",
"freebusy_uri": "Free/Busy URL"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "새 연락처",
"edit_title": "연락처 수정",
@@ -1968,7 +2013,10 @@
"resize": "일정 크기 조절",
"duplicate": "복제",
"today_header": "오늘",
"tomorrow_header": "내일"
"tomorrow_header": "내일",
"export_ics": ".ics로 내보내기",
"copy_title": "제목 복사",
"copy_link": "회의 링크 복사"
},
"detail": {
"add_note": "메모 추가...",
@@ -2115,7 +2163,10 @@
"rsvp_error": "응답을 업데이트하지 못했어요",
"event_duplicated": "일정이 복제되었어요",
"event_error": "일정을 저장하지 못했어요",
"task_due": "할 일 기한이 다가와요"
"task_due": "할 일 기한이 다가와요",
"event_exported": "이벤트 내보내기 완료",
"title_copied": "제목이 복사되었습니다",
"link_copied": "링크가 복사되었습니다"
},
"status": {
"loading_calendars": "캘린더를 불러오는 중...",
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "Mapes",
"mail": "Pasts",
"nav_label": "Navigācija",
"add_app": "Lietotnes"
"add_app": "Lietotnes",
"shared": "Koplietots"
},
"sidebar_apps": {
"modal_title": "Sānu joslas lietotnes",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "Apvienotā pastkaste",
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem"
},
"colorful_sidebar_icons": {
"label": "Krāsainas sānjoslas ikonas",
"description": "Iekrāsojiet mapju un birku ikonas pēc to veida (zila Iesūtne, sarkana Mēstules, zaļa Nosūtītie utt.). Atspējojiet, lai iegūtu vienkrāsainu sānjoslu."
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "Pievienot atslēgvārdu...",
"add": "Add",
"remove": "Noņemt"
},
"hide_inline_image_attachments": {
"label": "Slēpt iegultos attēlus no pielikumu saraksta",
"description": "Ziņojuma pamattekstā iegultie attēli netiek rādīti kā atsevišķi pielikumi"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "2FA ir izslēgta",
"enable_error": "Neizdevās iespējot 2FA",
"disable_error": "Neizdevās izslēgt 2FA",
"setup_instructions": "Nokopējiet šo URL savā autentifikācijas lietotnē (Google Authenticator, Authy utt.):"
"setup_instructions": "Nokopējiet šo URL savā autentifikācijas lietotnē (Google Authenticator, Authy utt.):",
"verification_code": "Verifikācijas kods",
"confirm": "Apstiprināt",
"disable": "Atspējot",
"disable_confirm_prompt": "Ievadiet paroli, lai atspējotu divpakāpju autentifikāciju.",
"password_required": "Nepieciešama parole",
"code_required": "Nepieciešams verifikācijas kods",
"code_invalid": "Nederīgs verifikācijas kods. Pārbaudiet autentifikācijas lietotni un mēģiniet vēlreiz."
},
"app_passwords": {
"title": "Lietotņu paroles",
@@ -1088,7 +1104,25 @@
"removed": "Lietotnes parole izdzēsta",
"add_error": "Neizdevās izveidot lietotnes paroli",
"remove_error": "Neizdevās izdzēst lietotnes paroli",
"none": "Lietotņu paroles nav iestatītas"
"none": "Lietotņu paroles nav iestatītas",
"done": "Gatavs",
"expires_label": "Derīguma termiņš (pēc izvēles)",
"copy_now_warning": "Kopējiet šo paroli tagad - tā vairs netiks rādīta.",
"allowed_ips_label": "Atļautās IP (neobligāti)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Atdalītas ar komatu vai atstarpi. Atstājiet tukšu, lai atļautu jebkuru IP."
},
"api_keys": {
"title": "API atslēgas",
"description": "Izveidojiet API atslēgas skriptiem un integrācijām, kas tieši sazinās ar serveri",
"name_label": "Atslēgas nosaukums",
"name_placeholder": "piem. Dublēšanas skripts, CI palaidējs",
"copy_now_warning": "Kopējiet šo API atslēgu tagad - tā vairs netiks parādīta.",
"added": "API atslēga izveidota",
"removed": "API atslēga noņemta",
"add_error": "Neizdevās izveidot API atslēgu",
"remove_error": "Neizdevās noņemt API atslēgu",
"none": "API atslēgas nav konfigurētas"
},
"encryption": {
"section_title": "Krātuves šifrēšana",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# nosacījums} other {# nosacījumi}}",
"actions_count": "{count, plural, one {# darbība} other {# darbības}}"
}
},
"origin_external": "Ārējs",
"managed_by_tooltip": "Pārvalda {source}. Rediģējiet to šajā lietotnē vai izmantojiet neapstrādāto Sieve redaktoru."
},
"templates": {
"title": "Vēstuļu veidnes",
@@ -1776,6 +1812,15 @@
"scheduling_uri": "Plānošanas URL",
"freebusy_uri": "Pieejamības URL"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "Jauns kontakts",
"edit_title": "Rediģēt kontaktu",
@@ -1967,7 +2012,10 @@
"resize": "Mainīt pasākuma laiku",
"duplicate": "Dublēt",
"today_header": "Šodien",
"tomorrow_header": "Rīt"
"tomorrow_header": "Rīt",
"export_ics": "Eksportēt kā .ics",
"copy_title": "Kopēt nosaukumu",
"copy_link": "Kopēt sapulces saiti"
},
"detail": {
"add_note": "Pievienot piezīmi...",
@@ -2114,7 +2162,10 @@
"rsvp_error": "Neizdevās atjaunināt atbildi",
"event_duplicated": "Pasākums dublēts",
"event_error": "Neizdevās saglabāt pasākumu",
"task_due": "Uzdevuma termiņš"
"task_due": "Uzdevuma termiņš",
"event_exported": "Notikums eksportēts",
"title_copied": "Nosaukums nokopēts",
"link_copied": "Saite nokopēta"
},
"status": {
"loading_calendars": "Ielādē kalendārus...",
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "Mappen",
"mail": "E-mail",
"nav_label": "Navigatie",
"add_app": "Apps"
"add_app": "Apps",
"shared": "Gedeeld"
},
"sidebar_apps": {
"modal_title": "Zijbalk-apps",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "Gecombineerd postvak",
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven"
},
"colorful_sidebar_icons": {
"label": "Gekleurde zijbalkpictogrammen",
"description": "Kleur map- en tagpictogrammen op type (blauw Postvak IN, rood Spam, groen Verzonden, enz.). Schakel uit voor een monochrome zijbalk."
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "Trefwoord toevoegen...",
"add": "Add",
"remove": "Verwijderen"
},
"hide_inline_image_attachments": {
"label": "Inline-afbeeldingen verbergen uit bijlagenlijst",
"description": "Afbeeldingen die in de berichttekst zijn ingesloten worden niet als aparte bijlagen weergegeven"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "Tweefactorauthenticatie uitgeschakeld",
"enable_error": "Kan 2FA niet inschakelen",
"disable_error": "Kan 2FA niet uitschakelen",
"setup_instructions": "Kopieer deze URL naar uw authenticator-app (Google Authenticator, Authy, etc.):"
"setup_instructions": "Kopieer deze URL naar uw authenticator-app (Google Authenticator, Authy, etc.):",
"verification_code": "Verificatiecode",
"confirm": "Bevestigen",
"disable": "Uitschakelen",
"disable_confirm_prompt": "Voer uw wachtwoord in om tweefactorauthenticatie uit te schakelen.",
"password_required": "Wachtwoord is vereist",
"code_required": "Verificatiecode is vereist",
"code_invalid": "Ongeldige verificatiecode. Controleer uw authenticator-app en probeer het opnieuw."
},
"app_passwords": {
"title": "App-wachtwoorden",
@@ -1088,7 +1104,25 @@
"removed": "App-wachtwoord verwijderd",
"add_error": "Kan app-wachtwoord niet aanmaken",
"remove_error": "Kan app-wachtwoord niet verwijderen",
"none": "Geen app-wachtwoorden geconfigureerd"
"none": "Geen app-wachtwoorden geconfigureerd",
"done": "Klaar",
"expires_label": "Verloopt (optioneel)",
"copy_now_warning": "Kopieer dit wachtwoord nu - het wordt niet opnieuw weergegeven.",
"allowed_ips_label": "Toegestane IP's (optioneel)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Gescheiden door komma of spatie. Laat leeg om elk IP toe te staan."
},
"api_keys": {
"title": "API-sleutels",
"description": "Maak API-sleutels voor scripts en integraties die rechtstreeks met de server praten",
"name_label": "Sleutelnaam",
"name_placeholder": "bijv. Back-upscript, CI-runner",
"copy_now_warning": "Kopieer deze API-sleutel nu - hij wordt niet opnieuw getoond.",
"added": "API-sleutel aangemaakt",
"removed": "API-sleutel verwijderd",
"add_error": "API-sleutel aanmaken mislukt",
"remove_error": "API-sleutel verwijderen mislukt",
"none": "Geen API-sleutels geconfigureerd"
},
"encryption": {
"section_title": "Versleuteling in rust",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# voorwaarde} other {# voorwaarden}}",
"actions_count": "{count, plural, one {# actie} other {# acties}}"
}
},
"origin_external": "Extern",
"managed_by_tooltip": "Beheerd door {source}. Bewerk het in die app of gebruik de ruwe Sieve-editor."
},
"templates": {
"title": "E-mailsjablonen",
@@ -1780,6 +1816,15 @@
"cert_imported": "Certificaat geïmporteerd",
"cert_import_failed": "Importeren van certificaat mislukt"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "Nieuw contact",
"edit_title": "Contact bewerken",
@@ -1968,7 +2013,10 @@
"resize": "Evenement formaat wijzigen",
"duplicate": "Dupliceren",
"today_header": "Vandaag",
"tomorrow_header": "Morgen"
"tomorrow_header": "Morgen",
"export_ics": "Exporteren als .ics",
"copy_title": "Titel kopiëren",
"copy_link": "Vergaderlink kopiëren"
},
"detail": {
"add_note": "Notitie toevoegen...",
@@ -2115,7 +2163,10 @@
"rsvp_error": "Reactie kon niet worden bijgewerkt",
"event_duplicated": "Evenement gedupliceerd",
"event_error": "Evenement opslaan mislukt",
"task_due": "Taak vervalt"
"task_due": "Taak vervalt",
"event_exported": "Afspraak geëxporteerd",
"title_copied": "Titel gekopieerd",
"link_copied": "Link gekopieerd"
},
"status": {
"loading_calendars": "Agenda's laden...",
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "Foldery",
"mail": "Poczta",
"nav_label": "Nawigacja",
"add_app": "Aplikacje"
"add_app": "Aplikacje",
"shared": "Udostępnione"
},
"sidebar_apps": {
"modal_title": "Aplikacje paska bocznego",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "Wspólna skrzynka",
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont"
},
"colorful_sidebar_icons": {
"label": "Kolorowe ikony paska bocznego",
"description": "Koloruj ikony folderów i tagów według typu (niebieska Skrzynka odbiorcza, czerwona Spam, zielona Wysłane itp.). Wyłącz, aby uzyskać monochromatyczny pasek boczny."
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "Dodaj słowo kluczowe...",
"add": "Add",
"remove": "Usuń"
},
"hide_inline_image_attachments": {
"label": "Ukryj obrazy osadzone z listy załączników",
"description": "Obrazy osadzone w treści wiadomości nie są wyświetlane jako osobne załączniki"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "Uwierzytelnianie dwuskładnikowe wyłączone",
"enable_error": "Nie udało się włączyć 2FA",
"disable_error": "Nie udało się wyłączyć 2FA",
"setup_instructions": "Skopiuj ten adres URL do swojej aplikacji uwierzytelniającej (Google Authenticator, Authy itp.):"
"setup_instructions": "Skopiuj ten adres URL do swojej aplikacji uwierzytelniającej (Google Authenticator, Authy itp.):",
"verification_code": "Kod weryfikacyjny",
"confirm": "Potwierdź",
"disable": "Wyłącz",
"disable_confirm_prompt": "Wprowadź hasło, aby wyłączyć uwierzytelnianie dwuskładnikowe.",
"password_required": "Hasło jest wymagane",
"code_required": "Kod weryfikacyjny jest wymagany",
"code_invalid": "Nieprawidłowy kod weryfikacyjny. Sprawdź aplikację uwierzytelniającą i spróbuj ponownie."
},
"app_passwords": {
"title": "Hasła aplikacji",
@@ -1088,7 +1104,25 @@
"removed": "Hasło aplikacji zostało usunięte",
"add_error": "Nie udało się utworzyć hasła aplikacji",
"remove_error": "Nie udało się usunąć hasła aplikacji",
"none": "Brak skonfigurowanych haseł aplikacji"
"none": "Brak skonfigurowanych haseł aplikacji",
"done": "Gotowe",
"expires_label": "Wygasa (opcjonalnie)",
"copy_now_warning": "Skopiuj to hasło teraz - nie zostanie ponownie wyświetlone.",
"allowed_ips_label": "Dozwolone adresy IP (opcjonalnie)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Oddzielone przecinkiem lub spacją. Pozostaw puste, aby zezwolić na dowolny IP."
},
"api_keys": {
"title": "Klucze API",
"description": "Twórz klucze API dla skryptów i integracji komunikujących się bezpośrednio z serwerem",
"name_label": "Nazwa klucza",
"name_placeholder": "np. Skrypt kopii zapasowej, CI runner",
"copy_now_warning": "Skopiuj ten klucz API teraz - nie zostanie pokazany ponownie.",
"added": "Klucz API utworzony",
"removed": "Klucz API usunięty",
"add_error": "Nie udało się utworzyć klucza API",
"remove_error": "Nie udało się usunąć klucza API",
"none": "Brak skonfigurowanych kluczy API"
},
"encryption": {
"section_title": "Szyfrowanie danych w spoczynku",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# warunek} other {# warunków}}",
"actions_count": "{count, plural, one {# akcja} other {# akcji}}"
}
},
"origin_external": "Zewnętrzny",
"managed_by_tooltip": "Zarządzane przez {source}. Edytuj w tej aplikacji lub użyj surowego edytora Sieve."
},
"templates": {
"title": "Szablony wiadomości e-mail",
@@ -1780,6 +1816,15 @@
"scheduling_uri": "Adres URL planowania",
"freebusy_uri": "Adres URL wolny/zajęty"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "Nowy kontakt",
"edit_title": "Edytuj kontakt",
@@ -1968,7 +2013,10 @@
"resize": "Zmień rozmiar wydarzenia",
"duplicate": "Duplikuj",
"today_header": "Dzisiaj",
"tomorrow_header": "Jutro"
"tomorrow_header": "Jutro",
"export_ics": "Eksportuj jako .ics",
"copy_title": "Kopiuj tytuł",
"copy_link": "Kopiuj link do spotkania"
},
"detail": {
"add_note": "Dodaj notatkę...",
@@ -2115,7 +2163,10 @@
"rsvp_error": "Nie udało się zaktualizować odpowiedzi",
"event_duplicated": "Wydarzenie zduplikowano",
"event_error": "Nie udało się zapisać wydarzenia",
"task_due": "Termin zadania"
"task_due": "Termin zadania",
"event_exported": "Wydarzenie wyeksportowane",
"title_copied": "Tytuł skopiowany",
"link_copied": "Link skopiowany"
},
"status": {
"loading_calendars": "Ładowanie kalendarzy...",
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "Pastas",
"mail": "E-mail",
"nav_label": "Navegação",
"add_app": "Apps"
"add_app": "Apps",
"shared": "Compartilhado"
},
"sidebar_apps": {
"modal_title": "Apps da barra lateral",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "Caixa de correio unificada",
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas"
},
"colorful_sidebar_icons": {
"label": "Ícones coloridos na barra lateral",
"description": "Colorir ícones de pastas e etiquetas por tipo (Caixa de entrada azul, Spam vermelho, Enviados verde, etc.). Desative para uma barra lateral monocromática."
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "Adicionar palavra-chave...",
"add": "Add",
"remove": "Remover"
},
"hide_inline_image_attachments": {
"label": "Ocultar imagens incorporadas dos anexos",
"description": "As imagens incorporadas no corpo da mensagem não são listadas como anexos separados"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "Autenticação de dois fatores desabilitada",
"enable_error": "Não foi possível habilitar a 2FA",
"disable_error": "Não foi possível desabilitar a 2FA",
"setup_instructions": "Copie esta URL para seu aplicativo de autenticação (Google Authenticator, Authy, etc.):"
"setup_instructions": "Copie esta URL para seu aplicativo de autenticação (Google Authenticator, Authy, etc.):",
"verification_code": "Código de verificação",
"confirm": "Confirmar",
"disable": "Desativar",
"disable_confirm_prompt": "Digite sua senha para desativar a autenticação de dois fatores.",
"password_required": "A senha é obrigatória",
"code_required": "O código de verificação é obrigatório",
"code_invalid": "Código de verificação inválido. Verifique seu aplicativo autenticador e tente novamente."
},
"app_passwords": {
"title": "Senhas de aplicativo",
@@ -1088,7 +1104,25 @@
"removed": "Senha de aplicativo removida",
"add_error": "Não foi possível criar a senha de aplicativo",
"remove_error": "Não foi possível remover a senha de aplicativo",
"none": "Nenhuma senha de aplicativo configurada"
"none": "Nenhuma senha de aplicativo configurada",
"done": "Concluído",
"expires_label": "Expira (opcional)",
"copy_now_warning": "Copie esta senha agora - ela não será exibida novamente.",
"allowed_ips_label": "IPs permitidos (opcional)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Separados por vírgula ou espaço. Deixe vazio para permitir qualquer IP."
},
"api_keys": {
"title": "Chaves de API",
"description": "Crie chaves de API para scripts e integrações que se comunicam diretamente com o servidor",
"name_label": "Nome da chave",
"name_placeholder": "ex. Script de backup, CI runner",
"copy_now_warning": "Copie esta chave de API agora - ela não será mostrada novamente.",
"added": "Chave de API criada",
"removed": "Chave de API removida",
"add_error": "Falha ao criar chave de API",
"remove_error": "Falha ao remover chave de API",
"none": "Nenhuma chave de API configurada"
},
"encryption": {
"section_title": "Criptografia em repouso",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condição} other {# condições}}",
"actions_count": "{count, plural, one {# ação} other {# ações}}"
}
},
"origin_external": "Externo",
"managed_by_tooltip": "Gerenciado por {source}. Edite nesse aplicativo ou use o editor Sieve bruto."
},
"templates": {
"title": "Modelos de e-mail",
@@ -1780,6 +1816,15 @@
"cert_imported": "Certificado importado",
"cert_import_failed": "Falha ao importar o certificado"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "Novo contato",
"edit_title": "Editar contato",
@@ -1968,7 +2013,10 @@
"resize": "Redimensionar evento",
"duplicate": "Duplicar",
"today_header": "Hoje",
"tomorrow_header": "Amanhã"
"tomorrow_header": "Amanhã",
"export_ics": "Exportar como .ics",
"copy_title": "Copiar título",
"copy_link": "Copiar link da reunião"
},
"detail": {
"add_note": "Adicionar uma nota...",
@@ -2115,7 +2163,10 @@
"rsvp_error": "Falha ao atualizar resposta",
"event_duplicated": "Evento duplicado",
"event_error": "Falha ao salvar o evento",
"task_due": "Tarefa vencendo"
"task_due": "Tarefa vencendo",
"event_exported": "Evento exportado",
"title_copied": "Título copiado",
"link_copied": "Link copiado"
},
"status": {
"loading_calendars": "Carregando calendários...",
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "Папки",
"mail": "Почта",
"nav_label": "Навигация",
"add_app": "Приложения"
"add_app": "Приложения",
"shared": "Общие"
},
"sidebar_apps": {
"modal_title": "Приложения боковой панели",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "Общий почтовый ящик",
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов"
},
"colorful_sidebar_icons": {
"label": "Цветные значки боковой панели",
"description": "Окрашивать значки папок и тегов по типу (синий «Входящие», красный «Спам», зелёный «Отправленные» и т. д.). Отключите для монохромной боковой панели."
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "Добавить ключевое слово...",
"add": "Add",
"remove": "Удалить"
},
"hide_inline_image_attachments": {
"label": "Скрывать встроенные изображения из вложений",
"description": "Изображения, встроенные в тело сообщения, не отображаются как отдельные вложения"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "Двухфакторная аутентификация отключена",
"enable_error": "Не удалось включить 2FA",
"disable_error": "Не удалось отключить 2FA",
"setup_instructions": "Скопируйте этот URL в приложение-аутентификатор (Google Authenticator, Authy и др.):"
"setup_instructions": "Скопируйте этот URL в приложение-аутентификатор (Google Authenticator, Authy и др.):",
"verification_code": "Код подтверждения",
"confirm": "Подтвердить",
"disable": "Отключить",
"disable_confirm_prompt": "Введите пароль, чтобы отключить двухфакторную аутентификацию.",
"password_required": "Требуется пароль",
"code_required": "Требуется код подтверждения",
"code_invalid": "Неверный код подтверждения. Проверьте приложение-аутентификатор и попробуйте снова."
},
"app_passwords": {
"title": "Пароли приложений",
@@ -1088,7 +1104,25 @@
"removed": "Пароль приложения удалён",
"add_error": "Не удалось создать пароль приложения",
"remove_error": "Не удалось удалить пароль приложения",
"none": "Пароли приложений не настроены"
"none": "Пароли приложений не настроены",
"done": "Готово",
"expires_label": "Срок действия (необязательно)",
"copy_now_warning": "Скопируйте этот пароль сейчас - он больше не будет показан.",
"allowed_ips_label": "Разрешённые IP (необязательно)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Через запятую или пробел. Оставьте пустым, чтобы разрешить любой IP."
},
"api_keys": {
"title": "API-ключи",
"description": "Создавайте API-ключи для скриптов и интеграций, обращающихся к серверу напрямую",
"name_label": "Название ключа",
"name_placeholder": "напр. Скрипт резервного копирования, CI runner",
"copy_now_warning": "Скопируйте этот API-ключ сейчас — он больше не будет показан.",
"added": "API-ключ создан",
"removed": "API-ключ удалён",
"add_error": "Не удалось создать API-ключ",
"remove_error": "Не удалось удалить API-ключ",
"none": "API-ключи не настроены"
},
"encryption": {
"section_title": "Шифрование хранилища",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# условие} other {# условий}}",
"actions_count": "{count, plural, one {# действие} other {# действий}}"
}
},
"origin_external": "Внешнее",
"managed_by_tooltip": "Управляется {source}. Редактируйте в этом приложении или используйте редактор Sieve."
},
"templates": {
"title": "Шаблоны писем",
@@ -1780,6 +1816,15 @@
"scheduling_uri": "URL планирования",
"freebusy_uri": "URL доступности"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "Новый контакт",
"edit_title": "Редактировать контакт",
@@ -1968,7 +2013,10 @@
"resize": "Изменить размер события",
"duplicate": "Дублировать",
"today_header": "Сегодня",
"tomorrow_header": "Завтра"
"tomorrow_header": "Завтра",
"export_ics": "Экспорт в .ics",
"copy_title": "Скопировать название",
"copy_link": "Скопировать ссылку встречи"
},
"detail": {
"add_note": "Добавить заметку...",
@@ -2115,7 +2163,10 @@
"rsvp_error": "Не удалось обновить ответ",
"event_duplicated": "Событие дублировано",
"event_error": "Не удалось сохранить событие",
"task_due": "Срок задачи"
"task_due": "Срок задачи",
"event_exported": "Событие экспортировано",
"title_copied": "Название скопировано",
"link_copied": "Ссылка скопирована"
},
"status": {
"loading_calendars": "Загрузка календарей...",
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "Папки",
"mail": "Пошта",
"nav_label": "Навігація",
"add_app": "програми"
"add_app": "програми",
"shared": "Спільні"
},
"sidebar_apps": {
"modal_title": "Програми бічної панелі",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "Спільна поштова скринька",
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів"
},
"colorful_sidebar_icons": {
"label": "Кольорові значки бічної панелі",
"description": "Забарвлюйте значки папок і тегів за типом (синя «Вхідні», червоний «Спам», зелена «Надіслані» тощо). Вимкніть для монохромної бічної панелі."
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "Додати ключове слово...",
"add": "додати",
"remove": "видалити"
},
"hide_inline_image_attachments": {
"label": "Приховувати вбудовані зображення з вкладень",
"description": "Зображення, вбудовані в тіло повідомлення, не відображаються як окремі вкладення"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "Двофакторну автентифікацію вимкнено",
"enable_error": "Не вдалося ввімкнути 2FA",
"disable_error": "Не вдалося вимкнути 2FA",
"setup_instructions": "Скопіюйте цю URL-адресу в програму автентифікації (Google Authenticator, Authy тощо):"
"setup_instructions": "Скопіюйте цю URL-адресу в програму автентифікації (Google Authenticator, Authy тощо):",
"verification_code": "Код підтвердження",
"confirm": "Підтвердити",
"disable": "Вимкнути",
"disable_confirm_prompt": "Введіть пароль, щоб вимкнути двофакторну автентифікацію.",
"password_required": "Потрібен пароль",
"code_required": "Потрібен код підтвердження",
"code_invalid": "Недійсний код підтвердження. Перевірте додаток автентифікації та спробуйте ще раз."
},
"app_passwords": {
"title": "Паролі програм",
@@ -1088,7 +1104,25 @@
"removed": "Пароль програми видалено",
"add_error": "Не вдалося створити пароль програми",
"remove_error": "Не вдалося видалити пароль програми",
"none": "Паролі програм не налаштовано"
"none": "Паролі програм не налаштовано",
"done": "Готово",
"expires_label": "Термін дії (необов’язково)",
"copy_now_warning": "Скопіюйте цей пароль зараз - він більше не буде показаний.",
"allowed_ips_label": "Дозволені IP (необов’язково)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "Через кому або пробіл. Залиште порожнім, щоб дозволити будь-який IP."
},
"api_keys": {
"title": "API-ключі",
"description": "Створюйте API-ключі для скриптів та інтеграцій, які звертаються до сервера напряму",
"name_label": "Назва ключа",
"name_placeholder": "напр. Скрипт резервної копії, CI runner",
"copy_now_warning": "Скопіюйте цей API-ключ зараз — він більше не буде показаний.",
"added": "API-ключ створено",
"removed": "API-ключ видалено",
"add_error": "Не вдалося створити API-ключ",
"remove_error": "Не вдалося видалити API-ключ",
"none": "API-ключі не налаштовано"
},
"encryption": {
"section_title": "Шифрування в спокої",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# умова} few {# умови} many {# умов} other {# умов}}",
"actions_count": "{count, plural, one {# дія} few {# дії} many {# дій} other {# дій}}"
}
},
"origin_external": "Зовнішнє",
"managed_by_tooltip": "Керується {source}. Редагуйте в тому додатку або використовуйте редактор Sieve."
},
"templates": {
"title": "Шаблони електронної пошти",
@@ -1780,6 +1816,15 @@
"scheduling_uri": "URL-адреса планування",
"freebusy_uri": "Вільний/зайнятий URL"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "Новий контакт",
"edit_title": "Редагувати контакт",
@@ -1968,7 +2013,10 @@
"resize": "Змінити розмір події",
"duplicate": "дублікат",
"today_header": "Сьогодні",
"tomorrow_header": "завтра"
"tomorrow_header": "завтра",
"export_ics": "Експортувати як .ics",
"copy_title": "Скопіювати назву",
"copy_link": "Скопіювати посилання зустрічі"
},
"detail": {
"add_note": "Додати примітку...",
@@ -2115,7 +2163,10 @@
"rsvp_error": "Не вдалося оновити відповідь",
"event_duplicated": "Подія дублюється",
"event_error": "Не вдалося зберегти подію",
"task_due": "Термін виконання завдання"
"task_due": "Термін виконання завдання",
"event_exported": "Подію експортовано",
"title_copied": "Назву скопійовано",
"link_copied": "Посилання скопійовано"
},
"status": {
"loading_calendars": "Завантаження календарів...",
+57 -6
View File
@@ -129,7 +129,8 @@
"folders": "文件夹",
"mail": "邮件",
"nav_label": "导航",
"add_app": "应用"
"add_app": "应用",
"shared": "共享"
},
"sidebar_apps": {
"modal_title": "侧边栏应用",
@@ -733,6 +734,10 @@
"unified_mailbox": {
"label": "统一邮箱",
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)"
},
"colorful_sidebar_icons": {
"label": "彩色侧边栏图标",
"description": "按类型为文件夹和标签图标着色(蓝色收件箱、红色垃圾邮件、绿色已发送等)。禁用以获得单色侧边栏。"
}
},
"keywords": {
@@ -961,6 +966,10 @@
"add_placeholder": "添加关键词...",
"add": "Add",
"remove": "删除"
},
"hide_inline_image_attachments": {
"label": "在附件列表中隐藏内嵌图片",
"description": "嵌入到邮件正文中的图片不会作为单独的附件显示"
}
},
"composer": {
@@ -1071,7 +1080,14 @@
"disabled": "禁用双因素身份验证",
"enable_error": "无法启用 2FA",
"disable_error": "无法禁用 2FA",
"setup_instructions": "请将此 URL 复制到身份验证器应用(Google Authenticator、Authy 等)中:"
"setup_instructions": "请将此 URL 复制到身份验证器应用(Google Authenticator、Authy 等)中:",
"verification_code": "验证码",
"confirm": "确认",
"disable": "禁用",
"disable_confirm_prompt": "输入密码以禁用双重身份验证。",
"password_required": "需要密码",
"code_required": "需要验证码",
"code_invalid": "验证码无效。请检查您的身份验证应用并重试。"
},
"app_passwords": {
"title": "应用密码",
@@ -1088,7 +1104,25 @@
"removed": "应用密码已删除",
"add_error": "创建应用密码失败",
"remove_error": "无法删除应用密码",
"none": "未配置应用密码"
"none": "未配置应用密码",
"done": "完成",
"expires_label": "过期时间(可选)",
"copy_now_warning": "立即复制此密码--它将不再显示。",
"allowed_ips_label": "允许的 IP(可选)",
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
"allowed_ips_hint": "用逗号或空格分隔。留空则允许任意 IP。"
},
"api_keys": {
"title": "API 密钥",
"description": "为直接与服务器通信的脚本和集成创建 API 密钥",
"name_label": "密钥名称",
"name_placeholder": "如:备份脚本、CI runner",
"copy_now_warning": "请立即复制此 API 密钥 - 它将不会再次显示。",
"added": "API 密钥已创建",
"removed": "API 密钥已删除",
"add_error": "创建 API 密钥失败",
"remove_error": "删除 API 密钥失败",
"none": "未配置 API 密钥"
},
"encryption": {
"section_title": "静态加密",
@@ -1411,7 +1445,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# 个条件} other {# 个条件}}",
"actions_count": "{count, plural, one {# 个操作} other {# 个操作}}"
}
},
"origin_external": "外部",
"managed_by_tooltip": "由 {source} 管理。请在该应用中编辑,或使用原始 Sieve 编辑器。"
},
"templates": {
"title": "邮件模板",
@@ -1780,6 +1816,15 @@
"scheduling_uri": "调度 URL",
"freebusy_uri": "空闲/忙碌 URL"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
},
"form": {
"create_title": "新联系人",
"edit_title": "编辑联系人",
@@ -1968,7 +2013,10 @@
"resize": "调整事件大小",
"duplicate": "复制",
"today_header": "今天",
"tomorrow_header": "明天"
"tomorrow_header": "明天",
"export_ics": "导出为 .ics",
"copy_title": "复制标题",
"copy_link": "复制会议链接"
},
"detail": {
"add_note": "添加注释...",
@@ -2115,7 +2163,10 @@
"rsvp_error": "无法更新回复",
"event_duplicated": "活动已复制",
"event_error": "无法保存活动",
"task_due": "任务到期"
"task_due": "任务到期",
"event_exported": "日程已导出",
"title_copied": "标题已复制",
"link_copied": "链接已复制"
},
"status": {
"loading_calendars": "正在加载日历...",
+279 -8
View File
@@ -28,9 +28,11 @@
"lucide-react": "^0.575.0",
"next": "^16.1.5",
"next-intl": "^4.5.8",
"otpauth": "^9.5.0",
"pkijs": "^3.3.3",
"postal-mime": "^2.7.4",
"pvtsutils": "^1.3.6",
"qrcode": "^1.5.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sonner": "^2.0.7",
@@ -46,6 +48,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^25.2.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0",
@@ -2061,10 +2064,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
@@ -4063,6 +4063,16 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -4535,7 +4545,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4545,7 +4554,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -4934,6 +4942,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001772",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001772.tgz",
@@ -4987,6 +5004,17 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -5000,7 +5028,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -5013,7 +5040,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
@@ -5217,6 +5243,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -5295,6 +5330,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -5361,6 +5402,12 @@
"minimalistic-crypto-utils": "^1.0.1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.20.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
@@ -6171,6 +6218,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -6741,6 +6797,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -8024,6 +8089,18 @@
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/otpauth": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz",
"integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"funding": {
"url": "https://github.com/hectorm/otpauth?sponsor=1"
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -8074,6 +8151,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@@ -8110,7 +8196,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8219,6 +8304,15 @@
"node": ">=18"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/po-parser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
@@ -8565,6 +8659,23 @@
"node": ">=16.0.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -8682,6 +8793,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -8692,6 +8812,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -8846,6 +8972,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -9123,6 +9255,20 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@@ -9221,6 +9367,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -10050,6 +10208,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
@@ -10099,6 +10263,20 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
@@ -10116,6 +10294,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -10123,6 +10307,93 @@
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+3
View File
@@ -51,9 +51,11 @@
"lucide-react": "^0.575.0",
"next": "^16.1.5",
"next-intl": "^4.5.8",
"otpauth": "^9.5.0",
"pkijs": "^3.3.3",
"postal-mime": "^2.7.4",
"pvtsutils": "^1.3.6",
"qrcode": "^1.5.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sonner": "^2.0.7",
@@ -69,6 +71,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^25.2.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0",
+2 -1
View File
@@ -23,11 +23,12 @@ export function proxy(request: NextRequest) {
`img-src 'self' data: blob: https:`,
`font-src 'self'`,
`connect-src ${connectSrc}`,
`frame-src 'none'`,
`frame-src 'self' blob:`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors ${frameAncestors}`,
`media-src 'self' blob:`,
].join("; ");
// Skip intl middleware for /admin routes - they have their own layout
-68
View File
@@ -1,68 +0,0 @@
{
"name": "Bulwark Webmail",
"short_name": "Bulwark",
"description": "A modern webmail client built for Stalwart Mail Server",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-light-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable",
"media": "(prefers-color-scheme: light)"
},
{
"src": "/icon-maskable-light-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable",
"media": "(prefers-color-scheme: light)"
},
{
"src": "/icon-maskable-dark-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable",
"media": "(prefers-color-scheme: dark)"
},
{
"src": "/icon-maskable-dark-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable",
"media": "(prefers-color-scheme: dark)"
}
],
"categories": ["productivity"],
"screenshots": [
{
"src": "/screenshot-540x720.png",
"sizes": "540x720",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "/screenshot-1280x720.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}
+281 -286
View File
@@ -1,423 +1,418 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useAccountSecurityStore } from '../account-security-store';
import { describe, it, expect, vi, beforeEach } from 'vitest';
function mockFetchResponse(status: number, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: { 'Content-Type': 'application/json' },
});
vi.mock('@/lib/stalwart/jmap-passthrough', () => ({
stalwartJmap: vi.fn(),
requireResult: <T,>(responses: Array<[string, unknown, string]>, method: string): T => {
const match = responses.find(r => r[0] === method);
if (!match) throw new Error(`Missing ${method}`);
return match[1] as T;
},
}));
vi.mock('@/stores/auth-store', () => ({
useAuthStore: {
getState: () => ({
client: {
getAccountId: () => 'acc-primary',
hasAccountCapability: (cap: string) => cap === 'urn:stalwart:jmap',
},
}),
},
}));
import { useAccountSecurityStore } from '../account-security-store';
import { stalwartJmap } from '@/lib/stalwart/jmap-passthrough';
const mockedJmap = stalwartJmap as unknown as ReturnType<typeof vi.fn>;
function resetStore() {
useAccountSecurityStore.getState().clearState();
}
const defaultState = {
isStalwart: null,
isProbing: false,
otpEnabled: false,
appPasswords: [],
isLoadingAuth: false,
encryptionType: 'disabled',
isLoadingCrypto: false,
displayName: '',
emails: [],
quota: 0,
roles: [],
isLoadingPrincipal: false,
isSaving: false,
error: null,
};
describe('AccountSecurityStore', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
describe('account-security-store', () => {
beforeEach(() => {
useAccountSecurityStore.setState(defaultState);
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
mockedJmap.mockReset();
resetStore();
});
describe('probe', () => {
it('sets isStalwart to true when probe succeeds', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { isStalwart: true }));
const result = await useAccountSecurityStore.getState().probe();
expect(result).toBe(true);
it('sets isStalwart=true when the account has the urn:stalwart:jmap capability', async () => {
const ok = await useAccountSecurityStore.getState().probe();
expect(ok).toBe(true);
expect(useAccountSecurityStore.getState().isStalwart).toBe(true);
expect(useAccountSecurityStore.getState().isProbing).toBe(false);
});
it('sets isStalwart to false when probe returns false', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { isStalwart: false }));
const result = await useAccountSecurityStore.getState().probe();
expect(result).toBe(false);
expect(useAccountSecurityStore.getState().isStalwart).toBe(false);
});
it('sets isStalwart to false on network error', async () => {
fetchSpy.mockRejectedValueOnce(new TypeError('Network error'));
const result = await useAccountSecurityStore.getState().probe();
expect(result).toBe(false);
expect(useAccountSecurityStore.getState().isStalwart).toBe(false);
expect(useAccountSecurityStore.getState().isProbing).toBe(false);
});
});
describe('fetchAuthInfo', () => {
it('populates auth info on success', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { otpEnabled: true, appPasswords: ['app1', 'app2'] } })
);
it('reports TOTP enabled when AccountPassword singleton has otpUrl', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ id: 'singleton', otpAuth: { otpUrl: 'otpauth://totp/x' } }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
['x:ApiKey/query', { ids: [] }, '2'],
]);
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.otpEnabled).toBe(true);
expect(state.appPasswords).toEqual(['app1', 'app2']);
expect(state.isLoadingAuth).toBe(false);
expect(state.error).toBeNull();
expect(useAccountSecurityStore.getState().otpEnabled).toBe(true);
expect(useAccountSecurityStore.getState().appPasswords).toEqual([]);
expect(useAccountSecurityStore.getState().apiKeys).toEqual([]);
});
it('sets defaults when data fields are missing', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
it('reports TOTP disabled when otpAuth is empty', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ id: 'singleton', otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
['x:ApiKey/query', { ids: [] }, '2'],
]);
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.otpEnabled).toBe(false);
expect(state.appPasswords).toEqual([]);
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
});
it('sets error on HTTP failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500));
it('resolves app password and api key rows via a single follow-up batch when queries return ids', async () => {
mockedJmap
.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: ['p1'] }, '1'],
['x:ApiKey/query', { ids: ['k1'] }, '2'],
])
.mockResolvedValueOnce([
['x:AppPassword/get', {
list: [{
id: 'p1',
description: 'Thunderbird',
createdAt: '2026-01-01T00:00:00Z',
expiresAt: null,
allowedIps: { '10.0.0.1': true },
}],
}, 'app'],
['x:ApiKey/get', {
list: [{
id: 'k1',
description: 'CI bot',
createdAt: '2026-02-01T00:00:00Z',
expiresAt: '2027-01-01T00:00:00Z',
allowedIps: {},
}],
}, 'key'],
]);
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.isLoadingAuth).toBe(false);
expect(state.error).toBe('HTTP 500');
const pw = useAccountSecurityStore.getState().appPasswords[0];
expect(pw).toMatchObject({
id: 'p1',
description: 'Thunderbird',
createdAt: '2026-01-01T00:00:00Z',
expiresAt: null,
allowedIps: ['10.0.0.1'],
});
const k = useAccountSecurityStore.getState().apiKeys[0];
expect(k).toMatchObject({
id: 'k1',
description: 'CI bot',
expiresAt: '2027-01-01T00:00:00Z',
allowedIps: [],
});
expect(mockedJmap).toHaveBeenCalledTimes(2);
});
it('sets error on network failure', async () => {
fetchSpy.mockRejectedValueOnce(new Error('Connection refused'));
it('records error on failure and clears loading flag', async () => {
mockedJmap.mockRejectedValueOnce(new Error('boom'));
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.isLoadingAuth).toBe(false);
expect(state.error).toBe('Connection refused');
expect(useAccountSecurityStore.getState().isLoadingAuth).toBe(false);
expect(useAccountSecurityStore.getState().error).toBe('boom');
});
});
describe('fetchCryptoInfo', () => {
it('populates crypto info on success', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { type: 'pgp' } })
);
it('reads encryption type from encryptionAtRest.@type', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountSettings/get', { list: [{ encryptionAtRest: { '@type': 'Aes256' } }] }, '0'],
]);
await useAccountSecurityStore.getState().fetchCryptoInfo();
const state = useAccountSecurityStore.getState();
expect(state.encryptionType).toBe('pgp');
expect(state.isLoadingCrypto).toBe(false);
expect(useAccountSecurityStore.getState().encryptionType).toBe('Aes256');
});
it('defaults to disabled when type is missing', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
it('defaults to Disabled when @type is missing or unknown', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountSettings/get', { list: [{ encryptionAtRest: null }] }, '0'],
]);
await useAccountSecurityStore.getState().fetchCryptoInfo();
expect(useAccountSecurityStore.getState().encryptionType).toBe('disabled');
});
it('sets error on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403));
await useAccountSecurityStore.getState().fetchCryptoInfo();
expect(useAccountSecurityStore.getState().error).toBe('HTTP 403');
expect(useAccountSecurityStore.getState().encryptionType).toBe('Disabled');
});
});
describe('fetchPrincipal', () => {
it('populates principal info on success', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, {
data: {
description: 'John Doe',
emails: ['john@example.com', 'doe@example.com'],
quota: 5000000,
roles: ['user', 'admin'],
},
})
);
it('combines primary name with enabled aliases and exposes quota/roles', async () => {
mockedJmap.mockResolvedValueOnce([
['x:Account/get', {
list: [{
name: 'user@example.com',
description: 'Display User',
aliases: {
a1: { name: 'alias1@example.com', enabled: true },
a2: { name: 'alias2@example.com', enabled: false },
a3: { name: 'alias3@example.com', enabled: true },
},
quotas: { maxDiskQuota: 5_000_000 },
roles: { '@type': 'User' },
}],
}, '0'],
]);
await useAccountSecurityStore.getState().fetchPrincipal();
const state = useAccountSecurityStore.getState();
expect(state.displayName).toBe('John Doe');
expect(state.emails).toEqual(['john@example.com', 'doe@example.com']);
expect(state.quota).toBe(5000000);
expect(state.roles).toEqual(['user', 'admin']);
expect(state.isLoadingPrincipal).toBe(false);
expect(state.displayName).toBe('Display User');
expect(state.emails).toEqual(['user@example.com', 'alias1@example.com', 'alias3@example.com']);
expect(state.quota).toBe(5_000_000);
expect(state.roles).toEqual(['User']);
});
it('handles single email string as array', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, {
data: { description: 'User', emails: 'single@example.com', quota: 0, roles: [] },
})
);
it('swallows forbidden errors (non-admins cannot read their own Account) without setting error', async () => {
mockedJmap.mockRejectedValueOnce(new Error('Forbidden: missing sysAccountGet permission'));
await useAccountSecurityStore.getState().fetchPrincipal();
expect(useAccountSecurityStore.getState().emails).toEqual(['single@example.com']);
expect(useAccountSecurityStore.getState().isLoadingPrincipal).toBe(false);
expect(useAccountSecurityStore.getState().error).toBeNull();
});
it('handles missing emails gracefully', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { description: 'User' } })
);
it('records non-forbidden errors', async () => {
mockedJmap.mockRejectedValueOnce(new Error('network down'));
await useAccountSecurityStore.getState().fetchPrincipal();
expect(useAccountSecurityStore.getState().emails).toEqual([]);
});
it('sets defaults when fields are missing', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
await useAccountSecurityStore.getState().fetchPrincipal();
const state = useAccountSecurityStore.getState();
expect(state.displayName).toBe('');
expect(state.emails).toEqual([]);
expect(state.quota).toBe(0);
expect(state.roles).toEqual([]);
});
});
describe('fetchAll', () => {
it('calls all three fetch methods in parallel', async () => {
fetchSpy
.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: [] } }))
.mockResolvedValueOnce(mockFetchResponse(200, { data: { type: 'smime' } }))
.mockResolvedValueOnce(mockFetchResponse(200, { data: { description: 'Test', emails: [], quota: 0, roles: [] } }));
await useAccountSecurityStore.getState().fetchAll();
const state = useAccountSecurityStore.getState();
expect(state.encryptionType).toBe('smime');
expect(state.displayName).toBe('Test');
expect(state.isLoadingAuth).toBe(false);
expect(state.isLoadingCrypto).toBe(false);
expect(state.isLoadingPrincipal).toBe(false);
});
it('continues even if one fetch fails', async () => {
fetchSpy
.mockResolvedValueOnce(mockFetchResponse(500)) // auth fails
.mockResolvedValueOnce(mockFetchResponse(200, { data: { type: 'pgp' } }))
.mockResolvedValueOnce(mockFetchResponse(200, { data: { description: 'OK', emails: [], quota: 0, roles: [] } }));
await useAccountSecurityStore.getState().fetchAll();
const state = useAccountSecurityStore.getState();
expect(state.encryptionType).toBe('pgp');
expect(state.displayName).toBe('OK');
expect(useAccountSecurityStore.getState().error).toBe('network down');
});
});
describe('changePassword', () => {
it('sends POST with currentPassword and newPassword', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { ok: true }));
it('calls x:AccountPassword/set with currentSecret and secret', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountPassword/set', { updated: { singleton: null } }, '0'],
]);
await useAccountSecurityStore.getState().changePassword('oldpass', 'newpass123');
await useAccountSecurityStore.getState().changePassword('old', 'new');
expect(fetchSpy).toHaveBeenCalledWith('/api/account/stalwart/password', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ currentPassword: 'oldpass', newPassword: 'newpass123' }),
}));
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
const calls = mockedJmap.mock.calls[0][0];
expect(calls).toEqual([[
'x:AccountPassword/set',
{
accountId: 'acc-primary',
update: { singleton: { currentSecret: 'old', secret: 'new' } },
},
'0',
]]);
});
it('throws and sets error on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403, { error: 'Current password is incorrect' }));
await expect(
useAccountSecurityStore.getState().changePassword('wrong', 'newpass123')
).rejects.toThrow('Current password is incorrect');
it('propagates errors and records state', async () => {
mockedJmap.mockRejectedValueOnce(new Error('forbidden'));
await expect(useAccountSecurityStore.getState().changePassword('x', 'y')).rejects.toThrow('forbidden');
expect(useAccountSecurityStore.getState().error).toBe('forbidden');
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
expect(useAccountSecurityStore.getState().error).toBe('Current password is incorrect');
});
});
describe('updateDisplayName', () => {
it('sends PATCH and updates local state on success', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
it('patches AccountSettings.description and updates local state', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountSettings/set', { updated: { singleton: null } }, '0'],
]);
await useAccountSecurityStore.getState().updateDisplayName('New Name');
const state = useAccountSecurityStore.getState();
expect(state.displayName).toBe('New Name');
expect(state.isSaving).toBe(false);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'description', value: 'New Name' }]);
});
it('throws and sets error on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Server error' }));
await expect(
useAccountSecurityStore.getState().updateDisplayName('Name')
).rejects.toThrow('Server error');
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
expect(useAccountSecurityStore.getState().displayName).toBe('New Name');
const args = mockedJmap.mock.calls[0][0][0][1];
expect(args).toEqual({ accountId: 'acc-primary', update: { singleton: { description: 'New Name' } } });
});
});
describe('enableTotp', () => {
it('sends enableOtpAuth and returns TOTP URL', async () => {
const totpUrl = 'otpauth://totp/user@example.com?secret=ABC';
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: totpUrl }));
describe('enableTotp / disableTotp', () => {
it('enableTotp sends currentSecret + otpAuth.otpUrl + otpCode', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountPassword/set', { updated: { singleton: null } }, '0'],
]);
const result = await useAccountSecurityStore.getState().enableTotp();
await useAccountSecurityStore.getState().enableTotp('pw', 'otpauth://totp/x?secret=S', '123456');
expect(result).toBe(totpUrl);
expect(useAccountSecurityStore.getState().otpEnabled).toBe(true);
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ type: 'enableOtpAuth' }]);
const args = mockedJmap.mock.calls[0][0][0][1];
expect(args.update.singleton).toEqual({
currentSecret: 'pw',
otpAuth: { otpUrl: 'otpauth://totp/x?secret=S', otpCode: '123456' },
});
});
it('throws and preserves otpEnabled=false on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { error: 'TOTP error' }));
await expect(
useAccountSecurityStore.getState().enableTotp()
).rejects.toThrow('TOTP error');
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
});
});
describe('disableTotp', () => {
it('sends disableOtpAuth and sets otpEnabled to false', async () => {
it('disableTotp clears otpUrl', async () => {
useAccountSecurityStore.setState({ otpEnabled: true });
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
mockedJmap.mockResolvedValueOnce([
['x:AccountPassword/set', { updated: { singleton: null } }, '0'],
]);
await useAccountSecurityStore.getState().disableTotp();
await useAccountSecurityStore.getState().disableTotp('pw');
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
const args = mockedJmap.mock.calls[0][0][0][1];
expect(args.update.singleton).toEqual({ currentSecret: 'pw', otpAuth: { otpUrl: null } });
});
});
describe('addAppPassword', () => {
it('sends addAppPassword and refreshes auth info', async () => {
// First call: POST addAppPassword
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
// Second call: fetchAuthInfo refresh
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: ['Thunderbird'] } })
);
describe('createAppPassword', () => {
it('returns the server-generated id and secret then refreshes auth info', async () => {
mockedJmap
.mockResolvedValueOnce([
['x:AppPassword/set', { created: { new: { id: 'p-new', secret: 'S3CR3T' } } }, '0'],
])
.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
['x:ApiKey/query', { ids: [] }, '2'],
]);
await useAccountSecurityStore.getState().addAppPassword('Thunderbird', 'secret');
const result = await useAccountSecurityStore
.getState()
.createAppPassword({ description: 'CLI', expiresAt: '2026-12-01T00:00:00Z', allowedIps: ['10.0.0.1', '192.168.1.0/24'] });
const state = useAccountSecurityStore.getState();
expect(state.appPasswords).toEqual(['Thunderbird']);
expect(state.isSaving).toBe(false);
expect(result).toEqual({ id: 'p-new', secret: 'S3CR3T' });
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ type: 'addAppPassword', name: 'Thunderbird', password: 'secret' }]);
const createArgs = mockedJmap.mock.calls[0][0][0][1];
expect(createArgs.create.new).toEqual({
description: 'CLI',
expiresAt: '2026-12-01T00:00:00Z',
allowedIps: { '10.0.0.1': true, '192.168.1.0/24': true },
});
expect(mockedJmap).toHaveBeenCalledTimes(2);
});
it('throws on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Server down' }));
it('omits allowedIps when none provided', async () => {
mockedJmap
.mockResolvedValueOnce([
['x:AppPassword/set', { created: { new: { id: 'p', secret: 's' } } }, '0'],
])
.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
['x:ApiKey/query', { ids: [] }, '2'],
]);
await useAccountSecurityStore.getState().createAppPassword({ description: 'CLI' });
const createArgs = mockedJmap.mock.calls[0][0][0][1];
expect(createArgs.create.new).toEqual({ description: 'CLI' });
});
it('throws with server-provided description when notCreated is returned', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AppPassword/set', { notCreated: { new: { type: 'invalidProperties', description: 'description too short' } } }, '0'],
]);
await expect(
useAccountSecurityStore.getState().addAppPassword('App', 'pass')
).rejects.toThrow('Server down');
useAccountSecurityStore.getState().createAppPassword({ description: 'x' })
).rejects.toThrow('description too short');
});
it('throws when the server does not return a secret', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AppPassword/set', { created: { new: { id: 'p' } } }, '0'],
]);
await expect(
useAccountSecurityStore.getState().createAppPassword({ description: 'x' })
).rejects.toThrow(/did not return/i);
});
});
describe('removeAppPassword', () => {
it('sends removeAppPassword and refreshes auth info', async () => {
useAccountSecurityStore.setState({ appPasswords: ['Thunderbird', 'iPhone'] });
it('calls AppPassword/set with destroy and refreshes auth info', async () => {
mockedJmap
.mockResolvedValueOnce([['x:AppPassword/set', { destroyed: ['p1'] }, '0']])
.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
['x:ApiKey/query', { ids: [] }, '2'],
]);
// First call: POST removeAppPassword
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
// Second call: fetchAuthInfo refresh
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: ['iPhone'] } })
);
await useAccountSecurityStore.getState().removeAppPassword('p1');
await useAccountSecurityStore.getState().removeAppPassword('Thunderbird');
expect(useAccountSecurityStore.getState().appPasswords).toEqual(['iPhone']);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ type: 'removeAppPassword', name: 'Thunderbird' }]);
const args = mockedJmap.mock.calls[0][0][0][1];
expect(args).toEqual({ accountId: 'acc-primary', destroy: ['p1'] });
expect(mockedJmap).toHaveBeenCalledTimes(2);
});
});
describe('updateEncryption', () => {
it('sends crypto settings and updates local encryptionType', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
describe('createApiKey / removeApiKey', () => {
it('routes through x:ApiKey/set and refreshes auth info', async () => {
mockedJmap
.mockResolvedValueOnce([
['x:ApiKey/set', { created: { new: { id: 'k1', secret: 'API_KEY' } } }, '0'],
])
.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
['x:ApiKey/query', { ids: [] }, '2'],
]);
await useAccountSecurityStore.getState().updateEncryption({ type: 'pgp' });
const result = await useAccountSecurityStore.getState().createApiKey({ description: 'bot', allowedIps: ['127.0.0.1'] });
expect(useAccountSecurityStore.getState().encryptionType).toBe('pgp');
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
expect(result).toEqual({ id: 'k1', secret: 'API_KEY' });
const createArgs = mockedJmap.mock.calls[0][0][0][1];
expect(createArgs.create.new).toEqual({ description: 'bot', allowedIps: { '127.0.0.1': true } });
});
it('throws on failure without changing encryptionType', async () => {
useAccountSecurityStore.setState({ encryptionType: 'disabled' });
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Encryption error' }));
it('removes via x:ApiKey/set destroy', async () => {
mockedJmap
.mockResolvedValueOnce([['x:ApiKey/set', { destroyed: ['k1'] }, '0']])
.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
['x:ApiKey/query', { ids: [] }, '2'],
]);
await expect(
useAccountSecurityStore.getState().updateEncryption({ type: 'pgp' })
).rejects.toThrow('Encryption error');
await useAccountSecurityStore.getState().removeApiKey('k1');
expect(useAccountSecurityStore.getState().encryptionType).toBe('disabled');
const args = mockedJmap.mock.calls[0][0][0][1];
expect(args).toEqual({ accountId: 'acc-primary', destroy: ['k1'] });
});
});
describe('clearState', () => {
it('resets all state to defaults', () => {
it('resets all derived fields back to defaults', () => {
useAccountSecurityStore.setState({
isStalwart: true,
otpEnabled: true,
appPasswords: ['app1'],
encryptionType: 'pgp',
displayName: 'Test User',
emails: ['test@example.com'],
quota: 5000000,
roles: ['admin'],
error: 'some error',
appPasswords: [{ id: 'p', description: 'd', createdAt: null, expiresAt: null, allowedIps: [] }],
apiKeys: [{ id: 'k', description: 'd', createdAt: null, expiresAt: null, allowedIps: [] }],
encryptionType: 'Aes256',
displayName: 'user',
emails: ['a@b'],
quota: 10,
roles: ['User'],
error: 'x',
});
useAccountSecurityStore.getState().clearState();
const state = useAccountSecurityStore.getState();
expect(state.isStalwart).toBeNull();
expect(state.isProbing).toBe(false);
expect(state.otpEnabled).toBe(false);
expect(state.appPasswords).toEqual([]);
expect(state.encryptionType).toBe('disabled');
expect(state.apiKeys).toEqual([]);
expect(state.encryptionType).toBe('Disabled');
expect(state.displayName).toBe('');
expect(state.emails).toEqual([]);
expect(state.quota).toBe(0);
expect(state.roles).toEqual([]);
expect(state.isSaving).toBe(false);
expect(state.error).toBeNull();
});
});
+295 -186
View File
@@ -1,51 +1,183 @@
import { create } from 'zustand';
import { debug } from '@/lib/debug';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { apiFetch } from '@/lib/browser-navigation';
import { useAuthStore } from '@/stores/auth-store';
import { stalwartJmap, requireResult } from '@/lib/stalwart/jmap-passthrough';
export type EncryptionType = 'Disabled' | 'Aes128' | 'Aes256';
export interface AppPasswordInfo {
id: string;
description: string;
createdAt: string | null;
expiresAt: string | null;
allowedIps: string[];
}
export interface ApiKeyInfo {
id: string;
description: string;
createdAt: string | null;
expiresAt: string | null;
allowedIps: string[];
}
export interface AppCredentialInput {
description: string;
expiresAt?: string | null;
allowedIps?: string[];
}
interface AccountSecurityState {
// Detection
isStalwart: boolean | null; // null = not yet probed
isStalwart: boolean | null;
isProbing: boolean;
// Auth info
otpEnabled: boolean;
appPasswords: string[];
appPasswords: AppPasswordInfo[];
apiKeys: ApiKeyInfo[];
isLoadingAuth: boolean;
// Crypto info
encryptionType: string;
// Encryption-at-rest
encryptionType: EncryptionType;
isLoadingCrypto: boolean;
// Principal info
// Profile
displayName: string;
emails: string[];
quota: number;
roles: string[];
isLoadingPrincipal: boolean;
// Operation states
isSaving: boolean;
error: string | null;
// Actions
probe: () => Promise<boolean>;
fetchAuthInfo: () => Promise<void>;
fetchCryptoInfo: () => Promise<void>;
fetchPrincipal: () => Promise<void>;
fetchAll: () => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
updateDisplayName: (displayName: string) => Promise<void>;
enableTotp: () => Promise<string>;
disableTotp: () => Promise<void>;
addAppPassword: (name: string, password: string) => Promise<void>;
removeAppPassword: (name: string) => Promise<void>;
updateEncryption: (settings: { type: string; algo?: string; certs?: string }) => Promise<void>;
enableTotp: (currentPassword: string, otpUrl: string, otpCode: string) => Promise<void>;
disableTotp: (currentPassword: string) => Promise<void>;
createAppPassword: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
removeAppPassword: (id: string) => Promise<void>;
createApiKey: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
removeApiKey: (id: string) => Promise<void>;
clearState: () => void;
}
function getApiHeaders(): Record<string, string> {
return getActiveAccountSlotHeaders();
function getPrimaryAccountId(): string {
const client = useAuthStore.getState().client;
if (!client) throw new Error('Not authenticated');
return client.getAccountId();
}
function credentialFromResult(raw: Record<string, unknown>): AppPasswordInfo {
const allowedIps = raw.allowedIps && typeof raw.allowedIps === 'object'
? Object.keys(raw.allowedIps as Record<string, unknown>)
: [];
return {
id: String(raw.id ?? ''),
description: typeof raw.description === 'string' ? raw.description : '',
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
expiresAt: typeof raw.expiresAt === 'string' ? raw.expiresAt : null,
allowedIps,
};
}
function ipsToMap(ips?: string[]): Record<string, true> | undefined {
if (!ips || ips.length === 0) return undefined;
return Object.fromEntries(ips.map((ip) => [ip, true]));
}
function buildCreateBody(input: AppCredentialInput): Record<string, unknown> {
const body: Record<string, unknown> = { description: input.description };
if (input.expiresAt) body.expiresAt = input.expiresAt;
const allowed = ipsToMap(input.allowedIps);
if (allowed) body.allowedIps = allowed;
return body;
}
type SetMethod = 'x:AppPassword/set' | 'x:ApiKey/set';
type StoreGet = () => AccountSecurityState;
type StoreSet = (partial: Partial<AccountSecurityState>) => void;
async function createCredential(
get: StoreGet,
set: StoreSet,
method: SetMethod,
input: AppCredentialInput,
fallbackError: string,
): Promise<{ id: string; secret: string }> {
set({ isSaving: true, error: null });
try {
const accountId = getPrimaryAccountId();
const tmpId = 'new';
const responses = await stalwartJmap([
[method, { accountId, create: { [tmpId]: buildCreateBody(input) } }, '0'],
]);
const result = requireResult<{
created?: Record<string, { id: string; secret: string; createdAt?: string }>;
notCreated?: Record<string, { type: string; description?: string }>;
}>(responses, method);
const notCreated = result.notCreated?.[tmpId];
if (notCreated) {
throw new Error(notCreated.description || notCreated.type || fallbackError);
}
const created = result.created?.[tmpId];
if (!created?.id || !created.secret) {
throw new Error(`Server did not return created credential`);
}
await get().fetchAuthInfo();
set({ isSaving: false });
return { id: created.id, secret: created.secret };
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : fallbackError,
});
throw error;
}
}
async function removeCredential(
get: StoreGet,
set: StoreSet,
method: SetMethod,
id: string,
fallbackError: string,
): Promise<void> {
set({ isSaving: true, error: null });
try {
const accountId = getPrimaryAccountId();
await stalwartJmap([
[method, { accountId, destroy: [id] }, '0'],
]);
await get().fetchAuthInfo();
set({ isSaving: false });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : fallbackError,
});
throw error;
}
}
function extractEncryptionType(raw: unknown): EncryptionType {
if (!raw || typeof raw !== 'object') return 'Disabled';
const type = (raw as { ['@type']?: string })['@type'];
if (type === 'Aes128' || type === 'Aes256') return type;
return 'Disabled';
}
export const useAccountSecurityStore = create<AccountSecurityState>()((set, get) => ({
@@ -53,8 +185,9 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
isProbing: false,
otpEnabled: false,
appPasswords: [],
apiKeys: [],
isLoadingAuth: false,
encryptionType: 'disabled',
encryptionType: 'Disabled',
isLoadingCrypto: false,
displayName: '',
emails: [],
@@ -67,11 +200,8 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
probe: async () => {
set({ isProbing: true });
try {
const response = await apiFetch('/api/account/stalwart/probe', {
headers: getApiHeaders(),
});
const data = await response.json();
const isStalwart = data.isStalwart === true;
const client = useAuthStore.getState().client;
const isStalwart = !!client?.hasAccountCapability?.('urn:stalwart:jmap');
set({ isStalwart, isProbing: false });
return isStalwart;
} catch (error) {
@@ -84,16 +214,46 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
fetchAuthInfo: async () => {
set({ isLoadingAuth: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/auth', {
headers: getApiHeaders(),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
set({
otpEnabled: data.data?.otpEnabled ?? false,
appPasswords: data.data?.appPasswords ?? [],
isLoadingAuth: false,
});
const accountId = getPrimaryAccountId();
const responses = await stalwartJmap([
['x:AccountPassword/get', { accountId, ids: ['singleton'] }, '0'],
['x:AppPassword/query', { accountId }, '1'],
['x:ApiKey/query', { accountId }, '2'],
]);
const passwordResult = requireResult<{ list: Array<{ otpAuth?: { otpUrl?: string | null } }> }>(
responses,
'x:AccountPassword/get',
);
const appPwQuery = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query');
const apiKeyQuery = requireResult<{ ids: string[] }>(responses, 'x:ApiKey/query');
const otpAuth = passwordResult.list?.[0]?.otpAuth;
const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl);
const followUps: [string, Record<string, unknown>, string][] = [];
if (appPwQuery.ids?.length) {
followUps.push(['x:AppPassword/get', { accountId, ids: appPwQuery.ids }, 'app']);
}
if (apiKeyQuery.ids?.length) {
followUps.push(['x:ApiKey/get', { accountId, ids: apiKeyQuery.ids }, 'key']);
}
let appPasswords: AppPasswordInfo[] = [];
let apiKeys: ApiKeyInfo[] = [];
if (followUps.length) {
const followUpResponses = await stalwartJmap(followUps);
if (appPwQuery.ids?.length) {
const r = requireResult<{ list: Array<Record<string, unknown>> }>(followUpResponses, 'x:AppPassword/get');
appPasswords = (r.list ?? []).map(credentialFromResult);
}
if (apiKeyQuery.ids?.length) {
const r = requireResult<{ list: Array<Record<string, unknown>> }>(followUpResponses, 'x:ApiKey/get');
apiKeys = (r.list ?? []).map(credentialFromResult);
}
}
set({ otpEnabled, appPasswords, apiKeys, isLoadingAuth: false });
} catch (error) {
debug.error('Failed to fetch auth info:', error);
set({
@@ -106,15 +266,16 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
fetchCryptoInfo: async () => {
set({ isLoadingCrypto: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/crypto', {
headers: getApiHeaders(),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
set({
encryptionType: data.data?.type ?? 'disabled',
isLoadingCrypto: false,
});
const accountId = getPrimaryAccountId();
const responses = await stalwartJmap([
['x:AccountSettings/get', { accountId, ids: ['singleton'] }, '0'],
]);
const result = requireResult<{ list: Array<{ encryptionAtRest?: unknown }> }>(
responses,
'x:AccountSettings/get',
);
const encryptionType = extractEncryptionType(result.list?.[0]?.encryptionAtRest);
set({ encryptionType, isLoadingCrypto: false });
} catch (error) {
debug.error('Failed to fetch crypto info:', error);
set({
@@ -127,37 +288,41 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
fetchPrincipal: async () => {
set({ isLoadingPrincipal: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/principal', {
headers: getApiHeaders(),
});
if (!response.ok) {
if (response.status === 403) {
// User lacks permission to read principal (e.g. non-admin); treat as empty
set({
displayName: '',
emails: [],
quota: 0,
roles: [],
isLoadingPrincipal: false,
});
return;
}
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const principal = data.data;
const accountId = getPrimaryAccountId();
const responses = await stalwartJmap([
['x:Account/get', { accountId, ids: [accountId] }, '0'],
]);
const result = requireResult<{
list: Array<{
description?: string | null;
aliases?: Record<string, { name?: string; domainId?: string; enabled?: boolean }>;
quotas?: { maxDiskQuota?: number };
roles?: { ['@type']?: string };
name?: string;
domainId?: string;
}>;
}>(responses, 'x:Account/get');
const acc = result.list?.[0];
const aliasAddresses = acc?.aliases
? Object.values(acc.aliases)
.flatMap((a) => (a && a.enabled !== false && a.name ? [a.name] : []))
: [];
const primaryEmail = acc?.name ? [acc.name] : [];
set({
displayName: principal?.description ?? '',
emails: Array.isArray(principal?.emails) ? principal.emails : principal?.emails ? [principal.emails] : [],
quota: principal?.quota ?? 0,
roles: principal?.roles ?? [],
displayName: acc?.description ?? '',
emails: [...primaryEmail, ...aliasAddresses],
quota: acc?.quotas?.maxDiskQuota ?? 0,
roles: acc?.roles?.['@type'] ? [acc.roles['@type']] : [],
isLoadingPrincipal: false,
});
} catch (error) {
debug.error('Failed to fetch principal:', error);
const msg = error instanceof Error ? error.message : 'Failed to fetch principal';
const isForbidden = msg.toLowerCase().includes('forbidden');
set({
isLoadingPrincipal: false,
error: error instanceof Error ? error.message : 'Failed to fetch principal',
error: isForbidden ? null : msg,
});
}
},
@@ -170,17 +335,17 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
changePassword: async (currentPassword, newPassword) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/password', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `HTTP ${response.status}`);
}
const accountId = getPrimaryAccountId();
await stalwartJmap([
[
'x:AccountPassword/set',
{
accountId,
update: { singleton: { currentSecret: currentPassword, secret: newPassword } },
},
'0',
],
]);
set({ isSaving: false });
} catch (error) {
set({
@@ -194,19 +359,14 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
updateDisplayName: async (displayName) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/principal', {
method: 'PATCH',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([
{ action: 'set', field: 'description', value: displayName },
]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `HTTP ${response.status}`);
}
const accountId = getPrimaryAccountId();
await stalwartJmap([
[
'x:AccountSettings/set',
{ accountId, update: { singleton: { description: displayName } } },
'0',
],
]);
set({ displayName, isSaving: false });
} catch (error) {
set({
@@ -217,23 +377,26 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
}
},
enableTotp: async () => {
enableTotp: async (currentPassword, otpUrl, otpCode) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/auth', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'enableOtpAuth' }]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
const data = await response.json();
const accountId = getPrimaryAccountId();
await stalwartJmap([
[
'x:AccountPassword/set',
{
accountId,
update: {
singleton: {
currentSecret: currentPassword,
otpAuth: { otpUrl, otpCode },
},
},
},
'0',
],
]);
set({ otpEnabled: true, isSaving: false });
return data.data;
} catch (error) {
set({
isSaving: false,
@@ -243,20 +406,25 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
}
},
disableTotp: async () => {
disableTotp: async (currentPassword) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/auth', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'disableOtpAuth' }]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
const accountId = getPrimaryAccountId();
await stalwartJmap([
[
'x:AccountPassword/set',
{
accountId,
update: {
singleton: {
currentSecret: currentPassword,
otpAuth: { otpUrl: null },
},
},
},
'0',
],
]);
set({ otpEnabled: false, isSaving: false });
} catch (error) {
set({
@@ -267,80 +435,20 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
}
},
addAppPassword: async (name, password) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/auth', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'addAppPassword', name, password }]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
// Refresh auth info to get updated app passwords list
await get().fetchAuthInfo();
set({ isSaving: false });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to add app password',
});
throw error;
}
createAppPassword: async (input) => {
return createCredential(get, set, 'x:AppPassword/set', input, 'Failed to create app password');
},
removeAppPassword: async (name) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/auth', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'removeAppPassword', name }]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
// Refresh auth info to get updated app passwords list
await get().fetchAuthInfo();
set({ isSaving: false });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to remove app password',
});
throw error;
}
removeAppPassword: async (id) => {
return removeCredential(get, set, 'x:AppPassword/set', id, 'Failed to remove app password');
},
updateEncryption: async (settings) => {
set({ isSaving: true, error: null });
try {
const response = await apiFetch('/api/account/stalwart/crypto', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
createApiKey: async (input) => {
return createCredential(get, set, 'x:ApiKey/set', input, 'Failed to create API key');
},
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
set({ encryptionType: settings.type, isSaving: false });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to update encryption',
});
throw error;
}
removeApiKey: async (id) => {
return removeCredential(get, set, 'x:ApiKey/set', id, 'Failed to remove API key');
},
clearState: () => set({
@@ -348,8 +456,9 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
isProbing: false,
otpEnabled: false,
appPasswords: [],
apiKeys: [],
isLoadingAuth: false,
encryptionType: 'disabled',
encryptionType: 'Disabled',
isLoadingCrypto: false,
displayName: '',
emails: [],
+2 -1
View File
@@ -8,6 +8,7 @@ import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normaliz
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { generateUUID } from '@/lib/utils';
import { apiFetch } from '@/lib/browser-navigation';
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
@@ -174,7 +175,7 @@ export const useCalendarStore = create<CalendarStore>()(
const calendars = await client.getAllCalendars();
const { selectedCalendarIds } = get();
const validIds = calendars.map(c => c.id);
const stillValid = selectedCalendarIds.filter(id => validIds.includes(id));
const stillValid = selectedCalendarIds.filter(id => validIds.includes(id) || id === BIRTHDAY_CALENDAR_ID);
set({
calendars,
isLoading: false,
+1 -1
View File
@@ -72,7 +72,7 @@ interface EmailStore {
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>;
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number }>) => Promise<void>;
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>) => Promise<void>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
-64
View File
@@ -29,7 +29,6 @@ interface FilterStore {
toggleRule: (ruleId: string) => void;
setRawScript: (content: string) => void;
resetToVisualBuilder: () => void;
syncVacationToScript: (client: IJMAPClient, vacation: VacationSieveConfig) => Promise<void>;
clearState: () => void;
}
@@ -193,69 +192,6 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [], externalRequires: [] }),
syncVacationToScript: async (client, vacation) => {
try {
// Preserve current rules before re-fetching, since the server
// may have overwritten our script with a vacation-only one.
const { rules: previousRules } = get();
// Always re-fetch scripts from the server to get the current state
// after Stalwart may have rewritten the active script.
const allScripts = await client.getSieveScripts();
// Skip the server-managed 'vacation' script (RFC 9661 §4)
const scripts = allScripts.filter(s => s.name !== 'vacation');
const activeScript = scripts.find(s => s.isActive) || scripts[0];
let rules = previousRules;
let externalRequires = get().externalRequires;
// If there's an active script, try to parse our metadata from it.
// If the server overwrote it (no metadata), fall back to stored rules.
if (activeScript) {
const content = await client.getSieveScriptContent(activeScript.blobId);
const parsed = parseScript(content);
if (!parsed.isOpaque) {
rules = parsed.rules;
externalRequires = parsed.externalRequires;
}
}
// Generate a combined script with our metadata, rules, and vacation
const content = generateScript(rules, vacation.isEnabled ? vacation : undefined, { externalRequires });
if (activeScript) {
// Preserve the script's current activation state - don't pass activate: true
// unconditionally, as that would deactivate the server-managed 'vacation'
// script and cause VacationResponse/get to return isEnabled: false.
await client.updateSieveScript(activeScript.id, content, activeScript.isActive);
set({
activeScriptId: activeScript.id,
rawScript: content,
rules,
vacationSettings: vacation,
isOpaque: false,
externalRequires,
});
} else {
// Don't activate; there may be a server-managed 'vacation' script active.
// The filters script will be activated when the user saves filters normally.
const script = await client.createSieveScript('filters', content, false);
set({
activeScriptId: script.id,
rawScript: content,
rules,
vacationSettings: vacation,
isOpaque: false,
externalRequires,
});
}
debug.log('filters', 'Vacation synced to sieve script');
} catch (error) {
debug.error('Failed to sync vacation to sieve script:', error);
}
},
clearState: () => set({
rules: [],
isLoading: false,
+7
View File
@@ -198,6 +198,10 @@ interface SettingsState {
attachmentReminderEnabled: boolean;
attachmentReminderKeywords: string[];
// Hide inline images (images referenced by cid in the HTML body) from the
// attachment list shown above the message body.
hideInlineImageAttachments: boolean;
// Sidebar Apps
sidebarApps: SidebarApp[];
keepAppsLoaded: boolean;
@@ -365,6 +369,8 @@ const DEFAULT_SETTINGS = {
'pielikumā',
] as string[],
hideInlineImageAttachments: true,
// Sidebar Apps
sidebarApps: [] as SidebarApp[],
keepAppsLoaded: false,
@@ -467,6 +473,7 @@ export const useSettingsStore = create<SettingsState>()(
emailKeywords: state.emailKeywords,
attachmentReminderEnabled: state.attachmentReminderEnabled,
attachmentReminderKeywords: state.attachmentReminderKeywords,
hideInlineImageAttachments: state.hideInlineImageAttachments,
sidebarApps: state.sidebarApps,
keepAppsLoaded: state.keepAppsLoaded,
debugMode: state.debugMode,