Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a8ad525f1 | ||
|
|
31d17098d6 | ||
|
|
3f97e6ed8d | ||
|
|
2dea33e698 | ||
|
|
123764f8b8 | ||
|
|
ec0f355c13 | ||
|
|
c555973b6b | ||
|
|
a8db02e881 | ||
|
|
7dc5984359 | ||
|
|
1c3003421e | ||
|
|
f3d9115ecd | ||
|
|
4400a7abba | ||
|
|
45a4db1c22 | ||
|
|
65eef4b2b8 | ||
|
|
25de7d996c | ||
|
|
c406fbb73e | ||
|
|
31024396e3 | ||
|
|
f0967f90eb | ||
|
|
a4bb8e0c28 | ||
|
|
7188abc9bc | ||
|
|
4a91cd0c44 | ||
|
|
6abf8a5dd8 | ||
|
|
3667c842c6 | ||
|
|
b64721b43c | ||
|
|
6b5ca2cb89 | ||
|
|
0f6e4f995f | ||
|
|
0b6fdcabfb | ||
|
|
fc49fe0687 | ||
|
|
419382d25d | ||
|
|
8935b81f12 | ||
|
|
ec581ce53e | ||
|
|
81d8465a79 | ||
|
|
0f3b506604 | ||
|
|
1b84547211 | ||
|
|
dafc8ace3c | ||
|
|
2c419cc4fe | ||
|
|
90acf181f3 | ||
|
|
54af07f2af | ||
|
|
68f1fabc4b | ||
|
|
27451807db | ||
|
|
55099bdcbb | ||
|
|
71eb720065 | ||
|
|
8abb0c8717 | ||
|
|
3043639d2d | ||
|
|
e9b3eacbb7 | ||
|
|
f37e55e285 | ||
|
|
c0af2dbdd1 | ||
|
|
ae517732f7 | ||
|
|
3e1de10213 | ||
|
|
511740bb6d | ||
|
|
4a24d2a11d | ||
|
|
aadf56c27b | ||
|
|
9f8588eadc | ||
|
|
d657aec391 | ||
|
|
e683c90404 | ||
|
|
cfb4a23c9d | ||
|
|
fe1d4861bb | ||
|
|
5aa9b1d5f9 | ||
|
|
0913dbd3e4 | ||
|
|
29197ea355 | ||
|
|
4788e8a91a | ||
|
|
b80678b00f | ||
|
|
4f7c9c332b | ||
|
|
da103ff06f | ||
|
|
2111c77870 | ||
|
|
e5083ec1df | ||
|
|
df8d04e233 | ||
|
|
9a11a18a44 | ||
|
|
ce9f7af330 | ||
|
|
081e8a0310 | ||
|
|
6c3529b368 | ||
|
|
077a4f03a7 | ||
|
|
b04dfaf252 | ||
|
|
9c7452e7fd |
+30
-4
@@ -71,11 +71,36 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
|
|
||||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||||
# For Docker, the working directory is /app, so the default resolves to
|
# For Docker, the working directory is /app, so the default resolves to
|
||||||
# /app/data/settings - mount a persistent volume there:
|
# /app/data/settings - mount a persistent volume there (see docker-compose.yml).
|
||||||
# volumes:
|
|
||||||
# - bulwark-settings:/app/data/settings
|
|
||||||
# SETTINGS_DATA_DIR=./data/settings
|
# SETTINGS_DATA_DIR=./data/settings
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Admin Dashboard Data
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Directory for admin dashboard state: config overrides, admin password hash,
|
||||||
|
# installed plugins/themes, and audit logs (default: ./data/admin).
|
||||||
|
# For Docker, the default resolves to /app/data/admin - mount a persistent
|
||||||
|
# volume there (see docker-compose.yml).
|
||||||
|
# ADMIN_DATA_DIR=./data/admin
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Anonymous Telemetry
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
|
||||||
|
# version, platform, bucketed account counts, and feature toggles only. See
|
||||||
|
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
|
||||||
|
#
|
||||||
|
# Disable telemetry entirely (overrides the admin UI):
|
||||||
|
# BULWARK_TELEMETRY=off
|
||||||
|
|
||||||
|
# Directory for telemetry state: instance id, consent, login HMACs
|
||||||
|
# (default: ./data/telemetry). For Docker, the default resolves to
|
||||||
|
# /app/data/telemetry - mount a persistent volume there (see docker-compose.yml)
|
||||||
|
# so the instance id and consent choice survive upgrades.
|
||||||
|
# TELEMETRY_DATA_DIR=./data/telemetry
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Server Listen Address
|
# Server Listen Address
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -183,7 +208,8 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# URL of the BulwarkMail extension directory for the admin marketplace.
|
# URL of the BulwarkMail extension directory for the admin marketplace.
|
||||||
# Set this to enable browsing and installing plugins/themes from the directory.
|
# Defaults to https://extensions.bulwarkmail.org. Override only if you run
|
||||||
|
# your own directory (e.g. http://localhost:3001 for local development).
|
||||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -1,5 +1,88 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.5.4 (2026-05-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **PWA**: Web push notifications for new inbox mail (#233), with click-through to open the message
|
||||||
|
- **Composer**: Insert and edit tables in rich-text emails (#236)
|
||||||
|
- **Mail**: Configurable sub-addressing delimiter character (#239)
|
||||||
|
- **i18n**: Turkish localization
|
||||||
|
- **i18n**: Missing keys filled in across 15 locales
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Set In-Reply-To and References headers on replies (#234)
|
||||||
|
- **Mail**: Persist htmlBody in drafts to preserve rich formatting (#236)
|
||||||
|
- **Auth**: Pin JMAP auth verification to the configured server URL (#237)
|
||||||
|
- **Auth**: Evict unrecoverable basic-auth accounts on reload
|
||||||
|
- **Notifications**: Scope new-mail notifications to genuine inbox deliveries
|
||||||
|
- **Notifications**: Extend PushVerification timeout and clean up leftover subscriptions
|
||||||
|
- **Viewer**: Smooth out body load to prevent flicker on first render
|
||||||
|
- **Viewer**: Prevent iframe flash when loading images or trusting the sender
|
||||||
|
- **Viewer**: Pad bare HTML emails like plain-text mails for consistent layout
|
||||||
|
- **Viewer**: Light-mode override now only affects body content
|
||||||
|
- **Viewer**: Detect `<style>` tag when applying padding
|
||||||
|
- **Viewer**: Drop iframe border-radius
|
||||||
|
- **Calendar**: Localize event start date in detail popover and event modal
|
||||||
|
- **Dev**: Include http protocol in connect-src for development mode CSP
|
||||||
|
|
||||||
|
## 1.5.3 (2026-04-28)
|
||||||
|
|
||||||
|
> **New:** Help shape Bulwark Webmail. Each instance now sends a lightweight daily heartbeat (version, platform, bucketed account counts, feature toggles - never message data or PII) so we can see which platforms and features actually get used and prioritize fixes where they matter most. You're in control: opt out any time from **Admin → Telemetry** or by setting `BULWARK_TELEMETRY=off`. Full schema in the [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Telemetry**: Anonymous instance telemetry, on by default. Reports schema version, platform, bucketed account counts, and feature toggles only - disable from the admin UI, with `BULWARK_TELEMETRY=off`, or by clearing the endpoint
|
||||||
|
- **Telemetry**: Track unique logins (HMAC'd per instance, 90-day retention) so the heartbeat can report bucketed account totals without storing usernames
|
||||||
|
- **Plugins**: Theme API v2 with token compiler and skin slot
|
||||||
|
- **Plugins**: Extension preview page and detailed extension info API
|
||||||
|
- **Calendar**: Right-click context menu on empty calendar space
|
||||||
|
- **Docker**: Persistent named volume for telemetry data so the instance id and admin's consent choice survive container upgrades
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Block telemetry endpoint from pointing at internal/loopback hosts (validation + DNS-rebind re-check at fetch time)
|
||||||
|
- **Security**: Harden plugin config, TOTP token exchange, and branding file serving
|
||||||
|
- **Mail**: Batch shortcuts now act on the multi-selection when one is present (#228)
|
||||||
|
|
||||||
|
## 1.5.2 (2026-04-27)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission - plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
|
||||||
|
- **Plugins**: Manifests can declare `frameOrigins` - a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
|
||||||
|
- **Calendar/Contacts**: JMAP sharing for calendars and address books
|
||||||
|
- **i18n**: Czech language support
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Validate URLs before outbound fetch
|
||||||
|
- **Calendar**: Prevent drag creation on touch events in the time grid
|
||||||
|
- **Contacts**: Emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import (#224, #187)
|
||||||
|
- **Mail**: Hide preview line in compact density to match settings preview (#223)
|
||||||
|
- **Proxy**: Inline matcher for Next.js proxy and drop unnecessary Node.js runtime config
|
||||||
|
- **i18n**: Portuguese fixes for "ficheiro" and "contactos" variants
|
||||||
|
|
||||||
|
## 1.5.1 (2026-04-25)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Stalwart**: OAuth auto-setup with dialog and validation for origin and issuer URLs
|
||||||
|
- **Mail**: Right-click context menu on the folders sidebar
|
||||||
|
- **Mail**: Replace folder `prompt()` calls with a proper modal dialog
|
||||||
|
- **Calendar**: Add 'Today' button to the desktop calendar toolbar
|
||||||
|
- **Junk**: Setting to show avatars in the Junk folder (off by default)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Admin**: Restore admin panel after Stalwart v0.16 REST API removal
|
||||||
|
- **Viewer**: Restore broken viewer toolbar actions and improve the mobile menu (#220)
|
||||||
|
- **Folders**: Stop flicker on background folder refresh
|
||||||
|
- **Email**: Preserve search/filter on batch move and archive
|
||||||
|
- **Email**: Preserve search/filter when moving emails via drag-drop
|
||||||
|
- **i18n**: Improve Korean flag
|
||||||
|
|
||||||
## 1.5.0 (2026-04-22)
|
## 1.5.0 (2026-04-22)
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ RUN apk upgrade --no-cache && \
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
RUN mkdir -p /app/data/settings /app/data/admin && chown -R nextjs:nodejs /app/data
|
RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
## Internationalization
|
## Internationalization
|
||||||
|
|
||||||
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||||
|
|
||||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +53,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
> **Anonymous telemetry is on by default** since 1.5.3. Each instance sends a daily heartbeat (version, platform, bucketed account counts, feature toggles - no message data, no PII). Disable from **Admin → Telemetry**, by setting `BULWARK_TELEMETRY=off`, or by clearing the endpoint. Full schema: [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Bulwark is a full webmail suite – not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
Bulwark is a full webmail suite – not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||||
@@ -62,7 +64,7 @@ Bulwark is a full webmail suite – not just an inbox. It bundles the four apps
|
|||||||
- **Contacts** – multiple address books, groups, vCard import/export
|
- **Contacts** – multiple address books, groups, vCard import/export
|
||||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||||
|
|
||||||
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a Stalwart admin dashboard.
|
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
|
||||||
|
|
||||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan
|
|||||||
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||||
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
||||||
|
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
||||||
@@ -47,7 +48,11 @@ import { getEventStartDate } from "@/lib/calendar-utils";
|
|||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
|
||||||
|
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
|
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
@@ -72,7 +77,8 @@ export default function CalendarPage() {
|
|||||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||||
|
removeCalendar, clearCalendarEvents,
|
||||||
refreshAllSubscriptions, icalSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||||
@@ -91,9 +97,15 @@ export default function CalendarPage() {
|
|||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||||
|
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
|
||||||
|
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
|
||||||
|
const [showCreateCalendar, setShowCreateCalendar] = useState(false);
|
||||||
|
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
|
||||||
|
const tMgmt = useTranslations("calendar.management");
|
||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||||
|
const [defaultModalAllDay, setDefaultModalAllDay] = useState(false);
|
||||||
const [miniMonth, setMiniMonth] = useState(new Date());
|
const [miniMonth, setMiniMonth] = useState(new Date());
|
||||||
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
||||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||||
@@ -316,11 +328,12 @@ export default function CalendarPage() {
|
|||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
}, [setSelectedDate]);
|
}, [setSelectedDate]);
|
||||||
|
|
||||||
const openCreateModal = useCallback((date?: Date, endDate?: Date) => {
|
const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => {
|
||||||
setEditEvent(null);
|
setEditEvent(null);
|
||||||
const d = date || selectedDate;
|
const d = date || selectedDate;
|
||||||
setDefaultModalDate(d);
|
setDefaultModalDate(d);
|
||||||
setDefaultModalEndDate(endDate);
|
setDefaultModalEndDate(endDate);
|
||||||
|
setDefaultModalAllDay(allDay ?? false);
|
||||||
setSelectedDate(d);
|
setSelectedDate(d);
|
||||||
setShowEventModal(true);
|
setShowEventModal(true);
|
||||||
}, [selectedDate, setSelectedDate]);
|
}, [selectedDate, setSelectedDate]);
|
||||||
@@ -385,6 +398,21 @@ export default function CalendarPage() {
|
|||||||
openEventContextMenu(e, event);
|
openEventContextMenu(e, event);
|
||||||
}, [closeDetail, openEventContextMenu]);
|
}, [closeDetail, openEventContextMenu]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
contextMenu: emptyContextMenu,
|
||||||
|
openContextMenu: openEmptyContextMenu,
|
||||||
|
closeContextMenu: closeEmptyContextMenu,
|
||||||
|
menuRef: emptyContextMenuRef,
|
||||||
|
} = useContextMenu<{ date: Date; hour?: number; allDayArea?: boolean }>();
|
||||||
|
|
||||||
|
const handleContextMenuEmpty = useCallback(
|
||||||
|
(e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => {
|
||||||
|
closeDetail();
|
||||||
|
openEmptyContextMenu(e, { date, hour, allDayArea });
|
||||||
|
},
|
||||||
|
[closeDetail, openEmptyContextMenu],
|
||||||
|
);
|
||||||
|
|
||||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
if (calendarHoverPreview === 'off') return;
|
if (calendarHoverPreview === 'off') return;
|
||||||
@@ -951,6 +979,7 @@ export default function CalendarPage() {
|
|||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
onContextMenuEvent={handleContextMenuEvent}
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
|
onContextMenuEmpty={handleContextMenuEmpty}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -968,6 +997,7 @@ export default function CalendarPage() {
|
|||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
onContextMenuEvent={handleContextMenuEvent}
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
|
onContextMenuEmpty={handleContextMenuEmpty}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
@@ -987,6 +1017,7 @@ export default function CalendarPage() {
|
|||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
onContextMenuEvent={handleContextMenuEvent}
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
|
onContextMenuEmpty={handleContextMenuEmpty}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -1102,6 +1133,42 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||||
|
onCreateEvent={(cal: Calendar) => {
|
||||||
|
setDefaultCalendarIdForCreate(cal.id);
|
||||||
|
openCreateModal();
|
||||||
|
}}
|
||||||
|
onClearCalendar={client ? async (cal: Calendar) => {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: tMgmt("clear_events"),
|
||||||
|
message: tMgmt("confirm_clear", { name: cal.name }),
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: tMgmt("clear_events"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
const count = await clearCalendarEvents(client, cal.id);
|
||||||
|
toast.success(tMgmt("events_cleared", { count }));
|
||||||
|
} catch {
|
||||||
|
toast.error(tMgmt("error_clear"));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
onDeleteCalendar={client ? async (cal: Calendar) => {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: tMgmt("delete"),
|
||||||
|
message: tMgmt("confirm_delete", { name: cal.name }),
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: tMgmt("delete"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await removeCalendar(client, cal.id);
|
||||||
|
toast.success(tMgmt("calendar_deleted"));
|
||||||
|
} catch {
|
||||||
|
toast.error(tMgmt("error_delete"));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined}
|
||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||||
client={client}
|
client={client}
|
||||||
@@ -1165,11 +1232,13 @@ export default function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
|
defaultAllDay={defaultModalAllDay}
|
||||||
|
defaultCalendarId={defaultCalendarIdForCreate}
|
||||||
onSave={handleSaveEvent}
|
onSave={handleSaveEvent}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||||
onPreviewChange={setPendingPreview}
|
onPreviewChange={setPendingPreview}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
@@ -1235,6 +1304,38 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{emptyContextMenu.data && (() => {
|
||||||
|
const { date, hour } = emptyContextMenu.data;
|
||||||
|
return (
|
||||||
|
<EmptySpaceContextMenu
|
||||||
|
position={emptyContextMenu.position}
|
||||||
|
isOpen={emptyContextMenu.isOpen}
|
||||||
|
onClose={closeEmptyContextMenu}
|
||||||
|
menuRef={emptyContextMenuRef}
|
||||||
|
onNewEvent={() => {
|
||||||
|
const d = new Date(date);
|
||||||
|
if (typeof hour === "number") {
|
||||||
|
d.setHours(hour, 0, 0, 0);
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
d.setHours(now.getHours() + 1, 0, 0, 0);
|
||||||
|
}
|
||||||
|
openCreateModal(d);
|
||||||
|
}}
|
||||||
|
onNewAllDayEvent={() => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
openCreateModal(d, undefined, true);
|
||||||
|
}}
|
||||||
|
onNewTask={enableCalendarTasks ? () => {
|
||||||
|
setEditTask(null);
|
||||||
|
setShowTaskModal(true);
|
||||||
|
} : undefined}
|
||||||
|
onGoToToday={goToToday}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{detailEvent && detailAnchorRect && (
|
{detailEvent && detailAnchorRect && (
|
||||||
<EventDetailPopover
|
<EventDetailPopover
|
||||||
event={detailEvent}
|
event={detailEvent}
|
||||||
@@ -1261,11 +1362,13 @@ export default function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
|
defaultAllDay={defaultModalAllDay}
|
||||||
|
defaultCalendarId={defaultCalendarIdForCreate}
|
||||||
onSave={handleSaveEvent}
|
onSave={handleSaveEvent}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={true}
|
isMobile={true}
|
||||||
/>
|
/>
|
||||||
@@ -1305,6 +1408,33 @@ export default function CalendarPage() {
|
|||||||
onSelect={handleScopeSelect}
|
onSelect={handleScopeSelect}
|
||||||
onClose={() => setPendingScopeAction(null)}
|
onClose={() => setPendingScopeAction(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
|
||||||
|
{showCreateCalendar && client && (
|
||||||
|
<CreateCalendarModal
|
||||||
|
client={client}
|
||||||
|
onClose={() => setShowCreateCalendar(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sharingCalendarId && client && (() => {
|
||||||
|
const cal = allCalendars.find((c) => c.id === sharingCalendarId);
|
||||||
|
if (!cal) return null;
|
||||||
|
return (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="calendar"
|
||||||
|
collectionName={cal.name}
|
||||||
|
shareWith={cal.shareWith}
|
||||||
|
ownAccountId={client.getAccountId()}
|
||||||
|
onShare={async (principalId, rights) => {
|
||||||
|
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
|
||||||
|
}}
|
||||||
|
onClose={() => setSharingCalendarId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
|||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||||
|
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| "list"
|
| "list"
|
||||||
@@ -75,6 +76,8 @@ export default function ContactsPage() {
|
|||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
moveContactToAddressBook,
|
moveContactToAddressBook,
|
||||||
renameAddressBook,
|
renameAddressBook,
|
||||||
|
removeAddressBook,
|
||||||
|
shareAddressBook,
|
||||||
renameKeyword,
|
renameKeyword,
|
||||||
importContacts,
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
@@ -83,6 +86,8 @@ export default function ContactsPage() {
|
|||||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||||
|
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||||
|
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
@@ -329,6 +334,7 @@ export default function ContactsPage() {
|
|||||||
addLocalContact(localContact);
|
addLocalContact(localContact);
|
||||||
toast.success(t("toast.created"));
|
toast.success(t("toast.created"));
|
||||||
}
|
}
|
||||||
|
setDefaultBookIdForCreate(undefined);
|
||||||
setView("list");
|
setView("list");
|
||||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||||
|
|
||||||
@@ -346,6 +352,7 @@ export default function ContactsPage() {
|
|||||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
setDefaultBookIdForCreate(undefined);
|
||||||
if (view === "group-create" || view === "group-edit") {
|
if (view === "group-create" || view === "group-edit") {
|
||||||
setView(selectedGroup ? "group-detail" : "list");
|
setView(selectedGroup ? "group-detail" : "list");
|
||||||
} else if (view === "bulk-add-to-group") {
|
} else if (view === "bulk-add-to-group") {
|
||||||
@@ -517,7 +524,7 @@ export default function ContactsPage() {
|
|||||||
const renderRightPanel = () => {
|
const renderRightPanel = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "create":
|
case "create":
|
||||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
|
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
if (!selectedContact) return null;
|
if (!selectedContact) return null;
|
||||||
@@ -690,6 +697,26 @@ export default function ContactsPage() {
|
|||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
onDropContactsToCategory={handleDropContactsToCategory}
|
onDropContactsToCategory={handleDropContactsToCategory}
|
||||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||||
|
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
|
||||||
|
onCreateContactInBook={(book) => {
|
||||||
|
setDefaultBookIdForCreate(book.id);
|
||||||
|
handleCreateNew();
|
||||||
|
}}
|
||||||
|
onDeleteAddressBook={client ? async (book) => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("address_books.delete"),
|
||||||
|
message: t("address_books.confirm_delete", { name: book.name }),
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: t("address_books.delete"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await removeAddressBook(client, book);
|
||||||
|
toast.success(t("address_books.deleted"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("address_books.delete_failed"));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -838,6 +865,23 @@ export default function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{sharingAddressBookId && client && (() => {
|
||||||
|
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
|
||||||
|
if (!book) return null;
|
||||||
|
return (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="addressBook"
|
||||||
|
collectionName={book.name}
|
||||||
|
shareWith={book.shareWith}
|
||||||
|
ownAccountId={client.getAccountId()}
|
||||||
|
onShare={async (principalId, rights) => {
|
||||||
|
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
|
||||||
|
}}
|
||||||
|
onClose={() => setSharingAddressBookId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+351
-29
@@ -14,6 +14,7 @@ import { useAccountStore } from "@/stores/account-store";
|
|||||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
@@ -23,6 +24,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
|||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
|
import { usePromptDialog } from "@/hooks/use-prompt-dialog";
|
||||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
ComposerErrorFallback,
|
ComposerErrorFallback,
|
||||||
} from "@/components/error";
|
} from "@/components/error";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||||
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||||
@@ -48,6 +51,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||||
|
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -67,6 +71,7 @@ export default function Home() {
|
|||||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
|
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
@@ -162,6 +167,16 @@ export default function Home() {
|
|||||||
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||||
refreshUnifiedCounts,
|
refreshUnifiedCounts,
|
||||||
exitUnifiedView,
|
exitUnifiedView,
|
||||||
|
emptyMailbox,
|
||||||
|
markMailboxAsRead,
|
||||||
|
createMailbox,
|
||||||
|
renameMailbox,
|
||||||
|
deleteMailbox,
|
||||||
|
batchDelete,
|
||||||
|
batchArchive,
|
||||||
|
batchMarkAsRead,
|
||||||
|
batchMarkAsSpam,
|
||||||
|
batchUndoSpam,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||||
@@ -344,27 +359,77 @@ export default function Home() {
|
|||||||
onToggleStar: () => {
|
onToggleStar: () => {
|
||||||
if (selectedEmail) handleToggleStar();
|
if (selectedEmail) handleToggleStar();
|
||||||
},
|
},
|
||||||
onArchive: () => {
|
onArchive: async () => {
|
||||||
if (selectedEmail) handleArchive();
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
|
try {
|
||||||
|
await batchArchive(client);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to batch archive:", error);
|
||||||
|
}
|
||||||
|
} else if (selectedEmail) {
|
||||||
|
handleArchive();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDelete: () => {
|
onDelete: async () => {
|
||||||
if (selectedEmail) handleDelete();
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
|
const isInTrash = currentMailbox?.role === 'trash';
|
||||||
|
const isInJunk = currentMailbox?.role === 'junk';
|
||||||
|
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
|
||||||
|
const permanent = isInTrash || (isInJunk && permanentlyDeleteJunk);
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: permanent
|
||||||
|
? t('email_list.permanent_delete_confirm_title')
|
||||||
|
: t('email_list.batch_actions.delete_confirm_title'),
|
||||||
|
message: permanent
|
||||||
|
? t('email_list.permanent_delete_confirm_batch_message', { count: selectedEmailIds.size })
|
||||||
|
: t('email_list.batch_actions.delete_confirm_message', { count: selectedEmailIds.size }),
|
||||||
|
confirmText: permanent
|
||||||
|
? t('email_list.permanent_delete')
|
||||||
|
: t('email_list.batch_actions.delete'),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
await batchDelete(client, permanent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to batch delete:", error);
|
||||||
|
}
|
||||||
|
} else if (selectedEmail) {
|
||||||
|
handleDelete();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onMarkAsUnread: async () => {
|
onMarkAsUnread: async () => {
|
||||||
if (selectedEmail && client) {
|
if (!client) return;
|
||||||
|
if (selectedEmailIds.size > 0) {
|
||||||
|
await batchMarkAsRead(client, false);
|
||||||
|
} else if (selectedEmail) {
|
||||||
await markAsRead(client, selectedEmail.id, false);
|
await markAsRead(client, selectedEmail.id, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMarkAsRead: async () => {
|
onMarkAsRead: async () => {
|
||||||
if (selectedEmail && client) {
|
if (!client) return;
|
||||||
|
if (selectedEmailIds.size > 0) {
|
||||||
|
await batchMarkAsRead(client, true);
|
||||||
|
} else if (selectedEmail) {
|
||||||
await markAsRead(client, selectedEmail.id, true);
|
await markAsRead(client, selectedEmail.id, true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToggleSpam: () => {
|
onToggleSpam: async () => {
|
||||||
if (selectedEmail) {
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
// Check if we're in junk folder
|
const isInJunk = currentMailbox?.role === 'junk';
|
||||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
const isInJunk = currentMailbox?.role === 'junk';
|
const ids = Array.from(selectedEmailIds);
|
||||||
|
try {
|
||||||
|
if (isInJunk) {
|
||||||
|
await batchUndoSpam(client, ids);
|
||||||
|
} else {
|
||||||
|
await batchMarkAsSpam(client, ids);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to batch toggle spam:", error);
|
||||||
|
}
|
||||||
|
} else if (selectedEmail) {
|
||||||
if (isInJunk) {
|
if (isInJunk) {
|
||||||
handleUndoSpam();
|
handleUndoSpam();
|
||||||
} else {
|
} else {
|
||||||
@@ -399,13 +464,14 @@ export default function Home() {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet]);
|
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]);
|
||||||
|
|
||||||
// Initialize keyboard shortcuts
|
// Initialize keyboard shortcuts
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: isAuthenticated && !showComposer,
|
enabled: isAuthenticated && !showComposer,
|
||||||
emails,
|
emails,
|
||||||
selectedEmailId: selectedEmail?.id,
|
selectedEmailId: selectedEmail?.id,
|
||||||
|
selectionCount: selectedEmailIds.size,
|
||||||
handlers: keyboardHandlers,
|
handlers: keyboardHandlers,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -586,11 +652,50 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
||||||
|
|
||||||
|
// System-notification click handler. The push SW navigates the user back
|
||||||
|
// here with `?email=<id>` (specific email it built the toast from) or
|
||||||
|
// `?openLatestUnread=1` (generic "New mail" toast — happens when the
|
||||||
|
// preview API failed). We resolve those params once after the inbox has
|
||||||
|
// finished loading and open the right message, then strip the params so a
|
||||||
|
// refresh doesn't re-open it.
|
||||||
|
const notificationParamHandledRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (notificationParamHandledRef.current) return;
|
||||||
|
if (!isAuthenticated || !client) return;
|
||||||
|
if (mailboxes.length === 0) return;
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const emailIdParam = params.get('email');
|
||||||
|
const openLatestUnread = params.get('openLatestUnread') === '1';
|
||||||
|
if (!emailIdParam && !openLatestUnread) return;
|
||||||
|
|
||||||
|
// For the latest-unread case we need the inbox emails loaded; bail and
|
||||||
|
// let the effect re-run once `emails` is populated.
|
||||||
|
if (openLatestUnread && emails.length === 0) return;
|
||||||
|
|
||||||
|
notificationParamHandledRef.current = true;
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
|
||||||
|
if (emailIdParam) {
|
||||||
|
setLoadingEmail(true);
|
||||||
|
fetchEmailContent(client, emailIdParam).finally(() => setLoadingEmail(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// emails are sorted receivedAt-desc, so the first unread is the newest.
|
||||||
|
const newestUnread = emails.find(e => !e.keywords?.$seen);
|
||||||
|
if (newestUnread) {
|
||||||
|
selectEmail(newestUnread);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, client, mailboxes.length, emails, fetchEmailContent, selectEmail, setLoadingEmail]);
|
||||||
|
|
||||||
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEmail || !client) return;
|
if (!selectedEmail || !client) return;
|
||||||
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
// If the email lacks bodyValues, it was auto-selected from the list and needs full content.
|
||||||
if (!selectedEmail.bodyValues) {
|
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
|
||||||
|
// calling selectEmail on the stub), to avoid a duplicate request.
|
||||||
|
if (!selectedEmail.bodyValues && !isLoadingEmail) {
|
||||||
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
||||||
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -685,6 +790,8 @@ export default function Home() {
|
|||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||||
|
inReplyTo?: string[];
|
||||||
|
references?: string[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
@@ -692,7 +799,7 @@ export default function Home() {
|
|||||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||||
const originalEmailId = selectedEmail?.id;
|
const originalEmailId = selectedEmail?.id;
|
||||||
|
|
||||||
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments);
|
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references);
|
||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
|
|
||||||
// Mark the original email with $answered or $forwarded keyword
|
// Mark the original email with $answered or $forwarded keyword
|
||||||
@@ -1097,6 +1204,197 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tCtxMenu = t;
|
||||||
|
|
||||||
|
const handleMarkFolderRead = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
const count = await markMailboxAsRead(client, mailboxId);
|
||||||
|
await fetchMailboxes(client);
|
||||||
|
if (selectedMailbox === mailboxId) await fetchEmails(client, mailboxId);
|
||||||
|
if (count > 0) {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count }));
|
||||||
|
} else {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkFolderTreeRead = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const collectIds = (rootId: string): string[] => {
|
||||||
|
const ids: string[] = [rootId];
|
||||||
|
const stack = [rootId];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()!;
|
||||||
|
for (const mb of mailboxes) {
|
||||||
|
if (mb.parentId === current) {
|
||||||
|
ids.push(mb.id);
|
||||||
|
stack.push(mb.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ids = collectIds(mailboxId);
|
||||||
|
let total = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
total += await markMailboxAsRead(client, id);
|
||||||
|
}
|
||||||
|
await fetchMailboxes(client);
|
||||||
|
if (selectedMailbox && ids.includes(selectedMailbox)) await fetchEmails(client, selectedMailbox);
|
||||||
|
if (total > 0) {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
|
||||||
|
} else {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllFoldersRead = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.mark_all_confirm_title'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.mark_all_confirm_message'),
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.mark_all_folders_read'),
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const total = await client.markAllAsRead();
|
||||||
|
await fetchMailboxes(client);
|
||||||
|
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
|
||||||
|
if (total > 0) {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
|
||||||
|
} else {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmptyFolderFromContextMenu = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
|
||||||
|
if (!mailbox) return;
|
||||||
|
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: tCtxMenu('email_list.empty_folder.confirm_title'),
|
||||||
|
message: tCtxMenu('email_list.empty_folder.confirm_message'),
|
||||||
|
confirmText: tCtxMenu('email_list.empty_folder.confirm_button'),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await emptyMailbox(client, mailboxId);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_emptied'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_empty'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSubfolderFromContextMenu = async (parentId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const name = await promptDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.new_subfolder'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.prompt_new_subfolder'),
|
||||||
|
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.create'),
|
||||||
|
});
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
await createMailbox(client, name, parentId);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateFolderFromContextMenu = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
const name = await promptDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.new_folder'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.prompt_new_folder'),
|
||||||
|
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.create'),
|
||||||
|
});
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
await createMailbox(client, name);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameFolderFromContextMenu = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
|
||||||
|
if (!mailbox) return;
|
||||||
|
const name = await promptDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.rename'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.prompt_rename'),
|
||||||
|
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
|
||||||
|
defaultValue: mailbox.name,
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.rename_confirm'),
|
||||||
|
});
|
||||||
|
if (!name || name === mailbox.name) return;
|
||||||
|
try {
|
||||||
|
await renameMailbox(client, mailboxId, name);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_renamed'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_rename'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFolderFromContextMenu = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
|
||||||
|
if (!mailbox) return;
|
||||||
|
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.delete_confirm_title'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.delete_confirm_message', { name: mailbox.name }),
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.delete_folder'),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteMailbox(client, mailboxId);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_deleted'));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const jmapType = (err as Error & { jmapType?: string })?.jmapType;
|
||||||
|
if (jmapType === 'mailboxHasChild') {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_children'));
|
||||||
|
} else if (jmapType === 'mailboxHasEmail') {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_email'));
|
||||||
|
} else {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshMailboxes = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await fetchMailboxes(client);
|
||||||
|
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = logout;
|
const handleLogout = logout;
|
||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
@@ -1193,6 +1491,12 @@ export default function Home() {
|
|||||||
|
|
||||||
const originalEmailId = selectedEmail.id;
|
const originalEmailId = selectedEmail.id;
|
||||||
|
|
||||||
|
// RFC 5322 §3.6.4 threading — keep the conversation stitched together (#234).
|
||||||
|
const threading = computeReplyThreadingHeaders({
|
||||||
|
messageId: selectedEmail.messageId,
|
||||||
|
references: selectedEmail.references,
|
||||||
|
});
|
||||||
|
|
||||||
// Send reply with just the body text
|
// Send reply with just the body text
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
client,
|
client,
|
||||||
@@ -1204,7 +1508,11 @@ export default function Home() {
|
|||||||
primaryIdentity?.id,
|
primaryIdentity?.id,
|
||||||
primaryIdentity?.email,
|
primaryIdentity?.email,
|
||||||
undefined,
|
undefined,
|
||||||
primaryIdentity?.name || undefined
|
primaryIdentity?.name || undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
threading?.inReplyTo,
|
||||||
|
threading?.references,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark the original email as answered
|
// Mark the original email as answered
|
||||||
@@ -1246,7 +1554,13 @@ export default function Home() {
|
|||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set loading state immediately (keep current email visible)
|
// Show the list stub immediately so subject/sender render without
|
||||||
|
// waiting for the body fetch — avoids the loading flicker.
|
||||||
|
const listEmail = emails.find(e => e.id === email.id);
|
||||||
|
if (listEmail) {
|
||||||
|
selectEmail(listEmail);
|
||||||
|
}
|
||||||
|
|
||||||
setLoadingEmail(true);
|
setLoadingEmail(true);
|
||||||
|
|
||||||
// On mobile, switch to viewer
|
// On mobile, switch to viewer
|
||||||
@@ -1263,7 +1577,6 @@ export default function Home() {
|
|||||||
try {
|
try {
|
||||||
// In unified view each email carries its own accountId. Use that
|
// In unified view each email carries its own accountId. Use that
|
||||||
// account's client so we fetch from the server that actually owns it.
|
// account's client so we fetch from the server that actually owns it.
|
||||||
const listEmail = emails.find(e => e.id === email.id);
|
|
||||||
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
||||||
const perAccountClient = emailAccountId
|
const perAccountClient = emailAccountId
|
||||||
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
||||||
@@ -1295,15 +1608,11 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle back navigation from viewer on mobile.
|
// Handle back navigation from viewer on mobile.
|
||||||
// Delegate to the browser history stack so this button is equivalent to
|
// Reset to list state directly. We can't just call window.history.back()
|
||||||
// the OS back button / mouse back button - popstate then restores the
|
// because the nav hook pushes a new entry for every email the user opens,
|
||||||
// previous snapshot via handleNavRestore. The viewer is only reachable
|
// so history.back() would pop to the previous email rather than the list.
|
||||||
// from a state that pushed history, so back() always lands on an app entry.
|
// The OS / hardware back button is still wired through popstate → handleNavRestore.
|
||||||
const handleMobileBack = () => {
|
const handleMobileBack = () => {
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.history.back();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (conversationThread) {
|
if (conversationThread) {
|
||||||
setConversationThread(null);
|
setConversationThread(null);
|
||||||
setConversationEmails([]);
|
setConversationEmails([]);
|
||||||
@@ -1458,6 +1767,15 @@ export default function Home() {
|
|||||||
onMailboxSelect={handleMailboxSelect}
|
onMailboxSelect={handleMailboxSelect}
|
||||||
onTagSelect={handleTagSelect}
|
onTagSelect={handleTagSelect}
|
||||||
onUnreadFilterClick={handleUnreadFilterClick}
|
onUnreadFilterClick={handleUnreadFilterClick}
|
||||||
|
onMarkFolderRead={handleMarkFolderRead}
|
||||||
|
onMarkFolderTreeRead={handleMarkFolderTreeRead}
|
||||||
|
onMarkAllFoldersRead={handleMarkAllFoldersRead}
|
||||||
|
onEmptyFolder={handleEmptyFolderFromContextMenu}
|
||||||
|
onCreateSubfolder={handleCreateSubfolderFromContextMenu}
|
||||||
|
onCreateFolder={handleCreateFolderFromContextMenu}
|
||||||
|
onRenameFolder={handleRenameFolderFromContextMenu}
|
||||||
|
onDeleteFolder={handleDeleteFolderFromContextMenu}
|
||||||
|
onRefreshMailboxes={handleRefreshMailboxes}
|
||||||
onCompose={() => {
|
onCompose={() => {
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -1850,6 +2168,9 @@ export default function Home() {
|
|||||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||||
receivedAt: selectedEmail.receivedAt,
|
receivedAt: selectedEmail.receivedAt,
|
||||||
attachments: selectedEmail.attachments,
|
attachments: selectedEmail.attachments,
|
||||||
|
messageId: selectedEmail.messageId,
|
||||||
|
inReplyTo: selectedEmail.inReplyTo,
|
||||||
|
references: selectedEmail.references,
|
||||||
} : undefined)}
|
} : undefined)}
|
||||||
initialDraftText={composerDraftText}
|
initialDraftText={composerDraftText}
|
||||||
initialData={pendingDraft}
|
initialData={pendingDraft}
|
||||||
@@ -1934,12 +2255,12 @@ export default function Home() {
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
onDelete={handleDelete}
|
onDelete={() => handleDelete()}
|
||||||
onArchive={() => handleArchive()}
|
onArchive={() => handleArchive()}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onSetColorTag={handleSetColorTag}
|
onSetColorTag={handleSetColorTag}
|
||||||
onMarkAsSpam={handleMarkAsSpam}
|
onMarkAsSpam={() => handleMarkAsSpam()}
|
||||||
onUndoSpam={handleUndoSpam}
|
onUndoSpam={() => handleUndoSpam()}
|
||||||
onMarkAsRead={async (emailId, read) => {
|
onMarkAsRead={async (emailId, read) => {
|
||||||
if (client) {
|
if (client) {
|
||||||
await markAsRead(client, emailId, read);
|
await markAsRead(client, emailId, read);
|
||||||
@@ -2009,6 +2330,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
<PromptDialog {...promptDialogProps} />
|
||||||
<TotpReauthDialog />
|
<TotpReauthDialog />
|
||||||
</div>
|
</div>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
|
|||||||
+166
-1
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
@@ -69,6 +69,58 @@ export default function AdminAuthPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [setupRunning, setSetupRunning] = useState(false);
|
||||||
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
|
const [setupOrigin, setSetupOrigin] = useState('');
|
||||||
|
const [setupIssuer, setSetupIssuer] = useState('');
|
||||||
|
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
|
||||||
|
|
||||||
|
function openSetupDialog() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
|
||||||
|
setSetupOrigin(origin);
|
||||||
|
setSetupIssuer(jmapUrl || origin);
|
||||||
|
setSetupOauthOnly(currentValue('oauthOnly') === true);
|
||||||
|
setSetupOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAutoSetup() {
|
||||||
|
setSetupRunning(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/oauth/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
origin: setupOrigin.trim().replace(/\/+$/, ''),
|
||||||
|
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
|
||||||
|
oauthOnly: setupOauthOnly,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
|
||||||
|
});
|
||||||
|
setEdits({});
|
||||||
|
setSetupOpen(false);
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
|
||||||
|
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
|
||||||
|
} finally {
|
||||||
|
setSetupRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
|
||||||
|
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
|
||||||
|
|
||||||
const hasEdits = Object.keys(edits).length > 0;
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -100,6 +152,119 @@ export default function AdminAuthPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Auto-setup */}
|
||||||
|
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
|
||||||
|
Requires your Stalwart account to have admin permissions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openSetupDialog}
|
||||||
|
disabled={setupRunning}
|
||||||
|
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-setup dialog */}
|
||||||
|
{setupOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="oauth-setup-title"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
|
||||||
|
<div className="px-5 py-4 border-b border-border">
|
||||||
|
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
||||||
|
Webmail origin
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-origin"
|
||||||
|
type="url"
|
||||||
|
value={setupOrigin}
|
||||||
|
onChange={(e) => setSetupOrigin(e.target.value)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
placeholder="https://webmail.example.com"
|
||||||
|
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback</code>) on Stalwart.
|
||||||
|
</p>
|
||||||
|
{!setupOriginValid && setupOrigin.length > 0 && (
|
||||||
|
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
|
||||||
|
Stalwart issuer URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-issuer"
|
||||||
|
type="url"
|
||||||
|
value={setupIssuer}
|
||||||
|
onChange={(e) => setSetupIssuer(e.target.value)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
placeholder="https://mail.example.com"
|
||||||
|
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
|
||||||
|
</p>
|
||||||
|
{!setupIssuerValid && setupIssuer.length > 0 && (
|
||||||
|
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setupOauthOnly}
|
||||||
|
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-input"
|
||||||
|
disabled={setupRunning}
|
||||||
|
/>
|
||||||
|
Also enable “OAuth only” (hide password login)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setSetupOpen(false)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoSetup}
|
||||||
|
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{setupRunning ? 'Configuring…' : 'Configure'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* OAuth */}
|
{/* OAuth */}
|
||||||
<Section title="OAuth / OpenID Connect">
|
<Section title="OAuth / OpenID Connect">
|
||||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
|||||||
+59
-48
@@ -14,6 +14,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
|
Activity,
|
||||||
Mail,
|
Mail,
|
||||||
Calendar,
|
Calendar,
|
||||||
BookUser,
|
BookUser,
|
||||||
@@ -56,6 +57,7 @@ const NAV_GROUPS = [
|
|||||||
{
|
{
|
||||||
label: 'System',
|
label: 'System',
|
||||||
items: [
|
items: [
|
||||||
|
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
|
||||||
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -65,6 +67,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||||
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
@@ -73,54 +76,59 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname !== '/admin/login') {
|
if (pathname === '/admin/login') return;
|
||||||
checkAuth();
|
let cancelled = false;
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
function getJmapHeaders(): Record<string, string> {
|
async function checkAuth() {
|
||||||
return getActiveAccountSlotHeaders();
|
try {
|
||||||
}
|
const jmapHeaders = getActiveAccountSlotHeaders();
|
||||||
|
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||||
|
const data = await res.json();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
async function checkAuth() {
|
const stalwartAdmin = data.stalwartAdmin === true;
|
||||||
try {
|
setIsStalwartAdmin(stalwartAdmin);
|
||||||
const jmapHeaders = getJmapHeaders();
|
|
||||||
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
const stalwartAdmin = data.stalwartAdmin === true;
|
// If neither password-based admin nor Stalwart admin, redirect away
|
||||||
setIsStalwartAdmin(stalwartAdmin);
|
if (!data.enabled && !stalwartAdmin) {
|
||||||
|
router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If neither password-based admin nor Stalwart admin, redirect away
|
if (data.authenticated) {
|
||||||
if (!data.enabled && !stalwartAdmin) {
|
|
||||||
router.replace('/');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.authenticated) {
|
|
||||||
setAuthenticated(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If Stalwart admin but not yet authenticated, auto-login
|
|
||||||
if (stalwartAdmin) {
|
|
||||||
const loginRes = await apiFetch('/api/admin/auth', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
|
||||||
body: JSON.stringify({ stalwartAuth: true }),
|
|
||||||
});
|
|
||||||
if (loginRes.ok) {
|
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
router.replace('/admin/login');
|
// If Stalwart admin but not yet authenticated, auto-login
|
||||||
} catch {
|
if (stalwartAdmin) {
|
||||||
router.replace('/admin/login');
|
const loginRes = await apiFetch('/api/admin/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||||
|
body: JSON.stringify({ stalwartAuth: true }),
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (loginRes.ok) {
|
||||||
|
setAuthenticated(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await loginRes.json().catch(() => ({}));
|
||||||
|
setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
|
||||||
|
setAuthenticated(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace('/admin/login');
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
|
||||||
|
setAuthenticated(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
checkAuth();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [pathname, router]);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||||
@@ -132,14 +140,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authenticated === null) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
||||||
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
{/* Slim webmail nav rail */}
|
{/* Slim webmail nav rail */}
|
||||||
@@ -269,7 +269,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto">
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
{children}
|
{authError ? (
|
||||||
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
<p className="font-medium">Admin authentication failed</p>
|
||||||
|
<p className="mt-1 text-destructive/80">{authError}</p>
|
||||||
|
</div>
|
||||||
|
) : authenticated === null ? (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
|
||||||
|
Loading admin panel…
|
||||||
|
</div>
|
||||||
|
) : authenticated ? (
|
||||||
|
children
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,542 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
|
Puzzle,
|
||||||
|
SwatchBook,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
ExternalLink,
|
||||||
|
Shield,
|
||||||
|
AlertTriangle,
|
||||||
|
FileCode,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface PreviewData {
|
||||||
|
extension: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
type: 'plugin' | 'theme';
|
||||||
|
pluginType: string | null;
|
||||||
|
description: string;
|
||||||
|
longDescription: string | null;
|
||||||
|
tags: string[];
|
||||||
|
permissions: string[];
|
||||||
|
totalDownloads: number;
|
||||||
|
featured: boolean;
|
||||||
|
githubRepo: string | null;
|
||||||
|
license: string | null;
|
||||||
|
minAppVersion: string | null;
|
||||||
|
author: {
|
||||||
|
displayName: string;
|
||||||
|
githubLogin: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
verified?: boolean;
|
||||||
|
} | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
versions: Array<{
|
||||||
|
version: string;
|
||||||
|
changelog: string | null;
|
||||||
|
bundleSize: number;
|
||||||
|
minAppVersion: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
permissions: string[];
|
||||||
|
}>;
|
||||||
|
screenshots: Array<{ url: string; altText: string | null }>;
|
||||||
|
themePreviews: Array<{
|
||||||
|
variant: 'light' | 'dark';
|
||||||
|
previewPath: string;
|
||||||
|
colors: Record<string, string> | null;
|
||||||
|
}>;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
bundle: {
|
||||||
|
manifest: Record<string, unknown> | null;
|
||||||
|
source: { name: string; content: string; truncated: boolean } | null;
|
||||||
|
size: number;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
installed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISKY_PERMISSIONS = new Set([
|
||||||
|
'mail:write',
|
||||||
|
'mail:delete',
|
||||||
|
'storage:write',
|
||||||
|
'network',
|
||||||
|
'admin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default function MarketplacePreviewPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const slug = params.slug as string;
|
||||||
|
|
||||||
|
const [data, setData] = useState<PreviewData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const [uninstalling, setUninstalling] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [showSource, setShowSource] = useState(false);
|
||||||
|
const [showManifest, setShowManifest] = useState(false);
|
||||||
|
|
||||||
|
const fetchPreview = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
setError(body.error || 'Failed to load preview');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setData(await res.json());
|
||||||
|
} catch {
|
||||||
|
setError('Failed to connect to extension directory');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPreview(); }, [fetchPreview]);
|
||||||
|
|
||||||
|
async function handleInstall() {
|
||||||
|
if (!data) return;
|
||||||
|
setInstalling(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/marketplace', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: data.extension.slug,
|
||||||
|
version: data.extension.latestVersion || '1.0.0',
|
||||||
|
type: data.extension.type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` });
|
||||||
|
setData(prev => prev ? { ...prev, installed: true } : prev);
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: body.error || 'Installation failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||||
|
} finally {
|
||||||
|
setInstalling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUninstall() {
|
||||||
|
if (!data) return;
|
||||||
|
if (!confirm(`Remove "${data.extension.name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setUninstalling(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const endpoint = data.extension.type === 'theme'
|
||||||
|
? '/api/admin/themes'
|
||||||
|
: '/api/admin/plugins';
|
||||||
|
const res = await apiFetch(endpoint, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: data.extension.slug }),
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: `"${data.extension.name}" removed` });
|
||||||
|
setData(prev => prev ? { ...prev, installed: false } : prev);
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: body.error || 'Uninstall failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Uninstall failed - network error' });
|
||||||
|
} finally {
|
||||||
|
setUninstalling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link
|
||||||
|
href="/admin/marketplace"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-destructive">{error || 'Extension not found'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = data.extension;
|
||||||
|
const bundle = data.bundle;
|
||||||
|
const isPlugin = ext.type === 'plugin';
|
||||||
|
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
||||||
|
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
||||||
|
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
href="/admin/marketplace"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
|
{isPlugin ? (
|
||||||
|
<Puzzle className="w-7 h-7 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<SwatchBook className="w-7 h-7 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground truncate">{ext.name}</h1>
|
||||||
|
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||||
|
{data.installed && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
|
||||||
|
<Check className="w-3 h-3" /> Installed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||||
|
isPlugin
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
|
||||||
|
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
|
||||||
|
}`}>
|
||||||
|
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
|
||||||
|
</span>
|
||||||
|
{ext.author && (
|
||||||
|
<span>by {ext.author.displayName}</span>
|
||||||
|
)}
|
||||||
|
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
|
||||||
|
{ext.license && <span>{ext.license}</span>}
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
{ext.totalDownloads.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{data.installed ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<SettingsIcon className="w-4 h-4" />
|
||||||
|
Manage
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleUninstall}
|
||||||
|
disabled={uninstalling}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{uninstalling ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||||
|
Uninstall
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={installing || !!bundle.error}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bundle.error && (
|
||||||
|
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Could not preview bundle</p>
|
||||||
|
<p className="text-xs mt-0.5 opacity-90">{bundle.error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">About</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">{ext.description}</p>
|
||||||
|
{ext.longDescription && ext.longDescription !== ext.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-3 whitespace-pre-wrap">{ext.longDescription}</p>
|
||||||
|
)}
|
||||||
|
{ext.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
|
{ext.tags.map(tag => (
|
||||||
|
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-4 pt-3 border-t border-border flex-wrap">
|
||||||
|
{ext.minAppVersion && <span>Requires app v{ext.minAppVersion}+</span>}
|
||||||
|
{bundle.size > 0 && <span>Bundle: {(bundle.size / 1024).toFixed(1)} KB</span>}
|
||||||
|
{ext.githubRepo && (
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${ext.githubRepo}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
{ext.githubRepo}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Screenshots */}
|
||||||
|
{ext.screenshots.length > 0 && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Screenshots</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||||
|
{ext.screenshots.map((s, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={s.url}
|
||||||
|
alt={s.altText || `Screenshot ${i + 1}`}
|
||||||
|
className="w-full rounded-md border border-border bg-muted"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme color preview */}
|
||||||
|
{!isPlugin && ext.themePreviews.length > 0 && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Theme preview</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||||
|
{ext.themePreviews.map(preview => (
|
||||||
|
<ThemeColorSwatch key={preview.variant} preview={preview} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
{isPlugin && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Permissions</h2>
|
||||||
|
</div>
|
||||||
|
{manifestPerms.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">This plugin requests no permissions.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 space-y-1.5">
|
||||||
|
{manifestPerms.map(perm => {
|
||||||
|
const risky = RISKY_PERMISSIONS.has(perm);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={perm}
|
||||||
|
className={`flex items-center gap-2 text-sm rounded-md px-2 py-1 ${
|
||||||
|
risky
|
||||||
|
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
|
||||||
|
: 'bg-muted/50 text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{risky && <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
|
||||||
|
<code className="font-mono text-xs">{perm}</code>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{frameOrigins.length > 0 && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-border">
|
||||||
|
<h3 className="text-xs font-medium text-foreground">Iframe origins</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
The plugin will be allowed to embed content from these origins.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{frameOrigins.map(origin => (
|
||||||
|
<li key={origin} className="text-xs font-mono text-foreground bg-muted/50 px-2 py-1 rounded">
|
||||||
|
{origin}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings schema preview */}
|
||||||
|
{isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">User settings</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Settings users will be able to configure after install.</p>
|
||||||
|
<ul className="mt-3 divide-y divide-border">
|
||||||
|
{Object.entries(settingsSchema).map(([key, field]) => (
|
||||||
|
<li key={key} className="py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-foreground">{key}</code>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{field.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-foreground mt-0.5">{field.label}</div>
|
||||||
|
{field.description && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{field.description}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source / manifest disclosure */}
|
||||||
|
{bundle.manifest && (
|
||||||
|
<section className="border border-border rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowManifest(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">manifest.json</h2>
|
||||||
|
</div>
|
||||||
|
{showManifest ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
{showManifest && (
|
||||||
|
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre">
|
||||||
|
{JSON.stringify(bundle.manifest, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bundle.source && (
|
||||||
|
<section className="border border-border rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSource(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">{bundle.source.name}</h2>
|
||||||
|
{bundle.source.truncated && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">truncated</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showSource ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
{showSource && (
|
||||||
|
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre max-h-[600px] overflow-y-auto">
|
||||||
|
{bundle.source.content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Version history */}
|
||||||
|
{ext.versions.length > 0 && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Version history</h2>
|
||||||
|
<ul className="mt-3 divide-y divide-border">
|
||||||
|
{ext.versions.slice(0, 5).map(v => (
|
||||||
|
<li key={v.version} className="py-2 flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-foreground">v{v.version}</code>
|
||||||
|
{v.publishedAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(v.publishedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{v.changelog && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 whitespace-pre-wrap">{v.changelog}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{(v.bundleSize / 1024).toFixed(1)} KB
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record<string, string> | null } }) {
|
||||||
|
const colors = preview.colors || {};
|
||||||
|
const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff');
|
||||||
|
const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a');
|
||||||
|
const accent = colors.primary || colors.accent || '#7c5cff';
|
||||||
|
const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5');
|
||||||
|
const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border overflow-hidden">
|
||||||
|
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b border-border capitalize">
|
||||||
|
{preview.variant}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2" style={{ background: bg, color: fg }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-block w-6 h-6 rounded" style={{ background: accent }} />
|
||||||
|
<span className="text-sm font-medium" style={{ color: fg }}>Sample text</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded p-2 text-xs" style={{ background: muted, border: `1px solid ${border}` }}>
|
||||||
|
<span style={{ color: fg }}>Card surface</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Object.entries(colors).slice(0, 6).map(([key, value]) => (
|
||||||
|
<span
|
||||||
|
key={key}
|
||||||
|
title={`${key}: ${value}`}
|
||||||
|
className="inline-block w-4 h-4 rounded border"
|
||||||
|
style={{ background: value, borderColor: border }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
|
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface Extension {
|
interface Extension {
|
||||||
@@ -262,10 +263,11 @@ function ExtensionCard({
|
|||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isPlugin = extension.type === 'plugin';
|
const isPlugin = extension.type === 'plugin';
|
||||||
|
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||||
<div className="p-4">
|
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
|
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
|
||||||
@@ -277,7 +279,9 @@ function ExtensionCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm font-medium text-foreground truncate">{extension.name}</span>
|
<span className="text-sm font-medium text-foreground truncate group-hover:underline">
|
||||||
|
{extension.name}
|
||||||
|
</span>
|
||||||
{extension.featured && (
|
{extension.featured && (
|
||||||
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
||||||
)}
|
)}
|
||||||
@@ -315,7 +319,7 @@ function ExtensionCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer (download count + permissions) */}
|
||||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
@@ -328,27 +332,34 @@ function ExtensionCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
|
||||||
{extension.installed ? (
|
<Eye className="w-3 h-3" />
|
||||||
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
Preview
|
||||||
<Check className="w-3 h-3" />
|
</span>
|
||||||
Installed
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={onInstall}
|
|
||||||
disabled={installing}
|
|
||||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{installing ? (
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Install
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Quick install button (sits over the link, stops navigation) */}
|
||||||
|
<div className="px-4 pb-4 -mt-1">
|
||||||
|
{extension.installed ? (
|
||||||
|
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Installed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||||
|
disabled={installing}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Quick install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+19
-1
@@ -31,6 +31,7 @@ export default function AdminDashboardPage() {
|
|||||||
const [pluginCount, setPluginCount] = useState(0);
|
const [pluginCount, setPluginCount] = useState(0);
|
||||||
const [themeCount, setThemeCount] = useState(0);
|
const [themeCount, setThemeCount] = useState(0);
|
||||||
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
||||||
|
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
||||||
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,7 +39,7 @@ export default function AdminDashboardPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function fetchDashboardData() {
|
async function fetchDashboardData() {
|
||||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
|
||||||
apiFetch('/api/admin/auth'),
|
apiFetch('/api/admin/auth'),
|
||||||
apiFetch('/api/admin/audit?limit=10'),
|
apiFetch('/api/admin/audit?limit=10'),
|
||||||
apiFetch('/api/config'),
|
apiFetch('/api/config'),
|
||||||
@@ -46,6 +47,7 @@ export default function AdminDashboardPage() {
|
|||||||
apiFetch('/api/admin/plugins').catch(() => null),
|
apiFetch('/api/admin/plugins').catch(() => null),
|
||||||
apiFetch('/api/admin/themes').catch(() => null),
|
apiFetch('/api/admin/themes').catch(() => null),
|
||||||
apiFetch('/api/admin/policy').catch(() => null),
|
apiFetch('/api/admin/policy').catch(() => null),
|
||||||
|
apiFetch('/api/admin/telemetry').catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (statusRes.ok) setStatus(await statusRes.json());
|
if (statusRes.ok) setStatus(await statusRes.json());
|
||||||
@@ -73,6 +75,12 @@ export default function AdminDashboardPage() {
|
|||||||
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
||||||
setPolicyRuleCount(restrictionCount + disabledGates);
|
setPolicyRuleCount(restrictionCount + disabledGates);
|
||||||
}
|
}
|
||||||
|
if (telemetryRes?.ok) {
|
||||||
|
const telemetry = await telemetryRes.json();
|
||||||
|
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
|
||||||
|
setAccountCounts(telemetry.accountCounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (configData?.jmapServerUrl) {
|
if (configData?.jmapServerUrl) {
|
||||||
try {
|
try {
|
||||||
@@ -165,6 +173,16 @@ export default function AdminDashboardPage() {
|
|||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Accounts */}
|
||||||
|
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
|
||||||
|
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
|
||||||
|
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
|
||||||
|
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Extensions */}
|
{/* Extensions */}
|
||||||
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
||||||
<SettingItem label="Plugins">
|
<SettingItem label="Plugins">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
|||||||
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||||
|
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||||
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
||||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface TelemetryStatus {
|
||||||
|
consent: 'pending' | 'on' | 'off';
|
||||||
|
consentSource: 'env' | 'file';
|
||||||
|
endpoint: string;
|
||||||
|
defaultEndpoint: string;
|
||||||
|
consentedAt: string | null;
|
||||||
|
lastSentAt: string | null;
|
||||||
|
nextScheduledAt: string | null;
|
||||||
|
payloadPreview: Record<string, unknown>;
|
||||||
|
accountCounts: { total: number; active7d: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso: string | null): string {
|
||||||
|
if (!iso) return 'never';
|
||||||
|
const d = Date.now() - new Date(iso).getTime();
|
||||||
|
if (d < 0) return new Date(iso).toLocaleString();
|
||||||
|
const m = Math.floor(d / 60000);
|
||||||
|
if (m < 1) return 'just now';
|
||||||
|
if (m < 60) return `${m} min ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 48) return `${h} hours ago`;
|
||||||
|
const days = Math.floor(h / 24);
|
||||||
|
return `${days} days ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminTelemetryPage() {
|
||||||
|
const [status, setStatus] = useState<TelemetryStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [endpointDraft, setEndpointDraft] = useState('');
|
||||||
|
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry');
|
||||||
|
if (!r.ok) throw new Error('failed to load');
|
||||||
|
const data = (await r.json()) as TelemetryStatus;
|
||||||
|
setStatus(data);
|
||||||
|
setEndpointDraft(data.endpoint);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { void refresh(); }, []);
|
||||||
|
|
||||||
|
async function setConsent(consent: 'on' | 'off'): Promise<void> {
|
||||||
|
setBusy('consent');
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'set-consent', consent }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||||
|
alert(j.error ?? 'failed');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEndpoint(): Promise<void> {
|
||||||
|
setBusy('endpoint');
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||||
|
alert(j.error ?? 'failed');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNow(): Promise<void> {
|
||||||
|
setBusy('send');
|
||||||
|
setSendResult(null);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'send-now' }),
|
||||||
|
});
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
|
||||||
|
setSendResult({
|
||||||
|
ok: !!j.ok,
|
||||||
|
msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !status) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envOverridden = status.consentSource === 'env';
|
||||||
|
const isOn = status.consent === 'on';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
|
||||||
|
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
|
||||||
|
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
|
||||||
|
<a
|
||||||
|
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Full schema and policy <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Status</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
||||||
|
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
|
||||||
|
{status.consent === 'off' && 'Heartbeats are off.'}
|
||||||
|
{envOverridden && (
|
||||||
|
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'consent' || envOverridden || isOn}
|
||||||
|
onClick={() => void setConsent('on')}
|
||||||
|
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
|
||||||
|
onClick={() => void setConsent('off')}
|
||||||
|
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
|
||||||
|
<dt className="text-muted-foreground">Last sent</dt>
|
||||||
|
<dd>{timeAgo(status.lastSentAt)}</dd>
|
||||||
|
<dt className="text-muted-foreground">Next scheduled</dt>
|
||||||
|
<dd>{timeAgo(status.nextScheduledAt)}</dd>
|
||||||
|
<dt className="text-muted-foreground">Consented at</dt>
|
||||||
|
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-2">
|
||||||
|
<div className="font-medium">Account activity</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Unique accounts that have logged in over the last 90 days. Identities are stored as a
|
||||||
|
per-instance HMAC, never as plaintext usernames. These are the numbers reported in the
|
||||||
|
heartbeat as bucketed ranges.
|
||||||
|
</p>
|
||||||
|
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
|
||||||
|
<dt className="text-muted-foreground">Total (90d)</dt>
|
||||||
|
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
|
||||||
|
<dt className="text-muted-foreground">Active (7d)</dt>
|
||||||
|
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="font-medium">Endpoint</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
|
||||||
|
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={endpointDraft}
|
||||||
|
onChange={(e) => setEndpointDraft(e.target.value)}
|
||||||
|
placeholder={status.defaultEndpoint}
|
||||||
|
className="flex-1 px-3 py-1.5 rounded-md border bg-background"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
|
||||||
|
onClick={() => void saveEndpoint()}
|
||||||
|
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" /> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Payload preview</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Exactly what the next heartbeat would send from this install, right now.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'send' || !isOn}
|
||||||
|
onClick={() => void sendNow()}
|
||||||
|
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" /> Send now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sendResult && (
|
||||||
|
<div
|
||||||
|
className={`text-sm flex items-center gap-2 ${
|
||||||
|
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||||
|
{sendResult.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
|
||||||
|
{JSON.stringify(status.payloadPreview, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -53,11 +53,19 @@ export async function GET(
|
|||||||
|
|
||||||
const buffer = await readFile(resolved);
|
const buffer = await readFile(resolved);
|
||||||
|
|
||||||
|
// SVG can carry inline <script> and event handlers that execute when the
|
||||||
|
// file is fetched as a top-level document. Defense in depth on top of
|
||||||
|
// admin-only upload: nosniff blocks MIME confusion, the CSP forces a
|
||||||
|
// sandboxed unique origin so any script in an SVG is inert and cannot
|
||||||
|
// touch app cookies or storage.
|
||||||
return new NextResponse(buffer, {
|
return new NextResponse(buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
'Content-Length': String(buffer.length),
|
'Content-Length': String(buffer.length),
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Content-Security-Policy':
|
||||||
|
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
getPluginRegistry,
|
||||||
|
getThemeRegistry,
|
||||||
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||||
|
|
||||||
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||||
|
|
||||||
|
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/marketplace/[slug]
|
||||||
|
* Returns full preview info for an extension: directory metadata,
|
||||||
|
* the bundle's manifest, a (truncated) source preview, and install status.
|
||||||
|
* Lets admins audit what they're about to install before pressing the button.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
// 1. Extension metadata + screenshots + theme previews from the directory
|
||||||
|
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
|
||||||
|
const detailRes = await fetch(detailUrl.toString(), {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!detailRes.ok) {
|
||||||
|
const status = detailRes.status === 404 ? 404 : 502;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: status === 404 ? 'Extension not found' : 'Directory request failed' },
|
||||||
|
{ status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailJson = await detailRes.json();
|
||||||
|
const extension = detailJson.data as Record<string, unknown> | undefined;
|
||||||
|
if (!extension) {
|
||||||
|
return NextResponse.json({ error: 'Extension not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = extension.type as 'plugin' | 'theme';
|
||||||
|
const latestVersion = (extension.latestVersion as { version?: string } | null)?.version
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
// 2. Pull the bundle so we can show what's actually inside.
|
||||||
|
let manifest: Record<string, unknown> | null = null;
|
||||||
|
let sourcePreview: { name: string; content: string; truncated: boolean } | null = null;
|
||||||
|
let bundleError: string | null = null;
|
||||||
|
let bundleSize = 0;
|
||||||
|
|
||||||
|
if (latestVersion) {
|
||||||
|
try {
|
||||||
|
const bundleUrl = new URL(
|
||||||
|
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
||||||
|
DIRECTORY_URL,
|
||||||
|
);
|
||||||
|
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bundleRes.ok) {
|
||||||
|
bundleError = `Bundle download failed (${bundleRes.status})`;
|
||||||
|
} else {
|
||||||
|
const buffer = await bundleRes.arrayBuffer();
|
||||||
|
bundleSize = buffer.byteLength;
|
||||||
|
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
|
||||||
|
if (buffer.byteLength > maxSize) {
|
||||||
|
bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`;
|
||||||
|
} else {
|
||||||
|
const zip = await JSZip.loadAsync(buffer);
|
||||||
|
|
||||||
|
// Detect optional root directory inside the ZIP.
|
||||||
|
const entries = Object.keys(zip.files);
|
||||||
|
const topDirs = new Set(entries.map((e) => e.split('/')[0]));
|
||||||
|
let root = '';
|
||||||
|
if (topDirs.size === 1) {
|
||||||
|
const dir = [...topDirs][0];
|
||||||
|
if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) {
|
||||||
|
root = dir + '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
|
if (!manifestFile) {
|
||||||
|
bundleError = 'Bundle missing manifest.json';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
bundleError = 'Invalid manifest.json in bundle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest) {
|
||||||
|
if (type === 'theme') {
|
||||||
|
const cssFile = zip.file(root + 'theme.css');
|
||||||
|
if (cssFile) {
|
||||||
|
const css = await cssFile.async('string');
|
||||||
|
sourcePreview = {
|
||||||
|
name: 'theme.css',
|
||||||
|
content: css.length > MAX_PREVIEW_SOURCE_LEN
|
||||||
|
? css.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||||
|
: css,
|
||||||
|
truncated: css.length > MAX_PREVIEW_SOURCE_LEN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const entrypoint = (manifest.entrypoint as string) || 'index.js';
|
||||||
|
const jsFile = zip.file(root + entrypoint);
|
||||||
|
if (jsFile) {
|
||||||
|
const code = await jsFile.async('string');
|
||||||
|
sourcePreview = {
|
||||||
|
name: entrypoint,
|
||||||
|
content: code.length > MAX_PREVIEW_SOURCE_LEN
|
||||||
|
? code.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||||
|
: code,
|
||||||
|
truncated: code.length > MAX_PREVIEW_SOURCE_LEN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
bundleError = err instanceof Error ? err.message : 'Failed to read bundle';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bundleError = 'Extension has no published version';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Install status (slug is used as the registry id at install time)
|
||||||
|
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||||
|
getPluginRegistry(),
|
||||||
|
getThemeRegistry(),
|
||||||
|
]);
|
||||||
|
const installed = type === 'theme'
|
||||||
|
? themeRegistry.themes.some((t) => t.id === slug)
|
||||||
|
: pluginRegistry.plugins.some((p) => p.id === slug);
|
||||||
|
|
||||||
|
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
||||||
|
const screenshots = Array.isArray(extension.screenshots)
|
||||||
|
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
||||||
|
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
|
||||||
|
altText: s.altText ?? null,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Strip the heavy `manifest` blob from versions when echoing the directory data.
|
||||||
|
const versions = Array.isArray(extension.versions)
|
||||||
|
? (extension.versions as Array<Record<string, unknown>>).map((v) => ({
|
||||||
|
version: v.version,
|
||||||
|
changelog: v.changelog,
|
||||||
|
bundleSize: v.bundleSize,
|
||||||
|
minAppVersion: v.minAppVersion,
|
||||||
|
publishedAt: v.publishedAt,
|
||||||
|
permissions: v.permissions,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
extension: {
|
||||||
|
slug: extension.slug,
|
||||||
|
name: extension.name,
|
||||||
|
type: extension.type,
|
||||||
|
pluginType: extension.pluginType ?? null,
|
||||||
|
description: extension.description,
|
||||||
|
longDescription: extension.longDescription ?? null,
|
||||||
|
tags: extension.tags ?? [],
|
||||||
|
permissions: extension.permissions ?? [],
|
||||||
|
totalDownloads: extension.totalDownloads ?? 0,
|
||||||
|
featured: extension.featured ?? false,
|
||||||
|
githubRepo: extension.githubRepo ?? null,
|
||||||
|
license: extension.license ?? null,
|
||||||
|
minAppVersion: extension.minAppVersion ?? null,
|
||||||
|
author: extension.author ?? null,
|
||||||
|
latestVersion,
|
||||||
|
versions,
|
||||||
|
screenshots,
|
||||||
|
themePreviews: extension.themePreviews ?? [],
|
||||||
|
createdAt: extension.createdAt ?? null,
|
||||||
|
updatedAt: extension.updatedAt ?? null,
|
||||||
|
},
|
||||||
|
bundle: {
|
||||||
|
manifest,
|
||||||
|
source: sourcePreview,
|
||||||
|
size: bundleSize,
|
||||||
|
error: bundleError,
|
||||||
|
},
|
||||||
|
installed,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Marketplace preview error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to load preview' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,15 @@ import {
|
|||||||
type ServerPlugin,
|
type ServerPlugin,
|
||||||
type ServerTheme,
|
type ServerTheme,
|
||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import {
|
||||||
|
sanitizeFrameOrigins,
|
||||||
|
invalidateFrameOriginsCache,
|
||||||
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/marketplace - Search/browse the extension directory
|
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||||
@@ -226,6 +230,22 @@ export async function POST(request: NextRequest) {
|
|||||||
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugins may declare iframe origins they need for embedded content.
|
||||||
|
// Anything that doesn't pass strict origin validation is silently
|
||||||
|
// dropped - the plugin still installs, but those origins are not
|
||||||
|
// added to the host CSP.
|
||||||
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
|
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
||||||
|
? (manifest.frameOrigins as unknown[]).filter(
|
||||||
|
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (droppedFrameOrigins.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
id: (manifest.id as string) || slug,
|
id: (manifest.id as string) || slug,
|
||||||
name: (manifest.name as string) || slug,
|
name: (manifest.name as string) || slug,
|
||||||
@@ -238,10 +258,14 @@ export async function POST(request: NextRequest) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
...(declaredFrameOrigins.length > 0
|
||||||
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
|
invalidateFrameOriginsCache();
|
||||||
|
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, plugin, warnings });
|
return NextResponse.json({ success: true, plugin, warnings });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { locales as ALL_LOCALES } from '@/i18n/routing';
|
||||||
|
|
||||||
|
const CLIENT_ID = 'bulwark-webmail';
|
||||||
|
const CLIENT_DESCRIPTION = 'Bulwark Webmail (auto-configured)';
|
||||||
|
const JMAP_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
interface JmapMethodCall {
|
||||||
|
using: string[];
|
||||||
|
methodCalls: Array<[string, Record<string, unknown>, string]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JmapMethodResponse {
|
||||||
|
methodResponses?: Array<[string, Record<string, unknown>, string]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jmapCall(
|
||||||
|
serverUrl: string,
|
||||||
|
authHeader: string,
|
||||||
|
body: JmapMethodCall,
|
||||||
|
): Promise<JmapMethodResponse> {
|
||||||
|
const res = await fetchWithTimeout(`${serverUrl}/jmap/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<JmapMethodResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStalwartAccountId(
|
||||||
|
serverUrl: string,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const res = await fetchWithTimeout(`${serverUrl}/.well-known/jmap`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': authHeader },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const session = await res.json() as { primaryAccounts?: Record<string, string> };
|
||||||
|
return session.primaryAccounts?.['urn:stalwart:jmap']
|
||||||
|
?? session.primaryAccounts?.['urn:ietf:params:jmap:mail']
|
||||||
|
?? Object.values(session.primaryAccounts ?? {})[0]
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRedirectUris(origin: string, localeList: readonly string[]): Record<string, true> {
|
||||||
|
const out: Record<string, true> = {};
|
||||||
|
for (const loc of localeList) {
|
||||||
|
out[`${origin}/${loc}/auth/callback`] = true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupRequestBody {
|
||||||
|
origin?: string;
|
||||||
|
issuerUrl?: string;
|
||||||
|
locales?: string[];
|
||||||
|
oauthOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidOriginUrl(value: string): boolean {
|
||||||
|
return /^https?:\/\/[^/]+$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAdminAuth();
|
||||||
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json() as SetupRequestBody;
|
||||||
|
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
|
||||||
|
if (!isValidOriginUrl(origin)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, '');
|
||||||
|
if (!isValidOriginUrl(issuerUrl)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const localeList = Array.isArray(body.locales) && body.locales.length > 0
|
||||||
|
? body.locales.filter(l => typeof l === 'string' && /^[a-z]{2,5}(-[A-Za-z0-9]+)*$/.test(l))
|
||||||
|
: Array.from(ALL_LOCALES);
|
||||||
|
if (localeList.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No valid locales supplied.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const oauthOnly = body.oauthOnly === true;
|
||||||
|
|
||||||
|
const accountId = await getStalwartAccountId(creds.serverUrl, creds.authHeader);
|
||||||
|
if (!accountId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Could not resolve Stalwart account from JMAP session.' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryRes = await jmapCall(creds.serverUrl, creds.authHeader, {
|
||||||
|
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||||
|
methodCalls: [[
|
||||||
|
'x:OAuthClient/query',
|
||||||
|
{ accountId, filter: { clientId: CLIENT_ID } },
|
||||||
|
'0',
|
||||||
|
]],
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryEntry = queryRes.methodResponses?.[0];
|
||||||
|
if (!queryEntry || queryEntry[0] === 'error') {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Stalwart denied OAuthClient/query - your Stalwart account likely lacks admin permissions.',
|
||||||
|
detail: queryEntry?.[1],
|
||||||
|
}, { status: 403 });
|
||||||
|
}
|
||||||
|
const existingIds = (queryEntry[1].ids as string[] | undefined) ?? [];
|
||||||
|
|
||||||
|
const secret = randomBytes(32).toString('base64url');
|
||||||
|
const redirectUris = buildRedirectUris(origin, localeList);
|
||||||
|
|
||||||
|
let setArgs: Record<string, unknown>;
|
||||||
|
let action: 'created' | 'updated';
|
||||||
|
if (existingIds.length > 0) {
|
||||||
|
const targetId = existingIds[0];
|
||||||
|
action = 'updated';
|
||||||
|
setArgs = {
|
||||||
|
accountId,
|
||||||
|
update: {
|
||||||
|
[targetId]: {
|
||||||
|
secret,
|
||||||
|
redirectUris,
|
||||||
|
description: CLIENT_DESCRIPTION,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
action = 'created';
|
||||||
|
setArgs = {
|
||||||
|
accountId,
|
||||||
|
create: {
|
||||||
|
new: {
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
description: CLIENT_DESCRIPTION,
|
||||||
|
secret,
|
||||||
|
redirectUris,
|
||||||
|
contacts: { [creds.username]: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const setRes = await jmapCall(creds.serverUrl, creds.authHeader, {
|
||||||
|
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||||
|
methodCalls: [['x:OAuthClient/set', setArgs, '0']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const setEntry = setRes.methodResponses?.[0];
|
||||||
|
if (!setEntry || setEntry[0] === 'error') {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Stalwart denied OAuthClient/set - admin permissions required.',
|
||||||
|
detail: setEntry?.[1],
|
||||||
|
}, { status: 403 });
|
||||||
|
}
|
||||||
|
const setBody = setEntry[1] as {
|
||||||
|
notCreated?: Record<string, unknown>;
|
||||||
|
notUpdated?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
if (setBody.notCreated && Object.keys(setBody.notCreated).length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Stalwart refused to create the OAuth client.', detail: setBody.notCreated },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (setBody.notUpdated && Object.keys(setBody.notUpdated).length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Stalwart refused to update the OAuth client.', detail: setBody.notUpdated },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
oauthEnabled: true,
|
||||||
|
oauthClientId: CLIENT_ID,
|
||||||
|
oauthClientSecret: secret,
|
||||||
|
oauthIssuerUrl: issuerUrl,
|
||||||
|
};
|
||||||
|
if (oauthOnly) updates.oauthOnly = true;
|
||||||
|
await configManager.setAdminConfig(updates);
|
||||||
|
|
||||||
|
await auditLog('admin.oauth_setup', {
|
||||||
|
action,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
origin,
|
||||||
|
issuer: issuerUrl,
|
||||||
|
redirectUriCount: localeList.length,
|
||||||
|
oauthOnly,
|
||||||
|
}, ip);
|
||||||
|
|
||||||
|
logger.info('Admin OAuth setup', {
|
||||||
|
action,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
origin,
|
||||||
|
issuer: issuerUrl,
|
||||||
|
locales: localeList.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
origin,
|
||||||
|
issuerUrl,
|
||||||
|
redirectUriCount: localeList.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin OAuth setup error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||||
import { requireAdminAuth } from '@/lib/admin/session';
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/config - Read all config for a plugin
|
* GET /api/admin/plugins/[id]/config - Read plugin config
|
||||||
*
|
*
|
||||||
* Returns the full config object for admin-configured plugin settings.
|
* - Admin sessions receive every field, including those declared
|
||||||
* This endpoint is accessible from the client-side plugin API.
|
* `type: 'secret'` in the plugin's configSchema.
|
||||||
|
* - Authenticated mailbox users (the plugin running in their browser)
|
||||||
|
* receive only non-secret fields.
|
||||||
|
* - Anonymous callers are rejected so unauthenticated visitors cannot
|
||||||
|
* enumerate plugin secrets.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -20,13 +25,34 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminAuth = await requireAdminAuth();
|
||||||
|
const isAdmin = !('error' in adminAuth);
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const plugin = await getPlugin(id);
|
const plugin = await getPlugin(id);
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getPluginConfig(id);
|
const config = await getPluginConfig(id);
|
||||||
return NextResponse.json(config, {
|
|
||||||
|
let response: Record<string, unknown> = config;
|
||||||
|
if (!isAdmin && plugin.configSchema) {
|
||||||
|
response = {};
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
const field = plugin.configSchema[key];
|
||||||
|
if (field?.type === 'secret') continue;
|
||||||
|
response[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(response, {
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
deletePlugin as removePlugin,
|
deletePlugin as removePlugin,
|
||||||
type ServerPlugin,
|
type ServerPlugin,
|
||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import {
|
||||||
|
sanitizeFrameOrigins,
|
||||||
|
invalidateFrameOriginsCache,
|
||||||
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
|
|
||||||
// Server-side extraction using the same validation logic
|
// Server-side extraction using the same validation logic
|
||||||
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
||||||
@@ -152,6 +156,8 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
id: manifest.id as string,
|
id: manifest.id as string,
|
||||||
@@ -166,12 +172,16 @@ export async function POST(request: NextRequest) {
|
|||||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredFrameOrigins.length > 0
|
||||||
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
|
: {}),
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
|
invalidateFrameOriginsCache();
|
||||||
|
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip);
|
||||||
|
|
||||||
return NextResponse.json({ plugin });
|
return NextResponse.json({ plugin });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -209,6 +219,11 @@ export async function PATCH(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable/disable changes the set of plugins contributing frame origins.
|
||||||
|
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
|
||||||
|
invalidateFrameOriginsCache();
|
||||||
|
}
|
||||||
|
|
||||||
await auditLog('plugin.update', { id, ...updates }, ip);
|
await auditLog('plugin.update', { id, ...updates }, ip);
|
||||||
return NextResponse.json({ plugin: updated });
|
return NextResponse.json({ plugin: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -238,6 +253,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateFrameOriginsCache();
|
||||||
await auditLog('plugin.delete', { id }, ip);
|
await auditLog('plugin.delete', { id }, ip);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
effectiveConsent,
|
||||||
|
loadState,
|
||||||
|
saveState,
|
||||||
|
buildPayload,
|
||||||
|
sendOnce,
|
||||||
|
reschedule,
|
||||||
|
DEFAULT_ENDPOINT,
|
||||||
|
getLoginCounts,
|
||||||
|
resolveEndpointAllowed,
|
||||||
|
} from '@/lib/telemetry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/telemetry
|
||||||
|
* Returns current consent + endpoint + next/last send + a live preview
|
||||||
|
* of exactly what the next heartbeat would contain.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const auth = await requireAdminAuth();
|
||||||
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
|
const { consent, source, state } = await effectiveConsent();
|
||||||
|
const [payload, accountCounts] = await Promise.all([
|
||||||
|
buildPayload(),
|
||||||
|
getLoginCounts(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
consent,
|
||||||
|
consentSource: source,
|
||||||
|
endpoint: state.endpoint || DEFAULT_ENDPOINT,
|
||||||
|
consentedAt: state.consentedAt,
|
||||||
|
lastSentAt: state.lastSentAt,
|
||||||
|
nextScheduledAt: state.nextScheduledAt,
|
||||||
|
defaultEndpoint: DEFAULT_ENDPOINT,
|
||||||
|
payloadPreview: payload,
|
||||||
|
accountCounts,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('telemetry GET error', {
|
||||||
|
error: err instanceof Error ? err.message : 'unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/telemetry
|
||||||
|
* Body: { action: 'set-consent' | 'set-endpoint' | 'send-now', ... }
|
||||||
|
* set-consent : { action, consent: 'on' | 'off' }
|
||||||
|
* set-endpoint : { action, endpoint: string }
|
||||||
|
* send-now : { action }
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAdminAuth();
|
||||||
|
if ('error' in auth) return auth.error;
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => null)) as
|
||||||
|
| { action?: string; consent?: string; endpoint?: string }
|
||||||
|
| null;
|
||||||
|
if (!body || typeof body.action !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'action required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { source } = await effectiveConsent();
|
||||||
|
|
||||||
|
if (body.action === 'set-consent') {
|
||||||
|
if (source === 'env') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'consent is overridden by BULWARK_TELEMETRY env var' },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.consent !== 'on' && body.consent !== 'off') {
|
||||||
|
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const state = await loadState();
|
||||||
|
const before = state.consent;
|
||||||
|
state.consent = body.consent;
|
||||||
|
if (body.consent === 'on' && !state.consentedAt) {
|
||||||
|
state.consentedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
await saveState(state);
|
||||||
|
await reschedule();
|
||||||
|
await auditLog('telemetry.set-consent', { from: before, to: body.consent }, ip);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'set-endpoint') {
|
||||||
|
if (typeof body.endpoint !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'endpoint required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const trimmed = body.endpoint.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
const check = await resolveEndpointAllowed(trimmed);
|
||||||
|
if (!check.ok) {
|
||||||
|
return NextResponse.json({ error: check.reason }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const state = await loadState();
|
||||||
|
const before = state.endpoint;
|
||||||
|
state.endpoint = trimmed || DEFAULT_ENDPOINT;
|
||||||
|
await saveState(state);
|
||||||
|
await auditLog('telemetry.set-endpoint', { from: before, to: state.endpoint }, ip);
|
||||||
|
return NextResponse.json({ ok: true, endpoint: state.endpoint });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'send-now') {
|
||||||
|
const result = await sendOnce({ reason: 'admin-manual' });
|
||||||
|
await auditLog(
|
||||||
|
'telemetry.send-now',
|
||||||
|
{ ok: result.ok, status: result.status ?? null, error: result.error ?? null },
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('telemetry POST error', {
|
||||||
|
error: err instanceof Error ? err.message : 'unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
clearStalwartAuthContextInStore,
|
clearStalwartAuthContextInStore,
|
||||||
setStalwartAuthContextInStore,
|
setStalwartAuthContextInStore,
|
||||||
} from '@/lib/stalwart/auth-context';
|
} from '@/lib/stalwart/auth-context';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
...getCookieOptions(),
|
...getCookieOptions(),
|
||||||
@@ -25,7 +28,9 @@ function getSlot(request: NextRequest): number {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||||
|
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
|
||||||
|
if (oauthEnabled && oauthOnly) {
|
||||||
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,10 +39,37 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||||
|
// caller cannot point this route at internal hosts. Only when no server URL
|
||||||
|
// is configured AND the deployment explicitly allows custom JMAP endpoints
|
||||||
|
// do we honor the body URL — and even then it must be a public URL.
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const configuredServerUrl =
|
||||||
|
configManager.get<string>('jmapServerUrl', '') ||
|
||||||
|
process.env.JMAP_SERVER_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
|
'';
|
||||||
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
|
||||||
|
let upstreamUrl: string;
|
||||||
|
let upstreamTrusted: boolean;
|
||||||
|
if (configuredServerUrl) {
|
||||||
|
upstreamUrl = configuredServerUrl;
|
||||||
|
upstreamTrusted = true;
|
||||||
|
} else if (allowCustomEndpoint) {
|
||||||
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
|
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
upstreamUrl = serverUrl;
|
||||||
|
upstreamTrusted = false;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
const cookieName = sessionCookieName(slot);
|
const cookieName = sessionCookieName(slot);
|
||||||
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
||||||
const token = encryptSession(normalizedServerUrl, username, password);
|
const token = encryptSession(normalizedServerUrl, username, password);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||||
@@ -47,6 +79,8 @@ export async function POST(request: NextRequest) {
|
|||||||
authHeader,
|
authHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void recordLogin(username, normalizedServerUrl);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof JmapAuthVerificationError) {
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||||
@@ -23,8 +26,35 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||||
|
// caller cannot point this route at internal hosts. Only when no server URL
|
||||||
|
// is configured AND the deployment explicitly allows custom JMAP endpoints
|
||||||
|
// do we honor the body URL — and even then it must be a public URL.
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const configuredServerUrl =
|
||||||
|
configManager.get<string>('jmapServerUrl', '') ||
|
||||||
|
process.env.JMAP_SERVER_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
|
'';
|
||||||
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
|
||||||
|
let upstreamUrl: string;
|
||||||
|
let upstreamTrusted: boolean;
|
||||||
|
if (configuredServerUrl) {
|
||||||
|
upstreamUrl = configuredServerUrl;
|
||||||
|
upstreamTrusted = true;
|
||||||
|
} else if (allowCustomEndpoint) {
|
||||||
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
|
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
upstreamUrl = serverUrl;
|
||||||
|
upstreamTrusted = false;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
const slot = getSlot(request, bodySlot);
|
const slot = getSlot(request, bodySlot);
|
||||||
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
||||||
|
|
||||||
await setStalwartAuthContext(slot, {
|
await setStalwartAuthContext(slot, {
|
||||||
serverUrl: normalizedServerUrl,
|
serverUrl: normalizedServerUrl,
|
||||||
@@ -32,6 +62,8 @@ export async function POST(request: NextRequest) {
|
|||||||
authHeader,
|
authHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void recordLogin(username, normalizedServerUrl);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof JmapAuthVerificationError) {
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
|
|||||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
@@ -83,22 +86,39 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||||
|
|
||||||
// Use the server-side JMAP_SERVER_URL if set (may differ from the
|
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||||
// public URL the browser uses, e.g. inside Docker).
|
// caller cannot point this route at internal hosts. Only when no server
|
||||||
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
|
// URL is configured (and the deployment explicitly allows custom JMAP
|
||||||
|
// endpoints) do we fall back to the user-supplied URL - and even then
|
||||||
|
// it must resolve to a public address.
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const configuredServerUrl =
|
||||||
|
configManager.get<string>('jmapServerUrl', '') ||
|
||||||
|
process.env.JMAP_SERVER_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
|
'';
|
||||||
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
|
||||||
const tokenEndpoint = await findTokenEndpoint(internalServerUrl);
|
let upstreamUrl: string;
|
||||||
if (!tokenEndpoint) {
|
if (configuredServerUrl) {
|
||||||
// Also try with the client-provided URL in case the internal one differs
|
upstreamUrl = configuredServerUrl;
|
||||||
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
|
} else if (allowCustomEndpoint) {
|
||||||
if (!clientEndpoint) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
|
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
||||||
}
|
}
|
||||||
return await attemptAllStrategies(clientEndpoint, username, password, slot);
|
upstreamUrl = serverUrl;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
|
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
|
||||||
|
if (!tokenEndpoint) {
|
||||||
|
logger.warn('TOTP token exchange: no token endpoint found');
|
||||||
|
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
@@ -107,14 +127,15 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
async function attemptAllStrategies(
|
async function attemptAllStrategies(
|
||||||
tokenEndpoint: string,
|
tokenEndpoint: string,
|
||||||
|
serverUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
slot: number,
|
slot: number,
|
||||||
): Promise<NextResponse> {
|
): Promise<NextResponse> {
|
||||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
const clientSecret = configManager.get<string>('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||||
|
|
||||||
@@ -125,6 +146,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||||
@@ -136,6 +158,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||||
@@ -147,6 +170,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||||
@@ -158,6 +182,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||||
|
|||||||
@@ -1,89 +1,9 @@
|
|||||||
import { lookup } from 'node:dns/promises';
|
|
||||||
import { BlockList, isIP } from 'node:net';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
|
||||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const FETCH_TIMEOUT_MS = 15000;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
const blockedAddressRanges = new BlockList();
|
|
||||||
blockedAddressRanges.addAddress('0.0.0.0');
|
|
||||||
blockedAddressRanges.addAddress('127.0.0.1');
|
|
||||||
blockedAddressRanges.addSubnet('10.0.0.0', 8);
|
|
||||||
blockedAddressRanges.addSubnet('172.16.0.0', 12);
|
|
||||||
blockedAddressRanges.addSubnet('192.168.0.0', 16);
|
|
||||||
blockedAddressRanges.addSubnet('169.254.0.0', 16);
|
|
||||||
blockedAddressRanges.addAddress('::', 'ipv6');
|
|
||||||
blockedAddressRanges.addAddress('::1', 'ipv6');
|
|
||||||
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
|
|
||||||
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
|
|
||||||
|
|
||||||
function normalizeHostname(hostname: string): string {
|
|
||||||
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBlockedIpAddress(hostname: string): boolean {
|
|
||||||
const normalized = normalizeHostname(hostname);
|
|
||||||
const family = isIP(normalized);
|
|
||||||
if (family === 4) {
|
|
||||||
return blockedAddressRanges.check(normalized, 'ipv4');
|
|
||||||
}
|
|
||||||
if (family === 6) {
|
|
||||||
return blockedAddressRanges.check(normalized, 'ipv6');
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isValidExternalUrl(urlString: string): Promise<boolean> {
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(urlString);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = normalizeHostname(url.hostname);
|
|
||||||
|
|
||||||
// Block private/internal hostnames
|
|
||||||
if (
|
|
||||||
hostname === 'localhost' ||
|
|
||||||
hostname.endsWith('.localhost') ||
|
|
||||||
hostname.endsWith('.local') ||
|
|
||||||
hostname.endsWith('.internal') ||
|
|
||||||
hostname.endsWith('.arpa') ||
|
|
||||||
hostname.endsWith('.localdomain')
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block URLs with credentials
|
|
||||||
if (url.username || url.password) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBlockedIpAddress(hostname)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isIP(hostname)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const records = await lookup(hostname, { all: true, verbatim: true });
|
|
||||||
if (records.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return records.every((record) => !isBlockedIpAddress(record.address));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { url?: string };
|
let body: { url?: string };
|
||||||
try {
|
try {
|
||||||
@@ -98,7 +18,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await isValidExternalUrl(url))) {
|
if (!(await isPublicHttpUrl(url))) {
|
||||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +31,7 @@ export async function POST(request: NextRequest) {
|
|||||||
let response: Response | undefined;
|
let response: Response | undefined;
|
||||||
|
|
||||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||||
if (!(await isValidExternalUrl(currentUrl))) {
|
if (!(await isPublicHttpUrl(currentUrl))) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -131,7 +51,6 @@ export async function POST(request: NextRequest) {
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
||||||
}
|
}
|
||||||
// Resolve relative redirects
|
|
||||||
currentUrl = new URL(location, currentUrl).toString();
|
currentUrl = new URL(location, currentUrl).toString();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/push/preview
|
||||||
|
*
|
||||||
|
* Called from the service worker when a Web Push wake-up arrives. Fetches the
|
||||||
|
* latest unread email so the SW can build an enriched system notification
|
||||||
|
* (sender, subject, avatar) without ever exposing JMAP credentials to the
|
||||||
|
* SW context.
|
||||||
|
*
|
||||||
|
* The relay's push payload is intentionally minimal (just a state-change
|
||||||
|
* ping), so this is what makes "From: Alice / Subject: …" appear instead of
|
||||||
|
* a generic "New mail" string.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
||||||
|
headers: { Authorization: creds.authHeader },
|
||||||
|
});
|
||||||
|
if (!sessionRes.ok) {
|
||||||
|
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
const session = (await sessionRes.json()) as {
|
||||||
|
apiUrl?: string;
|
||||||
|
primaryAccounts?: Record<string, string>;
|
||||||
|
};
|
||||||
|
const apiUrl = session.apiUrl;
|
||||||
|
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||||
|
if (!apiUrl || !accountId) {
|
||||||
|
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the inbox, then pull the most recent unread message in it. We use
|
||||||
|
// a single batched JMAP request with back-references so this round-trip
|
||||||
|
// is one POST regardless of how many messages exist.
|
||||||
|
const requestBody = {
|
||||||
|
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
'Mailbox/query',
|
||||||
|
{ accountId, filter: { role: 'inbox' }, limit: 1 },
|
||||||
|
'mb',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Email/query',
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
filter: {
|
||||||
|
operator: 'AND',
|
||||||
|
conditions: [
|
||||||
|
{ inMailbox: { resultOf: 'mb', name: 'Mailbox/query', path: '/ids/0' } },
|
||||||
|
{ notKeyword: '$seen' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||||
|
limit: 1,
|
||||||
|
calculateTotal: true,
|
||||||
|
},
|
||||||
|
'eq',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
'#ids': { resultOf: 'eq', name: 'Email/query', path: '/ids' },
|
||||||
|
properties: ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'],
|
||||||
|
},
|
||||||
|
'eg',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const jmapRes = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: creds.authHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
if (!jmapRes.ok) {
|
||||||
|
return NextResponse.json({ error: 'JMAP request failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
const data = (await jmapRes.json()) as {
|
||||||
|
methodResponses: [string, Record<string, unknown>, string][];
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmailLite = {
|
||||||
|
id: string;
|
||||||
|
threadId: string;
|
||||||
|
from?: { name?: string | null; email?: string }[] | null;
|
||||||
|
subject?: string | null;
|
||||||
|
preview?: string | null;
|
||||||
|
receivedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let email: EmailLite | null = null;
|
||||||
|
let unreadTotal = 0;
|
||||||
|
for (const [method, body] of data.methodResponses) {
|
||||||
|
if (method === 'Email/query') {
|
||||||
|
unreadTotal = ((body as { total?: number }).total) ?? 0;
|
||||||
|
}
|
||||||
|
if (method === 'Email/get') {
|
||||||
|
const list = (body as { list?: EmailLite[] }).list ?? [];
|
||||||
|
email = list[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
email,
|
||||||
|
unreadTotal,
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
// SW already gates on its own logic - don't let push events get
|
||||||
|
// cached and served stale.
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// `fetch failed` from undici is too generic to debug — the real reason
|
||||||
|
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
|
||||||
|
const err = error as Error & { cause?: { code?: string; message?: string } };
|
||||||
|
logger.error('push preview failed', {
|
||||||
|
error: err?.message ?? 'Unknown error',
|
||||||
|
causeCode: err?.cause?.code,
|
||||||
|
causeMessage: err?.cause?.message,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,6 +204,31 @@ body {
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Forces light-theme CSS variables inside the email content area, so when
|
||||||
|
"Always Show Emails in Light Mode" is enabled in dark theme the surrounding
|
||||||
|
sender info / attachments / plain-text body don't end up with light text
|
||||||
|
on a white background. */
|
||||||
|
.email-content-light {
|
||||||
|
--color-background: #ffffff;
|
||||||
|
--color-foreground: #0f172a;
|
||||||
|
--color-muted: #f1f5f9;
|
||||||
|
--color-muted-foreground: #64748b;
|
||||||
|
--color-border: #e2e8f0;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #0f172a;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #0f172a;
|
||||||
|
--color-secondary: #f8fafc;
|
||||||
|
--color-secondary-foreground: #0f172a;
|
||||||
|
--color-accent: #dbeafe;
|
||||||
|
--color-accent-foreground: #1e40af;
|
||||||
|
--color-input: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-content-light .email-content-text a {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
.email-content-text {
|
.email-content-text {
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
}
|
}
|
||||||
@@ -680,3 +705,54 @@ body {
|
|||||||
.tiptap .ProseMirror-selectednode img {
|
.tiptap .ProseMirror-selectednode img {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tiptap table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table td,
|
||||||
|
.tiptap table th {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
vertical-align: top;
|
||||||
|
position: relative;
|
||||||
|
min-width: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table th {
|
||||||
|
background-color: var(--color-muted) !important;
|
||||||
|
color: var(--color-foreground) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table .selectedCell::after {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
content: "";
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table .column-resize-handle {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
bottom: -2px;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
top: 0;
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap.resize-cursor {
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface CalendarDayViewProps {
|
|||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
@@ -39,6 +40,7 @@ export function CalendarDayView({
|
|||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
onContextMenuEvent,
|
onContextMenuEvent,
|
||||||
|
onContextMenuEmpty,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -144,7 +146,13 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
||||||
<div className="px-4 py-2 border-b border-border">
|
<div
|
||||||
|
className="px-4 py-2 border-b border-border"
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("[data-calendar-event],button")) return;
|
||||||
|
onContextMenuEmpty(e, selectedDate, undefined, true);
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
{allDayEvents.length > 0 && (
|
{allDayEvents.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||||
@@ -240,6 +248,7 @@ export function CalendarDayView({
|
|||||||
aria-label={formatHour(h)}
|
aria-label={formatHour(h)}
|
||||||
onClick={() => handleSlotClick(selectedDate, h)}
|
onClick={() => handleSlotClick(selectedDate, h)}
|
||||||
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
|
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, selectedDate, h, false) : undefined}
|
||||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface CalendarMonthViewProps {
|
|||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
onCreateAtTime?: (date: Date) => void;
|
onCreateAtTime?: (date: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
@@ -39,6 +40,7 @@ export function CalendarMonthView({
|
|||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
onContextMenuEvent,
|
onContextMenuEvent,
|
||||||
|
onContextMenuEmpty,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -174,6 +176,7 @@ export function CalendarMonthView({
|
|||||||
aria-label={fullDateLabel}
|
aria-label={fullDateLabel}
|
||||||
onClick={() => onSelectDate(day)}
|
onClick={() => onSelectDate(day)}
|
||||||
onDoubleClick={() => onCreateAtTime?.(day)}
|
onDoubleClick={() => onCreateAtTime?.(day)}
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
|
||||||
onDragOver={(e) => handleCellDragOver(e, key)}
|
onDragOver={(e) => handleCellDragOver(e, key)}
|
||||||
onDragLeave={handleCellDragLeave}
|
onDragLeave={handleCellDragLeave}
|
||||||
onDrop={(e) => handleCellDrop(e, day)}
|
onDrop={(e) => handleCellDrop(e, day)}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
|
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
@@ -11,6 +11,8 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
interface CalendarSidebarPanelProps {
|
interface CalendarSidebarPanelProps {
|
||||||
@@ -18,6 +20,11 @@ interface CalendarSidebarPanelProps {
|
|||||||
selectedCalendarIds: string[];
|
selectedCalendarIds: string[];
|
||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
|
onShareCalendar?: (calendar: Calendar) => void;
|
||||||
|
onCreateEvent?: (calendar: Calendar) => void;
|
||||||
|
onClearCalendar?: (calendar: Calendar) => void;
|
||||||
|
onDeleteCalendar?: (calendar: Calendar) => void;
|
||||||
|
onCreateCalendar?: () => void;
|
||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
onEditSubscription?: (subscriptionId: string) => void;
|
onEditSubscription?: (subscriptionId: string) => void;
|
||||||
client?: IJMAPClient | null;
|
client?: IJMAPClient | null;
|
||||||
@@ -28,12 +35,18 @@ export function CalendarSidebarPanel({
|
|||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
|
onShareCalendar,
|
||||||
|
onCreateEvent,
|
||||||
|
onClearCalendar,
|
||||||
|
onDeleteCalendar,
|
||||||
|
onCreateCalendar,
|
||||||
onSubscribe,
|
onSubscribe,
|
||||||
onEditSubscription,
|
onEditSubscription,
|
||||||
client,
|
client,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const tSub = useTranslations("calendar.subscription");
|
const tSub = useTranslations("calendar.subscription");
|
||||||
|
const tMgmt = useTranslations("calendar.management");
|
||||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||||
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
||||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||||
@@ -49,11 +62,8 @@ export function CalendarSidebarPanel({
|
|||||||
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
|
||||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||||
const sharedAccountGroups = useMemo(() => {
|
const sharedAccountGroups = useMemo(() => {
|
||||||
@@ -69,30 +79,6 @@ export function CalendarSidebarPanel({
|
|||||||
return Array.from(groups.values());
|
return Array.from(groups.values());
|
||||||
}, [calendars]);
|
}, [calendars]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!colorPickerId && !contextMenuCalId) return;
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
|
||||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
|
|
||||||
setColorPickerId(null);
|
|
||||||
}
|
|
||||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setColorPickerId(null);
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClick);
|
|
||||||
document.addEventListener('keydown', handleKey);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClick);
|
|
||||||
document.removeEventListener('keydown', handleKey);
|
|
||||||
};
|
|
||||||
}, [colorPickerId, contextMenuCalId]);
|
|
||||||
|
|
||||||
const getSubscriptionForCalendar = (calendarId: string) => {
|
const getSubscriptionForCalendar = (calendarId: string) => {
|
||||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||||
};
|
};
|
||||||
@@ -100,7 +86,6 @@ export function CalendarSidebarPanel({
|
|||||||
const handleRefreshSubscription = async (subId: string) => {
|
const handleRefreshSubscription = async (subId: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
setRefreshingSubId(subId);
|
setRefreshingSubId(subId);
|
||||||
setContextMenuCalId(null);
|
|
||||||
try {
|
try {
|
||||||
await refreshICalSubscription(client, subId);
|
await refreshICalSubscription(client, subId);
|
||||||
toast.success(tSub('refresh_success'));
|
toast.success(tSub('refresh_success'));
|
||||||
@@ -113,7 +98,6 @@ export function CalendarSidebarPanel({
|
|||||||
|
|
||||||
const handleUnsubscribe = async (subId: string) => {
|
const handleUnsubscribe = async (subId: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
setContextMenuCalId(null);
|
|
||||||
try {
|
try {
|
||||||
await removeICalSubscription(client, subId);
|
await removeICalSubscription(client, subId);
|
||||||
toast.success(tSub('deleted'));
|
toast.success(tSub('deleted'));
|
||||||
@@ -127,21 +111,13 @@ export function CalendarSidebarPanel({
|
|||||||
const renderCalendarItem = (cal: Calendar) => {
|
const renderCalendarItem = (cal: Calendar) => {
|
||||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
const color = cal.color || "#3b82f6";
|
const color = cal.color || "#3b82f6";
|
||||||
|
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cal.id} className="relative">
|
<div key={cal.id} className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleVisibility(cal.id)}
|
onClick={() => onToggleVisibility(cal.id)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
|
||||||
e.preventDefault();
|
|
||||||
if (isSubscriptionCalendar(cal.id) && client) {
|
|
||||||
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
|
|
||||||
setColorPickerId(null);
|
|
||||||
} else if (onColorChange) {
|
|
||||||
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
||||||
"hover:bg-muted"
|
"hover:bg-muted"
|
||||||
@@ -169,70 +145,101 @@ export function CalendarSidebarPanel({
|
|||||||
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Subscription context menu on right-click */}
|
|
||||||
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
|
|
||||||
const sub = getSubscriptionForCalendar(cal.id);
|
|
||||||
if (!sub) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={contextMenuRef}
|
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
onEditSubscription?.(sub.id);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
{tSub('edit')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRefreshSubscription(sub.id)}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
|
||||||
{tSub('refresh')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleUnsubscribe(sub.id)}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
{tSub('unsubscribe')}
|
|
||||||
</button>
|
|
||||||
{sub.lastRefreshed && (
|
|
||||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
|
||||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Color picker popover on right-click */}
|
|
||||||
{colorPickerId === cal.id && onColorChange && (
|
|
||||||
<div
|
|
||||||
ref={colorPickerRef}
|
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
|
||||||
>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
|
|
||||||
<CalendarColorPicker
|
|
||||||
value={color}
|
|
||||||
onChange={(c) => {
|
|
||||||
onColorChange(cal.id, c);
|
|
||||||
setColorPickerId(null);
|
|
||||||
}}
|
|
||||||
allowCustom
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderCalendarMenu = () => {
|
||||||
|
const cal = contextMenu.data;
|
||||||
|
if (!cal) return null;
|
||||||
|
|
||||||
|
if (isSubscriptionCalendar(cal.id)) {
|
||||||
|
const sub = getSubscriptionForCalendar(cal.id);
|
||||||
|
if (!sub || !client) return null;
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={tSub('edit')}
|
||||||
|
onClick={() => { closeContextMenu(); onEditSubscription?.(sub.id); }}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={RefreshCw}
|
||||||
|
label={tSub('refresh')}
|
||||||
|
onClick={() => { closeContextMenu(); handleRefreshSubscription(sub.id); }}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={tSub('unsubscribe')}
|
||||||
|
onClick={() => { closeContextMenu(); handleUnsubscribe(sub.id); }}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
{sub.lastRefreshed && (
|
||||||
|
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
||||||
|
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||||
|
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||||
|
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||||
|
const canChangeColor = !!onColorChange;
|
||||||
|
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||||
|
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||||
|
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
||||||
|
const color = cal.color || "#3b82f6";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||||
|
{canCreate && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Plus}
|
||||||
|
label={tMgmt('new_event_in_calendar')}
|
||||||
|
onClick={() => { closeContextMenu(); onCreateEvent(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={tMgmt('share')}
|
||||||
|
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canChangeColor && (
|
||||||
|
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
|
||||||
|
<div className="px-2 py-1.5 w-[200px]">
|
||||||
|
<CalendarColorPicker
|
||||||
|
value={color}
|
||||||
|
onChange={(c) => { onColorChange(cal.id, c); closeContextMenu(); }}
|
||||||
|
allowCustom
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ContextMenuSubMenu>
|
||||||
|
)}
|
||||||
|
{showSeparator && <ContextMenuSeparator />}
|
||||||
|
{canClear && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Eraser}
|
||||||
|
label={tMgmt('clear_events')}
|
||||||
|
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={tMgmt('delete')}
|
||||||
|
onClick={() => { closeContextMenu(); onDeleteCalendar(cal); }}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{enableCalendarTasks && (
|
{enableCalendarTasks && (
|
||||||
@@ -250,9 +257,22 @@ export function CalendarSidebarPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||||
{t("my_calendars")}
|
{onCreateCalendar ? (
|
||||||
</h3>
|
<button
|
||||||
|
onClick={onCreateCalendar}
|
||||||
|
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||||
|
title={tMgmt('add_calendar')}
|
||||||
|
>
|
||||||
|
{t('my_calendars')}
|
||||||
|
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{t('my_calendars')}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{personalCalendars.map(renderCalendarItem)}
|
{personalCalendars.map(renderCalendarItem)}
|
||||||
</div>
|
</div>
|
||||||
@@ -268,6 +288,8 @@ export function CalendarSidebarPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{renderCalendarMenu()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ export function CalendarToolbar({
|
|||||||
{/* ── DESKTOP TOOLBAR ── */}
|
{/* ── DESKTOP TOOLBAR ── */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
|
||||||
|
{t("views.today")}
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -279,14 +282,14 @@ export function CalendarToolbar({
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex border border-border rounded-md overflow-hidden">
|
<div className="flex h-8 border border-border rounded-md overflow-hidden">
|
||||||
{views.map((v) => (
|
{views.map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
onClick={() => onViewModeChange(v)}
|
onClick={() => onViewModeChange(v)}
|
||||||
title={t(`views.${v}_hint`)}
|
title={t(`views.${v}_hint`)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
"inline-flex items-center px-3 text-xs font-medium transition-colors",
|
||||||
v === viewMode
|
v === viewMode
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "hover:bg-muted text-muted-foreground"
|
: "hover:bg-muted text-muted-foreground"
|
||||||
@@ -300,7 +303,7 @@ export function CalendarToolbar({
|
|||||||
|
|
||||||
{(onImport || onSubscribe) && !isMobile && (
|
{(onImport || onSubscribe) && !isMobile && (
|
||||||
<div className="relative" ref={importDropdownRef}>
|
<div className="relative" ref={importDropdownRef}>
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}>
|
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
|
||||||
<Upload className="w-4 h-4 mr-1" />
|
<Upload className="w-4 h-4 mr-1" />
|
||||||
{t("import.title")}
|
{t("import.title")}
|
||||||
<ChevronDown className="w-3 h-3 ml-1" />
|
<ChevronDown className="w-3 h-3 ml-1" />
|
||||||
@@ -331,7 +334,7 @@ export function CalendarToolbar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
|
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
{t("events.create")}
|
{t("events.create")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface CalendarWeekViewProps {
|
|||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
@@ -44,6 +45,7 @@ export function CalendarWeekView({
|
|||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
onContextMenuEvent,
|
onContextMenuEvent,
|
||||||
|
onContextMenuEmpty,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
@@ -219,7 +221,11 @@ export function CalendarWeekView({
|
|||||||
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{weekDays.map((day) => (
|
{weekDays.map((day) => (
|
||||||
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
|
<div
|
||||||
|
key={format(day, "yyyy-MM-dd")}
|
||||||
|
className="bg-background min-h-[28px]"
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
@@ -378,6 +384,7 @@ export function CalendarWeekView({
|
|||||||
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
|
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
|
||||||
onClick={() => handleSlotClick(day, h)}
|
onClick={() => handleSlotClick(day, h)}
|
||||||
onDoubleClick={() => handleSlotDoubleClick(day, h)}
|
onDoubleClick={() => handleSlotDoubleClick(day, h)}
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
|
||||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X, Loader2, Calendar as CalendarIcon } from "lucide-react";
|
||||||
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
|
interface CreateCalendarModalProps {
|
||||||
|
client: IJMAPClient;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProps) {
|
||||||
|
const t = useTranslations("calendar.management");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const createCalendar = useCalendarStore((s) => s.createCalendar);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [color, setColor] = useState("#3b82f6");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const isValid = name.trim().length > 0;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const created = await createCalendar(client, { name: trimmed, color });
|
||||||
|
if (created) {
|
||||||
|
toast.success(t("calendar_created"));
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
toast.error(t("error_create"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("error_create"));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [name, color, client, createCalendar, onClose, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && !isSubmitting) onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [onClose, isSubmitting]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const modal = modalRef.current;
|
||||||
|
if (!modal) return;
|
||||||
|
const focusableEls = modal.querySelectorAll<HTMLElement>(
|
||||||
|
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
const firstEl = focusableEls[0];
|
||||||
|
const lastEl = focusableEls[focusableEls.length - 1];
|
||||||
|
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
if (e.shiftKey && document.activeElement === firstEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastEl?.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === lastEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstEl?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
modal.addEventListener("keydown", handler);
|
||||||
|
firstEl?.focus();
|
||||||
|
return () => modal.removeEventListener("keydown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
|
||||||
|
onClick={() => !isSubmitting && onClose()}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("add_calendar")}
|
||||||
|
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
aria-label={tCommon("close")}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
{t("name")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("name_placeholder")}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
{t("color")}
|
||||||
|
</label>
|
||||||
|
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
{tCommon("loading")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("create")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import { Plus, CalendarDays, CheckSquare, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmptySpaceContextMenuProps {
|
||||||
|
position: Position;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onNewEvent: () => void;
|
||||||
|
onNewAllDayEvent: () => void;
|
||||||
|
onNewTask?: () => void;
|
||||||
|
onGoToToday: () => void;
|
||||||
|
showAllDayOption?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptySpaceContextMenu({
|
||||||
|
position,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
menuRef,
|
||||||
|
onNewEvent,
|
||||||
|
onNewAllDayEvent,
|
||||||
|
onNewTask,
|
||||||
|
onGoToToday,
|
||||||
|
showAllDayOption = true,
|
||||||
|
}: EmptySpaceContextMenuProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
|
||||||
|
const handle = (fn: () => void) => () => {
|
||||||
|
fn();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuItem icon={Plus} label={t("events.new_event")} onClick={handle(onNewEvent)} />
|
||||||
|
{showAllDayOption && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={CalendarDays}
|
||||||
|
label={t("events.new_all_day_event")}
|
||||||
|
onClick={handle(onNewAllDayEvent)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onNewTask && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={CheckSquare}
|
||||||
|
label={t("events.new_task")}
|
||||||
|
onClick={handle(onNewTask)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem icon={Clock} label={t("events.go_to_today")} onClick={handle(onGoToToday)} />
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
getUserStatus,
|
getUserStatus,
|
||||||
getParticipantList,
|
getParticipantList,
|
||||||
} from "@/lib/calendar-participants";
|
} from "@/lib/calendar-participants";
|
||||||
|
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||||
|
|
||||||
interface EventDetailPopoverProps {
|
interface EventDetailPopoverProps {
|
||||||
event: CalendarEvent;
|
event: CalendarEvent;
|
||||||
@@ -253,6 +254,8 @@ export function EventDetailPopover({
|
|||||||
|
|
||||||
const hasParticipants = participants.length > 0;
|
const hasParticipants = participants.length > 0;
|
||||||
|
|
||||||
|
const formatEventDate = useFormatEventDate();
|
||||||
|
|
||||||
const popover = (
|
const popover = (
|
||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
@@ -329,7 +332,7 @@ export function EventDetailPopover({
|
|||||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{format(startDate, "EEE, MMM d, yyyy")}
|
{formatEventDate(startDate)}
|
||||||
</span>
|
</span>
|
||||||
{event.showWithoutTime ? (
|
{event.showWithoutTime ? (
|
||||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||||
|
|||||||
+114
-107
@@ -21,6 +21,7 @@ import {
|
|||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||||
|
|
||||||
export interface PendingEventPreview {
|
export interface PendingEventPreview {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -35,6 +36,8 @@ interface EventModalProps {
|
|||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
defaultDate?: Date;
|
defaultDate?: Date;
|
||||||
defaultEndDate?: Date;
|
defaultEndDate?: Date;
|
||||||
|
defaultAllDay?: boolean;
|
||||||
|
defaultCalendarId?: string;
|
||||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||||
@@ -113,6 +116,8 @@ export function EventModal({
|
|||||||
calendars,
|
calendars,
|
||||||
defaultDate,
|
defaultDate,
|
||||||
defaultEndDate,
|
defaultEndDate,
|
||||||
|
defaultAllDay,
|
||||||
|
defaultCalendarId,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -126,6 +131,7 @@ export function EventModal({
|
|||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
const isEdit = !!event;
|
const isEdit = !!event;
|
||||||
|
const formatEventDate = useFormatEventDate();
|
||||||
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
||||||
|
|
||||||
const userIsOrganizer = useMemo(() => {
|
const userIsOrganizer = useMemo(() => {
|
||||||
@@ -197,9 +203,10 @@ export function EventModal({
|
|||||||
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||||
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
||||||
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
||||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false);
|
||||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||||
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||||
|
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
|
||||||
const defaultCal = calendars.find(c => c.isDefault);
|
const defaultCal = calendars.find(c => c.isDefault);
|
||||||
return defaultCal?.id || calendars[0]?.id || "";
|
return defaultCal?.id || calendars[0]?.id || "";
|
||||||
});
|
});
|
||||||
@@ -490,14 +497,14 @@ export function EventModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
||||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="px-6 py-4 space-y-3">
|
<div className="px-6 py-4 space-y-3">
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
|
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
|
||||||
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||||
@@ -512,7 +519,7 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
|
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||||
{!event.showWithoutTime && (
|
{!event.showWithoutTime && (
|
||||||
<span className="text-muted-foreground ml-2">
|
<span className="text-muted-foreground ml-2">
|
||||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||||
@@ -545,48 +552,48 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||||
onClick={() => handleRsvp("accepted")}
|
onClick={() => handleRsvp("accepted")}
|
||||||
className={userCurrentStatus === "accepted"
|
className={userCurrentStatus === "accepted"
|
||||||
? "bg-success hover:bg-success/80 text-success-foreground"
|
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||||
: "text-success border-success/30 hover:bg-success/10"}
|
: "text-success border-success/30 hover:bg-success/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
||||||
{t("participants.accepted")}
|
{t("participants.accepted")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||||
onClick={() => handleRsvp("tentative")}
|
onClick={() => handleRsvp("tentative")}
|
||||||
className={userCurrentStatus === "tentative"
|
className={userCurrentStatus === "tentative"
|
||||||
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||||
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
||||||
{t("participants.tentative")}
|
{t("participants.tentative")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
||||||
onClick={() => handleRsvp("declined")}
|
onClick={() => handleRsvp("declined")}
|
||||||
className={userCurrentStatus === "declined"
|
className={userCurrentStatus === "declined"
|
||||||
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||||
: "text-destructive hover:bg-destructive/10"}
|
: "text-destructive hover:bg-destructive/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
||||||
{t("participants.declined")}
|
{t("participants.declined")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -633,7 +640,7 @@ export function EventModal({
|
|||||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{format(startD, "EEE, MMM d, yyyy")}
|
{formatEventDate(startD)}
|
||||||
</span>
|
</span>
|
||||||
{event.showWithoutTime ? (
|
{event.showWithoutTime ? (
|
||||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||||
@@ -762,16 +769,16 @@ export function EventModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
{isEdit ? t("events.edit") : t("events.create")}
|
{isEdit ? t("events.edit") : t("events.create")}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="px-6 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
|
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
|
||||||
@@ -981,69 +988,69 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{isEdit && onDelete && (
|
{isEdit && onDelete && (
|
||||||
showDeleteConfirm ? (
|
showDeleteConfirm ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
{t("form.delete_confirm")}
|
{t("form.delete_confirm")}
|
||||||
</span>
|
</span>
|
||||||
{hasParticipants && (
|
{hasParticipants && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{t("participants.cancel_notification")}
|
{t("participants.cancel_notification")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
|
||||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
|
||||||
>
|
|
||||||
{t("events.delete")}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
|
||||||
{t("form.cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||||
className="text-red-600 dark:text-red-400"
|
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||||
)}
|
{t("form.cancel")}
|
||||||
{isEdit && onDuplicate && !showDeleteConfirm && (
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleDuplicate}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
aria-label={t("events.duplicate")}
|
className="text-red-600 dark:text-red-400"
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4 mr-1" />
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
{t("events.duplicate")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)
|
||||||
</div>
|
)}
|
||||||
|
{isEdit && onDuplicate && !showDeleteConfirm && (
|
||||||
<div className="flex gap-2">
|
<Button
|
||||||
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
variant="ghost"
|
||||||
{t("form.cancel")}
|
size="sm"
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
aria-label={t("events.duplicate")}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 mr-1" />
|
||||||
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
|
)}
|
||||||
{t("form.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
|
||||||
|
{t("form.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface ContactFormProps {
|
|||||||
contact?: ContactCard | null;
|
contact?: ContactCard | null;
|
||||||
addressBooks?: AddressBook[];
|
addressBooks?: AddressBook[];
|
||||||
allKeywords?: string[];
|
allKeywords?: string[];
|
||||||
|
defaultAddressBookId?: string;
|
||||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@@ -143,11 +144,13 @@ function Select({ value, onChange, children, className }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
|
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) {
|
||||||
const t = useTranslations("contacts.form");
|
const t = useTranslations("contacts.form");
|
||||||
const isEditing = !!contact;
|
const isEditing = !!contact;
|
||||||
|
|
||||||
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
|
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
|
||||||
|
const findComponent = (...kinds: string[]) =>
|
||||||
|
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
|
||||||
|
|
||||||
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
|
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
|
||||||
function anniversaryDateToString(date: AnniversaryDate): string {
|
function anniversaryDateToString(date: AnniversaryDate): string {
|
||||||
@@ -210,11 +213,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [prefix, setPrefix] = useState(findComponent("prefix"));
|
const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
|
||||||
const [givenName, setGivenName] = useState(findComponent("given"));
|
const [givenName, setGivenName] = useState(findComponent("given"));
|
||||||
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
|
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
|
||||||
const [surname, setSurname] = useState(findComponent("surname"));
|
const [surname, setSurname] = useState(findComponent("surname"));
|
||||||
const [suffix, setSuffix] = useState(findComponent("suffix"));
|
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
|
||||||
|
|
||||||
const [nickname, setNickname] = useState(
|
const [nickname, setNickname] = useState(
|
||||||
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
|
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
|
||||||
@@ -328,8 +331,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
return ids[0];
|
return ids[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
|
||||||
|
return defaultAddressBookId;
|
||||||
|
}
|
||||||
return "";
|
return "";
|
||||||
}, [contact]);
|
}, [contact, defaultAddressBookId, addressBooks]);
|
||||||
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
||||||
|
|
||||||
const initialPhotoEntry = useMemo(() => {
|
const initialPhotoEntry = useMemo(() => {
|
||||||
@@ -436,12 +442,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
phonesMap[`p${i}`] = obj;
|
phonesMap[`p${i}`] = obj;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
|
||||||
const nameComponents = [];
|
const nameComponents = [];
|
||||||
if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() });
|
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
|
||||||
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
||||||
if (additionalName.trim()) nameComponents.push({ kind: "additional" as const, value: additionalName.trim() });
|
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
|
||||||
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
||||||
if (suffix.trim()) nameComponents.push({ kind: "suffix" as const, value: suffix.trim() });
|
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
|
||||||
|
|
||||||
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
||||||
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ interface ContactsSidebarProps {
|
|||||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||||
|
onShareAddressBook?: (addressBook: AddressBook) => void;
|
||||||
|
onCreateContactInBook?: (addressBook: AddressBook) => void;
|
||||||
|
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
||||||
onRenameKeyword?: (keyword: string) => void;
|
onRenameKeyword?: (keyword: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,9 @@ export function ContactsSidebar({
|
|||||||
onDropContacts,
|
onDropContacts,
|
||||||
onDropContactsToCategory,
|
onDropContactsToCategory,
|
||||||
onRenameAddressBook,
|
onRenameAddressBook,
|
||||||
|
onShareAddressBook,
|
||||||
|
onCreateContactInBook,
|
||||||
|
onDeleteAddressBook,
|
||||||
onRenameKeyword,
|
onRenameKeyword,
|
||||||
className,
|
className,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
@@ -288,7 +294,7 @@ export function ContactsSidebar({
|
|||||||
contactCount={contactCountByBook[book.id] || 0}
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
onDropContacts={onDropContacts}
|
onDropContacts={onDropContacts}
|
||||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -430,7 +436,7 @@ export function ContactsSidebar({
|
|||||||
contactCount={contactCountByBook[book.id] || 0}
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
onDropContacts={onDropContacts}
|
onDropContacts={onDropContacts}
|
||||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -438,24 +444,65 @@ export function ContactsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address book context menu */}
|
{/* Address book context menu */}
|
||||||
{bookContextMenu.data && onRenameAddressBook && (
|
{bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => {
|
||||||
<ContextMenu
|
const book = bookContextMenu.data;
|
||||||
ref={bookMenuRef}
|
const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false;
|
||||||
isOpen={bookContextMenu.isOpen}
|
const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false;
|
||||||
position={bookContextMenu.position}
|
const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared;
|
||||||
onClose={closeBookContextMenu}
|
const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false;
|
||||||
>
|
const showSeparator = (canCreate || canRename || canShare) && canDelete;
|
||||||
<ContextMenuItem
|
return (
|
||||||
icon={Pencil}
|
<ContextMenu
|
||||||
label={t("address_books.rename")}
|
ref={bookMenuRef}
|
||||||
onClick={() => {
|
isOpen={bookContextMenu.isOpen}
|
||||||
const book = bookContextMenu.data!;
|
position={bookContextMenu.position}
|
||||||
closeBookContextMenu();
|
onClose={closeBookContextMenu}
|
||||||
onRenameAddressBook(book);
|
>
|
||||||
}}
|
{canCreate && (
|
||||||
/>
|
<ContextMenuItem
|
||||||
</ContextMenu>
|
icon={UserPlus}
|
||||||
)}
|
label={t("address_books.new_contact_in_book")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onCreateContactInBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canRename && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("address_books.rename")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onRenameAddressBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={t("address_books.share")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onShareAddressBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showSeparator && <ContextMenuSeparator />}
|
||||||
|
{canDelete && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("address_books.delete")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onDeleteAddressBook(book);
|
||||||
|
}}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Keyword (category) context menu */}
|
{/* Keyword (category) context menu */}
|
||||||
{keywordContextMenu.data && onRenameKeyword && (
|
{keywordContextMenu.data && onRenameKeyword && (
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { TemplateForm } from "@/components/templates/template-form";
|
|||||||
import type { EmailTemplate } from "@/lib/template-types";
|
import type { EmailTemplate } from "@/lib/template-types";
|
||||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||||
import { findReplyIdentityId } from "@/lib/reply-identity";
|
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||||
|
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
|
|
||||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||||
@@ -67,6 +68,8 @@ interface EmailComposerProps {
|
|||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||||
|
inReplyTo?: string[];
|
||||||
|
references?: string[];
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onDiscardDraft?: (draftId: string) => void;
|
onDiscardDraft?: (draftId: string) => void;
|
||||||
@@ -87,6 +90,11 @@ interface EmailComposerProps {
|
|||||||
receivedAt?: string;
|
receivedAt?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
|
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
|
||||||
|
// Threading: parent's Message-ID and References, used to set RFC 5322
|
||||||
|
// In-Reply-To and References on outgoing replies. See #234.
|
||||||
|
messageId?: string;
|
||||||
|
inReplyTo?: string[];
|
||||||
|
references?: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +124,7 @@ export function EmailComposer({
|
|||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||||
|
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||||
@@ -721,7 +730,7 @@ export function EmailComposer({
|
|||||||
// Generate sub-addressed email if tag is set
|
// Generate sub-addressed email if tag is set
|
||||||
const fromEmail = currentIdentity?.email
|
const fromEmail = currentIdentity?.email
|
||||||
? subAddressTag
|
? subAddressTag
|
||||||
? generateSubAddress(currentIdentity.email, subAddressTag)
|
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||||
: currentIdentity.email
|
: currentIdentity.email
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -736,7 +745,8 @@ export function EmailComposer({
|
|||||||
fromEmail,
|
fromEmail,
|
||||||
draftId || undefined,
|
draftId || undefined,
|
||||||
uploadedAttachments,
|
uploadedAttachments,
|
||||||
currentIdentity?.name || undefined
|
currentIdentity?.name || undefined,
|
||||||
|
plainTextMode ? undefined : body
|
||||||
);
|
);
|
||||||
|
|
||||||
setDraftId(savedDraftId);
|
setDraftId(savedDraftId);
|
||||||
@@ -812,19 +822,27 @@ export function EmailComposer({
|
|||||||
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
|
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
|
||||||
} => {
|
} => {
|
||||||
const known = inlineImagesRef.current;
|
const known = inlineImagesRef.current;
|
||||||
if (known.length === 0) return { html, attachments: [] };
|
|
||||||
|
|
||||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
||||||
const used = new Map<string, typeof known[number]>();
|
const used = new Map<string, typeof known[number]>();
|
||||||
|
|
||||||
doc.querySelectorAll('img[data-cid]').forEach((img) => {
|
if (known.length > 0) {
|
||||||
const cid = img.getAttribute('data-cid');
|
doc.querySelectorAll('img[data-cid]').forEach((img) => {
|
||||||
if (!cid) return;
|
const cid = img.getAttribute('data-cid');
|
||||||
const entry = known.find((e) => e.cid === cid);
|
if (!cid) return;
|
||||||
if (!entry) return;
|
const entry = known.find((e) => e.cid === cid);
|
||||||
img.setAttribute('src', `cid:${cid}`);
|
if (!entry) return;
|
||||||
img.removeAttribute('data-cid');
|
img.setAttribute('src', `cid:${cid}`);
|
||||||
used.set(cid, entry);
|
img.removeAttribute('data-cid');
|
||||||
|
used.set(cid, entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient mail clients apply default <p> margins inside table cells,
|
||||||
|
// inflating row height. Tiptap wraps cell text in <p>, so force margin:0
|
||||||
|
// to match the composer's tight rows.
|
||||||
|
doc.querySelectorAll('td > p, th > p').forEach((p) => {
|
||||||
|
const existing = p.getAttribute('style') || '';
|
||||||
|
p.setAttribute('style', `margin:0;${existing}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -889,7 +907,7 @@ export function EmailComposer({
|
|||||||
|
|
||||||
const fromEmail = currentIdentity?.email
|
const fromEmail = currentIdentity?.email
|
||||||
? subAddressTag
|
? subAddressTag
|
||||||
? generateSubAddress(currentIdentity.email, subAddressTag)
|
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||||
: currentIdentity.email
|
: currentIdentity.email
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -905,6 +923,11 @@ export function EmailComposer({
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// RFC 5322 §3.6.4 threading — only continues the chain on a reply, not a forward.
|
||||||
|
const threadingHeaders = (mode === 'reply' || mode === 'replyAll')
|
||||||
|
? computeReplyThreadingHeaders(replyTo)
|
||||||
|
: null;
|
||||||
|
|
||||||
// In plain text mode, send text/plain only (no HTML body)
|
// In plain text mode, send text/plain only (no HTML body)
|
||||||
const finalBody = plainTextMode
|
const finalBody = plainTextMode
|
||||||
? appendPlainTextSignature(body, currentIdentity)
|
? appendPlainTextSignature(body, currentIdentity)
|
||||||
@@ -968,12 +991,22 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Build canonical MIME
|
// 4. Build canonical MIME
|
||||||
|
// mime-builder takes inReplyTo as a single ref-form msg-id (with brackets);
|
||||||
|
// references stays an array. threadingHeaders contains bare msg-ids.
|
||||||
|
const mimeInReplyTo = threadingHeaders?.inReplyTo[0]
|
||||||
|
? `<${threadingHeaders.inReplyTo[0]}>`
|
||||||
|
: undefined;
|
||||||
|
const mimeReferences = threadingHeaders?.references.length
|
||||||
|
? threadingHeaders.references.map(id => `<${id}>`)
|
||||||
|
: undefined;
|
||||||
const mimeBytes = buildMimeMessage({
|
const mimeBytes = buildMimeMessage({
|
||||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||||
to: toAddresses.map(e => ({ email: e })),
|
to: toAddresses.map(e => ({ email: e })),
|
||||||
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
||||||
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
|
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
|
||||||
subject,
|
subject,
|
||||||
|
inReplyTo: mimeInReplyTo,
|
||||||
|
references: mimeReferences,
|
||||||
textBody: finalBody,
|
textBody: finalBody,
|
||||||
htmlBody: finalHtmlBody,
|
htmlBody: finalHtmlBody,
|
||||||
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
|
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
|
||||||
@@ -986,6 +1019,8 @@ export function EmailComposer({
|
|||||||
to: toAddresses.map(e => ({ email: e })),
|
to: toAddresses.map(e => ({ email: e })),
|
||||||
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
||||||
subject,
|
subject,
|
||||||
|
inReplyTo: mimeInReplyTo,
|
||||||
|
references: mimeReferences,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 5. Sign if enabled
|
// 5. Sign if enabled
|
||||||
@@ -1042,6 +1077,8 @@ export function EmailComposer({
|
|||||||
fromName: currentIdentity?.name || undefined,
|
fromName: currentIdentity?.name || undefined,
|
||||||
identityId: currentIdentity?.id,
|
identityId: currentIdentity?.id,
|
||||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||||
|
inReplyTo: threadingHeaders?.inReplyTo,
|
||||||
|
references: threadingHeaders?.references,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1100,8 +1137,14 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={cn("flex h-full bg-background", className)}>
|
||||||
|
<PluginSlot
|
||||||
|
name="composer-sidebar"
|
||||||
|
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
|
||||||
|
/>
|
||||||
|
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
|
||||||
<div
|
<div
|
||||||
className={cn("flex flex-col h-full bg-background relative", className)}
|
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
|
||||||
data-tour="composer"
|
data-tour="composer"
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@@ -1173,7 +1216,7 @@ export function EmailComposer({
|
|||||||
>
|
>
|
||||||
{identities.map((identity) => {
|
{identities.map((identity) => {
|
||||||
const displayEmail = subAddressTag
|
const displayEmail = subAddressTag
|
||||||
? generateSubAddress(identity.email, subAddressTag)
|
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
|
||||||
: identity.email;
|
: identity.email;
|
||||||
return (
|
return (
|
||||||
<option key={identity.id} value={identity.id}>
|
<option key={identity.id} value={identity.id}>
|
||||||
@@ -1186,7 +1229,7 @@ export function EmailComposer({
|
|||||||
<span className="text-sm text-foreground flex-1 truncate">
|
<span className="text-sm text-foreground flex-1 truncate">
|
||||||
{subAddressTag ? (
|
{subAddressTag ? (
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
|
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1674,6 +1717,11 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<PluginSlot
|
||||||
|
name="composer-sidebar-right"
|
||||||
|
className="hidden md:flex shrink-0 h-full overflow-hidden border-l border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Email, Identity } from '@/lib/jmap/types';
|
import type { Email, Identity } from '@/lib/jmap/types';
|
||||||
import { parseSubAddress } from '@/lib/sub-addressing';
|
import { parseSubAddress } from '@/lib/sub-addressing';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
interface EmailIdentityBadgeProps {
|
interface EmailIdentityBadgeProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -20,12 +21,13 @@ export function EmailIdentityBadge({
|
|||||||
className,
|
className,
|
||||||
}: EmailIdentityBadgeProps) {
|
}: EmailIdentityBadgeProps) {
|
||||||
const t = useTranslations('identities.badge');
|
const t = useTranslations('identities.badge');
|
||||||
|
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||||
|
|
||||||
const fromAddress = email.from?.[0]?.email;
|
const fromAddress = email.from?.[0]?.email;
|
||||||
if (!fromAddress) return null;
|
if (!fromAddress) return null;
|
||||||
|
|
||||||
// Parse the from address to check for sub-addressing
|
// Parse the from address to check for sub-addressing
|
||||||
const parsedFrom = parseSubAddress(fromAddress);
|
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
|
||||||
|
|
||||||
// Find matching identity (email sent BY the user)
|
// Find matching identity (email sent BY the user)
|
||||||
const matchingIdentity = identities.find(
|
const matchingIdentity = identities.find(
|
||||||
@@ -37,7 +39,7 @@ export function EmailIdentityBadge({
|
|||||||
if (!matchingIdentity) {
|
if (!matchingIdentity) {
|
||||||
// Check all TO addresses for sub-address tags matching user's identities
|
// Check all TO addresses for sub-address tags matching user's identities
|
||||||
for (const recipient of email.to || []) {
|
for (const recipient of email.to || []) {
|
||||||
const parsedTo = parseSubAddress(recipient.email);
|
const parsedTo = parseSubAddress(recipient.email, subAddressDelimiter);
|
||||||
if (parsedTo.tag) {
|
if (parsedTo.tag) {
|
||||||
// Check if this base email matches any of the user's identities
|
// Check if this base email matches any of the user's identities
|
||||||
const matchingToIdentity = identities.find(
|
const matchingToIdentity = identities.find(
|
||||||
@@ -70,7 +72,7 @@ export function EmailIdentityBadge({
|
|||||||
title={t('sub_address_tag', { tag: displayTag })}
|
title={t('sub_address_tag', { tag: displayTag })}
|
||||||
>
|
>
|
||||||
<Tag className="w-3 h-3" />
|
<Tag className="w-3 h-3" />
|
||||||
<span className="font-mono">+{displayTag}</span>
|
<span className="font-mono">{subAddressDelimiter}{displayTag}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,7 +116,7 @@ export function EmailIdentityBadge({
|
|||||||
aria-label={t('sub_address_tag', { tag: displayTag })}
|
aria-label={t('sub_address_tag', { tag: displayTag })}
|
||||||
>
|
>
|
||||||
<Tag className="w-3 h-3" />
|
<Tag className="w-3 h-3" />
|
||||||
<span className="font-mono">{t('subaddress_tag', { tag: displayTag })}</span>
|
<span className="font-mono">{subAddressDelimiter}{displayTag}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
@@ -49,6 +50,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||||
@@ -164,6 +166,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -285,7 +288,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||||
{showPreview && density !== 'extra-compact' && (
|
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
isUnread
|
isUnread
|
||||||
|
|||||||
+284
-156
@@ -2285,7 +2285,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Sanitize and prepare email HTML content
|
// Sanitize and prepare email HTML content
|
||||||
const emailContent = useMemo(() => {
|
const emailContent = useMemo(() => {
|
||||||
if (!email) return { html: "", isHtml: false };
|
if (!email) return { html: "", isHtml: false, hasStyleTag: false };
|
||||||
|
|
||||||
// Check if we have body values
|
// Check if we have body values
|
||||||
if (email.bodyValues) {
|
if (email.bodyValues) {
|
||||||
@@ -2338,8 +2338,7 @@ export function EmailViewer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (shouldBlockExternal) {
|
if (shouldBlockExternal) {
|
||||||
sanitizeConfig.FORBID_TAGS.push('link');
|
sanitizeConfig.FORBID_TAGS = [...sanitizeConfig.FORBID_TAGS, 'link'];
|
||||||
sanitizeConfig.FORBID_ATTR.push('background');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
@@ -2357,11 +2356,19 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bgAttr = node.getAttribute?.('background');
|
||||||
|
if (bgAttr && (bgAttr.startsWith('http://') || bgAttr.startsWith('https://') || bgAttr.startsWith('//'))) {
|
||||||
|
node.setAttribute('data-blocked-background', bgAttr);
|
||||||
|
node.removeAttribute('background');
|
||||||
|
blockedExternalContent = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (htmlNode.style) {
|
if (htmlNode.style) {
|
||||||
const style = htmlNode.style.cssText;
|
const style = htmlNode.style.cssText;
|
||||||
if (style && style.includes('url(')) {
|
if (style && style.includes('url(')) {
|
||||||
const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
|
const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
|
||||||
if (urlMatch) {
|
if (urlMatch) {
|
||||||
|
node.setAttribute('data-blocked-style', style);
|
||||||
htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
|
htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
|
||||||
blockedExternalContent = true;
|
blockedExternalContent = true;
|
||||||
}
|
}
|
||||||
@@ -2395,7 +2402,8 @@ export function EmailViewer({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
html: cleanHtml,
|
html: cleanHtml,
|
||||||
isHtml: true
|
isHtml: true,
|
||||||
|
hasStyleTag: /<style[\s>]/i.test(htmlContent),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2405,7 +2413,8 @@ export function EmailViewer({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
html: plainTextToSafeHtml(textContent),
|
html: plainTextToSafeHtml(textContent),
|
||||||
isHtml: false
|
isHtml: false,
|
||||||
|
hasStyleTag: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2419,15 +2428,21 @@ export function EmailViewer({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
|
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
|
||||||
isHtml: false
|
isHtml: false,
|
||||||
|
hasStyleTag: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
||||||
isHtml: false
|
isHtml: false,
|
||||||
|
hasStyleTag: false,
|
||||||
};
|
};
|
||||||
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
|
// Intentionally omit allowExternalContent and trust state from deps:
|
||||||
|
// toggling permission imperatively unblocks content via restoreBlockedContent
|
||||||
|
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [email, externalContentPolicy, cidBlobUrls]);
|
||||||
|
|
||||||
// Override email content with S/MIME decrypted content when available
|
// Override email content with S/MIME decrypted content when available
|
||||||
const effectiveEmailContent = useMemo(() => {
|
const effectiveEmailContent = useMemo(() => {
|
||||||
@@ -2439,26 +2454,26 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
|
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||||
return { html: cleanHtml, isHtml: true };
|
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(smimeDecryptedHtml) };
|
||||||
}
|
}
|
||||||
if (smimeDecryptedText) {
|
if (smimeDecryptedText) {
|
||||||
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false };
|
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false };
|
||||||
}
|
}
|
||||||
// TNEF (winmail.dat) extracted content
|
// TNEF (winmail.dat) extracted content
|
||||||
if (tnefHtml) {
|
if (tnefHtml) {
|
||||||
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||||
return { html: cleanHtml, isHtml: true };
|
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml) };
|
||||||
}
|
}
|
||||||
if (tnefText) {
|
if (tnefText) {
|
||||||
return { html: plainTextToSafeHtml(tnefText), isHtml: false };
|
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false };
|
||||||
}
|
}
|
||||||
// Embedded message/rfc822 unwrapped content
|
// Embedded message/rfc822 unwrapped content
|
||||||
if (embeddedEmailHtml) {
|
if (embeddedEmailHtml) {
|
||||||
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||||
return { html: cleanHtml, isHtml: true };
|
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml) };
|
||||||
}
|
}
|
||||||
if (embeddedEmailText) {
|
if (embeddedEmailText) {
|
||||||
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false };
|
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false };
|
||||||
}
|
}
|
||||||
return emailContent;
|
return emailContent;
|
||||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||||
@@ -2600,10 +2615,14 @@ export function EmailViewer({
|
|||||||
|
|
||||||
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
|
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
|
||||||
|
|
||||||
|
// Bare HTML emails (no <style>) tend to be plain prose without their own
|
||||||
|
// layout — give them the same padding as plain-text mails (.email-content-text).
|
||||||
|
const bodyPadding = effectiveEmailContent.hasStyleTag ? '0' : '1rem 1.25rem';
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||||
img { max-width: 100% !important; height: auto !important; }
|
img { max-width: 100% !important; height: auto !important; }
|
||||||
a { color: #1a73e8; }
|
a { color: #1a73e8; }
|
||||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||||
@@ -2613,6 +2632,79 @@ export function EmailViewer({
|
|||||||
</style></head><body>${effectiveEmailContent.html}</body></html>`;
|
</style></head><body>${effectiveEmailContent.html}</body></html>`;
|
||||||
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
|
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
|
||||||
|
|
||||||
|
// Imperatively restore blocked external content inside the iframe document.
|
||||||
|
// Avoids re-rendering the iframe srcDoc (which would reload and flash) when
|
||||||
|
// the user clicks "Load images" or "Trust sender".
|
||||||
|
const restoreBlockedContent = useCallback(() => {
|
||||||
|
const doc = iframeRef.current?.contentDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
|
||||||
|
doc.querySelectorAll('img[data-blocked-src]').forEach((node) => {
|
||||||
|
const el = node as HTMLImageElement;
|
||||||
|
const src = el.getAttribute('data-blocked-src');
|
||||||
|
if (src) {
|
||||||
|
el.setAttribute('src', src);
|
||||||
|
el.style.display = '';
|
||||||
|
el.removeAttribute('data-blocked-src');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('[data-blocked-style]').forEach((node) => {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const style = el.getAttribute('data-blocked-style');
|
||||||
|
if (style !== null) {
|
||||||
|
el.style.cssText = style;
|
||||||
|
el.removeAttribute('data-blocked-style');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('[data-blocked-background]').forEach((node) => {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const bg = el.getAttribute('data-blocked-background');
|
||||||
|
if (bg) {
|
||||||
|
el.setAttribute('background', bg);
|
||||||
|
el.removeAttribute('data-blocked-background');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('[data-blocked-collapsed-style]').forEach((node) => {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const style = el.getAttribute('data-blocked-collapsed-style');
|
||||||
|
if (style !== null) {
|
||||||
|
el.style.cssText = style;
|
||||||
|
el.removeAttribute('data-blocked-collapsed-style');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Whenever permission is granted (allow toggled, or sender becomes trusted),
|
||||||
|
// restore blocked content in the existing iframe — no srcDoc rebuild.
|
||||||
|
const senderEmailLower = email?.from?.[0]?.email?.toLowerCase();
|
||||||
|
const senderIsTrustedNow = senderEmailLower
|
||||||
|
? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower))
|
||||||
|
: false;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasBlockedContent) return;
|
||||||
|
if (!allowExternalContent && !senderIsTrustedNow) return;
|
||||||
|
restoreBlockedContent();
|
||||||
|
}, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]);
|
||||||
|
|
||||||
|
// Tracks the last rendered body height so the loading skeleton can hold
|
||||||
|
// the same size — avoids the body shrink/expand flash when switching emails.
|
||||||
|
const lastBodyHeightRef = useRef<number>(300);
|
||||||
|
|
||||||
|
// True while the new email's body is still being fetched. Catches the
|
||||||
|
// window between selectedEmail changing and isLoading flipping true, so the
|
||||||
|
// quick reply / body don't flicker through a partial render.
|
||||||
|
const isBodyLoading = isLoading || !email?.bodyValues || Object.keys(email.bodyValues).length === 0;
|
||||||
|
|
||||||
|
// Gates the quick reply on the iframe having loaded the current srcDoc, so
|
||||||
|
// it doesn't flash in below a still-resizing iframe.
|
||||||
|
const [iframeReady, setIframeReady] = useState(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setIframeReady(false);
|
||||||
|
}, [emailIframeSrcDoc]);
|
||||||
|
|
||||||
const handleIframeLoad = useCallback(() => {
|
const handleIframeLoad = useCallback(() => {
|
||||||
const iframe = iframeRef.current;
|
const iframe = iframeRef.current;
|
||||||
if (!iframe) return;
|
if (!iframe) return;
|
||||||
@@ -2623,9 +2715,13 @@ export function EmailViewer({
|
|||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
const height = doc.documentElement.scrollHeight;
|
const height = doc.documentElement.scrollHeight;
|
||||||
iframe.style.height = height + 'px';
|
iframe.style.height = height + 'px';
|
||||||
|
lastBodyHeightRef.current = height;
|
||||||
});
|
});
|
||||||
resizeObserver.observe(doc.body);
|
resizeObserver.observe(doc.body);
|
||||||
iframe.style.height = doc.documentElement.scrollHeight + 'px';
|
const initialHeight = doc.documentElement.scrollHeight;
|
||||||
|
iframe.style.height = initialHeight + 'px';
|
||||||
|
lastBodyHeightRef.current = initialHeight;
|
||||||
|
setIframeReady(true);
|
||||||
|
|
||||||
// Make links open in new tab
|
// Make links open in new tab
|
||||||
doc.querySelectorAll('a').forEach(a => {
|
doc.querySelectorAll('a').forEach(a => {
|
||||||
@@ -2946,11 +3042,6 @@ export function EmailViewer({
|
|||||||
|
|
||||||
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||||
{isLoading && (
|
|
||||||
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Archive */}
|
{/* Archive */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3436,70 +3527,172 @@ export function EmailViewer({
|
|||||||
moreMenuOpen ? "translate-x-0" : "translate-x-full"
|
moreMenuOpen ? "translate-x-0" : "translate-x-full"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
|
{moreMenuSub ? (
|
||||||
<Button variant="ghost" size="icon" onClick={() => setMoreMenuOpen(false)} className="h-9 w-9">
|
<button
|
||||||
|
onClick={() => setMoreMenuSub(null)}
|
||||||
|
className="flex items-center gap-1 -ml-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
{moreMenuSub === 'move' ? t('move_to') : t('tag')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => { setMoreMenuOpen(false); setMoreMenuSub(null); }} className="h-9 w-9">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
<button
|
{moreMenuSub === null && (
|
||||||
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Archive className="w-5 h-5" />
|
|
||||||
{t('archive')}
|
|
||||||
</button>
|
|
||||||
{/* Move to folder */}
|
|
||||||
{moveTree.length > 0 && onMoveToMailbox && (
|
|
||||||
<>
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Archive className="w-5 h-5" />
|
||||||
|
{t('archive')}
|
||||||
|
</button>
|
||||||
|
{/* Move to folder (opens sub-view) */}
|
||||||
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMoreMenuSub('move')}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<FolderInput className="w-5 h-5" />
|
||||||
|
<span className="flex-1">{t('move_to')}</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Tag (opens sub-view) */}
|
||||||
|
{colorOptions.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMoreMenuSub('tag')}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Tag className="w-5 h-5" />
|
||||||
|
<span className="flex-1">{t('tag')}</span>
|
||||||
|
{currentColors.length > 0 && (
|
||||||
|
<div className="flex -space-x-1 mr-1">
|
||||||
|
{currentColors.slice(0, 3).map((c) => {
|
||||||
|
const opt = colorOptions.find((o) => o.value === c);
|
||||||
|
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Spam */}
|
||||||
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isInJunkFolder ? (
|
||||||
|
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Toggle read state */}
|
||||||
|
<button
|
||||||
|
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
|
||||||
|
{isUnread ? t('mark_read') : t('mark_unread')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Printer className="w-5 h-5" />
|
||||||
|
{t('print')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Code className="w-5 h-5" />
|
||||||
|
{t('view_source')}
|
||||||
|
</button>
|
||||||
|
{effectiveEmailContent.isHtml && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||||
|
{isDark ? 'View in light mode' : 'View in dark mode'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
|
<button
|
||||||
{(() => {
|
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
||||||
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
return nodes.map((node) => {
|
>
|
||||||
const Icon = getMoveMailboxIcon(node.role);
|
<Download className="w-5 h-5" />
|
||||||
const isTarget = moveTargetIds.has(node.id);
|
{t('export_email')}
|
||||||
return (
|
</button>
|
||||||
<div key={node.id}>
|
<button
|
||||||
{isTarget ? (
|
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
|
||||||
<button
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); }}
|
>
|
||||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
|
<Upload className="w-5 h-5" />
|
||||||
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
{t('import_email')}
|
||||||
>
|
</button>
|
||||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
{onShowShortcuts && (
|
||||||
<span className="truncate">{node.name}</span>
|
<button
|
||||||
</button>
|
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
|
||||||
) : (
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
<div
|
>
|
||||||
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
|
<Keyboard className="w-5 h-5" />
|
||||||
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
{t('keyboard_shortcuts')}
|
||||||
>
|
</button>
|
||||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
)}
|
||||||
<span>{node.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return renderMobileNodes(moveTree);
|
|
||||||
})()}
|
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Tags */}
|
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
|
||||||
{colorOptions.length > 0 && (
|
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
const Icon = getMoveMailboxIcon(node.role);
|
||||||
|
const isTarget = moveTargetIds.has(node.id);
|
||||||
|
return (
|
||||||
|
<div key={node.id}>
|
||||||
|
{isTarget ? (
|
||||||
|
<button
|
||||||
|
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
|
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
|
||||||
|
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
|
||||||
|
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{node.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return renderMobileNodes(moveTree);
|
||||||
|
})()}
|
||||||
|
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
|
||||||
{colorOptions.map((option) => {
|
{colorOptions.map((option) => {
|
||||||
const isActive = currentColors.includes(option.value);
|
const isActive = currentColors.includes(option.value);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
||||||
isActive && "bg-accent font-medium"
|
isActive && "bg-accent font-medium"
|
||||||
@@ -3513,99 +3706,20 @@ export function EmailViewer({
|
|||||||
})}
|
})}
|
||||||
{currentColors.length > 0 && (
|
{currentColors.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 flex-shrink-0" />
|
<X className="w-4 h-4 flex-shrink-0" />
|
||||||
<span>{t('remove_color')}</span>
|
<span>{t('remove_color')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Spam */}
|
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
|
||||||
<button
|
|
||||||
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
{isInJunkFolder ? (
|
|
||||||
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
|
|
||||||
) : (
|
|
||||||
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
|
|
||||||
)}
|
|
||||||
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Toggle read state */}
|
|
||||||
<button
|
|
||||||
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
|
|
||||||
{isUnread ? t('mark_read') : t('mark_unread')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Printer className="w-5 h-5" />
|
|
||||||
{t('print')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Code className="w-5 h-5" />
|
|
||||||
{t('view_source')}
|
|
||||||
</button>
|
|
||||||
{effectiveEmailContent.isHtml && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
|
||||||
{isDark ? 'View in light mode' : 'View in dark mode'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
<button
|
|
||||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Download className="w-5 h-5" />
|
|
||||||
{t('export_email')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Upload className="w-5 h-5" />
|
|
||||||
{t('import_email')}
|
|
||||||
</button>
|
|
||||||
{onShowShortcuts && (
|
|
||||||
<button
|
|
||||||
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Keyboard className="w-5 h-5" />
|
|
||||||
{t('keyboard_shortcuts')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Main email content */}
|
{/* Main email content */}
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||||
{/* Loading overlay when fetching new email */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 bg-background/60 backdrop-blur-[2px] z-50 flex items-center justify-center animate-in fade-in duration-200">
|
|
||||||
<div className="bg-background rounded-lg shadow-lg border border-border p-4 flex items-center gap-3">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
|
||||||
<span className="text-sm font-medium text-foreground">{t('loading_email')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* === TOOLBAR (top position) === */}
|
{/* === TOOLBAR (top position) === */}
|
||||||
{toolbarPosition === 'top' && (
|
{toolbarPosition === 'top' && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -4602,14 +4716,28 @@ export function EmailViewer({
|
|||||||
<PluginSlot name="email-banner" extraProps={{ email }} />
|
<PluginSlot name="email-banner" extraProps={{ email }} />
|
||||||
|
|
||||||
{/* Email Body */}
|
{/* Email Body */}
|
||||||
<div className="email-content-wrapper overflow-x-auto">
|
<div className={cn(
|
||||||
{effectiveEmailContent.isHtml ? (
|
"email-content-wrapper overflow-x-auto",
|
||||||
|
emailAlwaysLightMode ? "bg-white email-content-light" : "bg-background"
|
||||||
|
)}>
|
||||||
|
{isBodyLoading ? (
|
||||||
|
<div
|
||||||
|
className="space-y-3 px-6 py-4 animate-pulse"
|
||||||
|
style={{ minHeight: `${lastBodyHeightRef.current}px` }}
|
||||||
|
>
|
||||||
|
<div className="h-2 bg-muted/15 rounded w-full"></div>
|
||||||
|
<div className="h-2 bg-muted/15 rounded w-5/6"></div>
|
||||||
|
<div className="h-2 bg-muted/15 rounded w-4/6"></div>
|
||||||
|
<div className="h-2 bg-muted/15 rounded w-full"></div>
|
||||||
|
<div className="h-2 bg-muted/15 rounded w-3/4"></div>
|
||||||
|
</div>
|
||||||
|
) : effectiveEmailContent.isHtml ? (
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
srcDoc={emailIframeSrcDoc}
|
srcDoc={emailIframeSrcDoc}
|
||||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||||
title="Email content"
|
title="Email content"
|
||||||
className="w-full border-0 rounded"
|
className="w-full border-0"
|
||||||
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
|
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
|
||||||
onLoad={handleIframeLoad}
|
onLoad={handleIframeLoad}
|
||||||
/>
|
/>
|
||||||
@@ -4630,8 +4758,8 @@ export function EmailViewer({
|
|||||||
|
|
||||||
<PluginSlot name="email-footer" />
|
<PluginSlot name="email-footer" />
|
||||||
|
|
||||||
{/* Quick Reply Section - hidden for drafts */}
|
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||||
{!isDraft && (<div className={cn(
|
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn(
|
||||||
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
||||||
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
|
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useCallback } from "react";
|
import React, { useEffect, useCallback, useState, useRef } from "react";
|
||||||
import { useEditor, EditorContent } from "@tiptap/react";
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Underline from "@tiptap/extension-underline";
|
import Underline from "@tiptap/extension-underline";
|
||||||
@@ -10,6 +10,10 @@ import { TextStyle } from "@tiptap/extension-text-style";
|
|||||||
import Color from "@tiptap/extension-color";
|
import Color from "@tiptap/extension-color";
|
||||||
import { ResizableImage } from "@/components/email/resizable-image";
|
import { ResizableImage } from "@/components/email/resizable-image";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { Table } from "@tiptap/extension-table";
|
||||||
|
import { TableRow } from "@tiptap/extension-table-row";
|
||||||
|
import { TableHeader } from "@tiptap/extension-table-header";
|
||||||
|
import { TableCell } from "@tiptap/extension-table-cell";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
@@ -29,6 +33,10 @@ import {
|
|||||||
RemoveFormatting,
|
RemoveFormatting,
|
||||||
Heading1,
|
Heading1,
|
||||||
Heading2,
|
Heading2,
|
||||||
|
Table as TableIcon,
|
||||||
|
Trash2,
|
||||||
|
Rows3,
|
||||||
|
Columns3,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export interface InlineImageUpload {
|
export interface InlineImageUpload {
|
||||||
@@ -79,6 +87,43 @@ function ToolbarSeparator() {
|
|||||||
return <div className="w-px h-5 bg-border mx-0.5" />;
|
return <div className="w-px h-5 bg-border mx-0.5" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TABLE_PICKER_ROWS = 6;
|
||||||
|
const TABLE_PICKER_COLS = 8;
|
||||||
|
|
||||||
|
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
||||||
|
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="grid gap-0.5"
|
||||||
|
style={{ gridTemplateColumns: `repeat(${TABLE_PICKER_COLS}, 1fr)` }}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: TABLE_PICKER_ROWS * TABLE_PICKER_COLS }).map((_, i) => {
|
||||||
|
const r = Math.floor(i / TABLE_PICKER_COLS);
|
||||||
|
const c = i % TABLE_PICKER_COLS;
|
||||||
|
const active = hover && r <= hover.r && c <= hover.c;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={() => setHover({ r, c })}
|
||||||
|
onClick={() => onPick(r + 1, c + 1)}
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4 border border-border/60 rounded-[2px] transition-colors",
|
||||||
|
active ? "bg-primary border-primary" : "bg-background hover:bg-accent"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1.5 text-center">
|
||||||
|
{hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function RichTextEditor({
|
export function RichTextEditor({
|
||||||
content,
|
content,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -109,6 +154,27 @@ export function RichTextEditor({
|
|||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
}),
|
}),
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
HTMLAttributes: {
|
||||||
|
border: "1",
|
||||||
|
cellpadding: "6",
|
||||||
|
cellspacing: "0",
|
||||||
|
width: "100%",
|
||||||
|
style: "width:100%;border-collapse:collapse;",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableHeader.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
style: "padding:6px 8px;border:1px solid #ccc;background-color:#f5f5f5;color:#1f2937;text-align:left;",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TableCell.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -188,6 +254,20 @@ export function RichTextEditor({
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||||
|
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tableMenuOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (tableWrapperRef.current && !tableWrapperRef.current.contains(e.target as Node)) {
|
||||||
|
setTableMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [tableMenuOpen]);
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("min-h-[100px]", className)} />
|
<div className={cn("min-h-[100px]", className)} />
|
||||||
@@ -309,6 +389,89 @@ export function RichTextEditor({
|
|||||||
<LinkIcon className="w-4 h-4" />
|
<LinkIcon className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<div ref={tableWrapperRef} className="relative">
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("table")}
|
||||||
|
onClick={() => setTableMenuOpen((v) => !v)}
|
||||||
|
title="Table"
|
||||||
|
>
|
||||||
|
<TableIcon className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
{tableMenuOpen && (
|
||||||
|
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
|
||||||
|
{editor.isActive("table") ? (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Rows3 className="w-4 h-4" /> Add row above
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Rows3 className="w-4 h-4" /> Add row below
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Columns3 className="w-4 h-4" /> Add column before
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Columns3 className="w-4 h-4" /> Add column after
|
||||||
|
</button>
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> Delete row
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> Delete column
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Rows3 className="w-4 h-4" /> Toggle header row
|
||||||
|
</button>
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left text-red-600 dark:text-red-400"
|
||||||
|
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> Delete table
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TableSizePicker
|
||||||
|
onPick={(rows, cols) => {
|
||||||
|
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
|
||||||
|
setTableMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ToolbarSeparator />
|
<ToolbarSeparator />
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||||
@@ -182,6 +184,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -306,7 +309,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{email.subject || "(no subject)"}
|
{email.subject || "(no subject)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPreview && density !== 'extra-compact' && (
|
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
isUnread
|
isUnread
|
||||||
@@ -359,6 +362,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
@@ -376,6 +380,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)).slice(0, 4)
|
)).slice(0, 4)
|
||||||
: participantNames;
|
: participantNames;
|
||||||
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
|
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
|
||||||
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
|
|
||||||
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
||||||
email: latestEmail,
|
email: latestEmail,
|
||||||
@@ -563,6 +568,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
email={avatarPerson?.email}
|
email={avatarPerson?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -709,7 +715,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{latestEmail.subject || "(no subject)"}
|
{latestEmail.subject || "(no subject)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPreview && density !== 'extra-compact' && (
|
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
hasUnread
|
hasUnread
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useIdentityStore } from '@/stores/identity-store';
|
import { useIdentityStore } from '@/stores/identity-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import {
|
import {
|
||||||
generateSubAddress,
|
generateSubAddress,
|
||||||
extractDomain,
|
extractDomain,
|
||||||
@@ -35,6 +36,7 @@ export function SubAddressHelper({
|
|||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore();
|
const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore();
|
||||||
|
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||||
|
|
||||||
// Get suggestions based on recipient (memoized for performance)
|
// Get suggestions based on recipient (memoized for performance)
|
||||||
const suggestions = useMemo(() => {
|
const suggestions = useMemo(() => {
|
||||||
@@ -47,7 +49,7 @@ export function SubAddressHelper({
|
|||||||
}, [recipientEmails]);
|
}, [recipientEmails]);
|
||||||
|
|
||||||
// Generate preview
|
// Generate preview
|
||||||
const preview = tag ? generateSubAddress(baseEmail, tag) : baseEmail;
|
const preview = tag ? generateSubAddress(baseEmail, tag, subAddressDelimiter) : baseEmail;
|
||||||
|
|
||||||
// Close popover when clicking outside
|
// Close popover when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -226,7 +228,7 @@ export function SubAddressHelper({
|
|||||||
|
|
||||||
{/* Help Text */}
|
{/* Help Text */}
|
||||||
<div className="mb-3 text-xs text-muted-foreground">
|
<div className="mb-3 text-xs text-muted-foreground">
|
||||||
{t('help_text')}
|
{t('help_text', { delimiter: subAddressDelimiter })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Use Address Button */}
|
{/* Use Address Button */}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuHeader,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
CheckCheck,
|
||||||
|
MailOpen,
|
||||||
|
Mails,
|
||||||
|
Trash2,
|
||||||
|
FolderPlus,
|
||||||
|
Pencil,
|
||||||
|
FolderX,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MailboxContextTarget =
|
||||||
|
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
|
||||||
|
| { kind: "folders-section" };
|
||||||
|
|
||||||
|
interface MailboxContextMenuProps {
|
||||||
|
target: MailboxContextTarget | null;
|
||||||
|
position: Position;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onMarkFolderRead?: (mailboxId: string) => void;
|
||||||
|
onMarkFolderTreeRead?: (mailboxId: string) => void;
|
||||||
|
onMarkAllFoldersRead?: () => void;
|
||||||
|
onEmptyFolder?: (mailboxId: string) => void;
|
||||||
|
onCreateSubfolder?: (parentId: string) => void;
|
||||||
|
onCreateFolder?: () => void;
|
||||||
|
onRenameFolder?: (mailboxId: string) => void;
|
||||||
|
onDeleteFolder?: (mailboxId: string) => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MailboxContextMenu({
|
||||||
|
target,
|
||||||
|
position,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
menuRef,
|
||||||
|
onMarkFolderRead,
|
||||||
|
onMarkFolderTreeRead,
|
||||||
|
onMarkAllFoldersRead,
|
||||||
|
onEmptyFolder,
|
||||||
|
onCreateSubfolder,
|
||||||
|
onCreateFolder,
|
||||||
|
onRenameFolder,
|
||||||
|
onDeleteFolder,
|
||||||
|
onRefresh,
|
||||||
|
}: MailboxContextMenuProps) {
|
||||||
|
const t = useTranslations("mailbox_context_menu");
|
||||||
|
|
||||||
|
const handleAction = (action: () => void) => {
|
||||||
|
action();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
if (target.kind === "folders-section") {
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={CheckCheck}
|
||||||
|
label={t("mark_all_folders_read")}
|
||||||
|
onClick={() => handleAction(onMarkAllFoldersRead!)}
|
||||||
|
disabled={!onMarkAllFoldersRead}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={FolderPlus}
|
||||||
|
label={t("new_folder")}
|
||||||
|
onClick={() => handleAction(onCreateFolder!)}
|
||||||
|
disabled={!onCreateFolder}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={RefreshCw}
|
||||||
|
label={t("refresh")}
|
||||||
|
onClick={() => handleAction(onRefresh!)}
|
||||||
|
disabled={!onRefresh}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailbox = target.mailbox;
|
||||||
|
const isTrashOrJunk = mailbox.role === "trash" || mailbox.role === "junk";
|
||||||
|
const isSystem =
|
||||||
|
!!mailbox.role &&
|
||||||
|
["inbox", "sent", "drafts", "trash", "junk", "archive"].includes(mailbox.role);
|
||||||
|
const canRename = mailbox.myRights?.mayRename !== false && !isSystem;
|
||||||
|
const canDelete = mailbox.myRights?.mayDelete !== false && !isSystem;
|
||||||
|
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
|
||||||
|
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
|
||||||
|
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuHeader>{mailbox.name}</ContextMenuHeader>
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={MailOpen}
|
||||||
|
label={t("mark_folder_read")}
|
||||||
|
onClick={() => handleAction(() => onMarkFolderRead?.(mailbox.id))}
|
||||||
|
disabled={!onMarkFolderRead || !canSetSeen}
|
||||||
|
/>
|
||||||
|
{target.hasChildren && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Mails}
|
||||||
|
label={t("mark_folder_tree_read")}
|
||||||
|
onClick={() => handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
|
||||||
|
disabled={!onMarkFolderTreeRead || !canSetSeen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={FolderPlus}
|
||||||
|
label={t("new_subfolder")}
|
||||||
|
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
|
||||||
|
disabled={!onCreateSubfolder || !canCreateChild}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("rename")}
|
||||||
|
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
||||||
|
disabled={!onRenameFolder || !canRename}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={FolderX}
|
||||||
|
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
|
||||||
|
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
|
||||||
|
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("delete_folder")}
|
||||||
|
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
|
||||||
|
disabled={!onDeleteFolder || !canDelete}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={RefreshCw}
|
||||||
|
label={t("refresh")}
|
||||||
|
onClick={() => handleAction(onRefresh!)}
|
||||||
|
disabled={!onRefresh}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
|||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
||||||
import NextLink from "next/link";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
@@ -336,9 +335,9 @@ export function NavigationRail({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Admin (Stalwart admins) */}
|
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
|
||||||
{isStalwartAdmin && (
|
{isStalwartAdmin && (
|
||||||
<NextLink
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||||
@@ -348,7 +347,7 @@ export function NavigationRail({
|
|||||||
>
|
>
|
||||||
<Shield className="w-5 h-5" />
|
<Shield className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
|
||||||
</NextLink>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
@@ -514,13 +513,13 @@ export function NavigationRail({
|
|||||||
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
|
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
|
||||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||||
{isStalwartAdmin && (
|
{isStalwartAdmin && (
|
||||||
<NextLink
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
title={t("admin") || "Admin"}
|
title={t("admin") || "Admin"}
|
||||||
>
|
>
|
||||||
<Shield className="w-[18px] h-[18px]" />
|
<Shield className="w-[18px] h-[18px]" />
|
||||||
</NextLink>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
|
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
|
||||||
import { useAccountStore } from '@/stores/account-store';
|
import { useAccountStore } from '@/stores/account-store';
|
||||||
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
|
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
|
||||||
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
|
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
|
||||||
@@ -56,6 +58,15 @@ interface SidebarProps {
|
|||||||
onCompose?: () => void;
|
onCompose?: () => void;
|
||||||
onSidebarClose?: () => void;
|
onSidebarClose?: () => void;
|
||||||
onUnreadFilterClick?: (mailboxId: string) => void;
|
onUnreadFilterClick?: (mailboxId: string) => void;
|
||||||
|
onMarkFolderRead?: (mailboxId: string) => void;
|
||||||
|
onMarkFolderTreeRead?: (mailboxId: string) => void;
|
||||||
|
onMarkAllFoldersRead?: () => void;
|
||||||
|
onEmptyFolder?: (mailboxId: string) => void;
|
||||||
|
onCreateSubfolder?: (parentId: string) => void;
|
||||||
|
onCreateFolder?: () => void;
|
||||||
|
onRenameFolder?: (mailboxId: string) => void;
|
||||||
|
onDeleteFolder?: (mailboxId: string) => void;
|
||||||
|
onRefreshMailboxes?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +198,7 @@ interface SidebarRowProps {
|
|||||||
dropHandlers?: Record<string, unknown>;
|
dropHandlers?: Record<string, unknown>;
|
||||||
isValidDropTarget?: boolean;
|
isValidDropTarget?: boolean;
|
||||||
isInvalidDropTarget?: boolean;
|
isInvalidDropTarget?: boolean;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarRow({
|
function SidebarRow({
|
||||||
@@ -206,6 +218,7 @@ function SidebarRow({
|
|||||||
dropHandlers,
|
dropHandlers,
|
||||||
isValidDropTarget,
|
isValidDropTarget,
|
||||||
isInvalidDropTarget,
|
isInvalidDropTarget,
|
||||||
|
onContextMenu,
|
||||||
}: SidebarRowProps) {
|
}: SidebarRowProps) {
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
||||||
@@ -213,6 +226,7 @@ function SidebarRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...(dropHandlers || {})}
|
{...(dropHandlers || {})}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
|
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
|
||||||
@@ -365,6 +379,7 @@ function MailboxTreeItem({
|
|||||||
isCollapsed,
|
isCollapsed,
|
||||||
onUnreadFilterClick,
|
onUnreadFilterClick,
|
||||||
colorful,
|
colorful,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
node: MailboxNode;
|
node: MailboxNode;
|
||||||
selectedMailbox: string;
|
selectedMailbox: string;
|
||||||
@@ -374,6 +389,7 @@ function MailboxTreeItem({
|
|||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onUnreadFilterClick?: (mailboxId: string) => void;
|
onUnreadFilterClick?: (mailboxId: string) => void;
|
||||||
colorful: boolean;
|
colorful: boolean;
|
||||||
|
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
|
||||||
}) {
|
}) {
|
||||||
const tNotifications = useTranslations('notifications');
|
const tNotifications = useTranslations('notifications');
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = node.children.length > 0;
|
||||||
@@ -423,6 +439,7 @@ function MailboxTreeItem({
|
|||||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
||||||
isValidDropTarget={isValidDropTarget}
|
isValidDropTarget={isValidDropTarget}
|
||||||
isInvalidDropTarget={isInvalidDropTarget}
|
isInvalidDropTarget={isInvalidDropTarget}
|
||||||
|
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
||||||
@@ -436,6 +453,7 @@ function MailboxTreeItem({
|
|||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onUnreadFilterClick={onUnreadFilterClick}
|
onUnreadFilterClick={onUnreadFilterClick}
|
||||||
colorful={colorful}
|
colorful={colorful}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -610,6 +628,15 @@ export function Sidebar({
|
|||||||
onCompose: _onCompose,
|
onCompose: _onCompose,
|
||||||
onSidebarClose,
|
onSidebarClose,
|
||||||
onUnreadFilterClick,
|
onUnreadFilterClick,
|
||||||
|
onMarkFolderRead,
|
||||||
|
onMarkFolderTreeRead,
|
||||||
|
onMarkAllFoldersRead,
|
||||||
|
onEmptyFolder,
|
||||||
|
onCreateSubfolder,
|
||||||
|
onCreateFolder,
|
||||||
|
onRenameFolder,
|
||||||
|
onDeleteFolder,
|
||||||
|
onRefreshMailboxes,
|
||||||
className,
|
className,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -790,6 +817,23 @@ export function Sidebar({
|
|||||||
router.push('/settings');
|
router.push('/settings');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
contextMenu: mailboxContextMenu,
|
||||||
|
openContextMenu: openMailboxContextMenu,
|
||||||
|
closeContextMenu: closeMailboxContextMenu,
|
||||||
|
menuRef: mailboxMenuRef,
|
||||||
|
} = useContextMenu<MailboxContextTarget>();
|
||||||
|
|
||||||
|
const handleMailboxContextMenu = (e: React.MouseEvent, node: MailboxNode) => {
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === node.id);
|
||||||
|
if (!mailbox) return;
|
||||||
|
openMailboxContextMenu(e, { kind: "mailbox", mailbox, hasChildren: node.children.length > 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFoldersHeaderContextMenu = (e: React.MouseEvent) => {
|
||||||
|
openMailboxContextMenu(e, { kind: "folders-section" });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -866,7 +910,7 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div onContextMenu={handleFoldersHeaderContextMenu}>
|
||||||
<SidebarSectionHeader
|
<SidebarSectionHeader
|
||||||
label={t("folders")}
|
label={t("folders")}
|
||||||
expanded={foldersExpanded}
|
expanded={foldersExpanded}
|
||||||
@@ -894,6 +938,7 @@ export function Sidebar({
|
|||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onUnreadFilterClick={onUnreadFilterClick}
|
onUnreadFilterClick={onUnreadFilterClick}
|
||||||
colorful={colorfulSidebarIcons}
|
colorful={colorfulSidebarIcons}
|
||||||
|
onContextMenu={handleMailboxContextMenu}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -934,6 +979,7 @@ export function Sidebar({
|
|||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onUnreadFilterClick={onUnreadFilterClick}
|
onUnreadFilterClick={onUnreadFilterClick}
|
||||||
colorful={colorfulSidebarIcons}
|
colorful={colorfulSidebarIcons}
|
||||||
|
onContextMenu={handleMailboxContextMenu}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -975,6 +1021,23 @@ export function Sidebar({
|
|||||||
|
|
||||||
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MailboxContextMenu
|
||||||
|
target={mailboxContextMenu.data}
|
||||||
|
position={mailboxContextMenu.position}
|
||||||
|
isOpen={mailboxContextMenu.isOpen}
|
||||||
|
onClose={closeMailboxContextMenu}
|
||||||
|
menuRef={mailboxMenuRef}
|
||||||
|
onMarkFolderRead={onMarkFolderRead}
|
||||||
|
onMarkFolderTreeRead={onMarkFolderTreeRead}
|
||||||
|
onMarkAllFoldersRead={onMarkAllFoldersRead}
|
||||||
|
onEmptyFolder={onEmptyFolder}
|
||||||
|
onCreateSubfolder={onCreateSubfolder}
|
||||||
|
onCreateFolder={onCreateFolder}
|
||||||
|
onRenameFolder={onRenameFolder}
|
||||||
|
onDeleteFolder={onDeleteFolder}
|
||||||
|
onRefresh={onRefreshMailboxes}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { useLocaleStore } from '@/stores/locale-store';
|
import { useLocaleStore } from '@/stores/locale-store';
|
||||||
|
import csMessages from '@/locales/cs/common.json';
|
||||||
import enMessages from '@/locales/en/common.json';
|
import enMessages from '@/locales/en/common.json';
|
||||||
import frMessages from '@/locales/fr/common.json';
|
import frMessages from '@/locales/fr/common.json';
|
||||||
import jaMessages from '@/locales/ja/common.json';
|
import jaMessages from '@/locales/ja/common.json';
|
||||||
@@ -15,11 +16,13 @@ import nlMessages from '@/locales/nl/common.json';
|
|||||||
import plMessages from '@/locales/pl/common.json';
|
import plMessages from '@/locales/pl/common.json';
|
||||||
import ptMessages from '@/locales/pt/common.json';
|
import ptMessages from '@/locales/pt/common.json';
|
||||||
import ruMessages from '@/locales/ru/common.json';
|
import ruMessages from '@/locales/ru/common.json';
|
||||||
|
import trMessages from '@/locales/tr/common.json';
|
||||||
import ukMessages from '@/locales/uk/common.json';
|
import ukMessages from '@/locales/uk/common.json';
|
||||||
import zhMessages from '@/locales/zh/common.json';
|
import zhMessages from '@/locales/zh/common.json';
|
||||||
|
|
||||||
// Pre-loaded translations (loaded at build time, not runtime)
|
// Pre-loaded translations (loaded at build time, not runtime)
|
||||||
const ALL_MESSAGES = {
|
const ALL_MESSAGES = {
|
||||||
|
cs: csMessages,
|
||||||
en: enMessages,
|
en: enMessages,
|
||||||
fr: frMessages,
|
fr: frMessages,
|
||||||
ja: jaMessages,
|
ja: jaMessages,
|
||||||
@@ -32,6 +35,7 @@ const ALL_MESSAGES = {
|
|||||||
pl: plMessages,
|
pl: plMessages,
|
||||||
pt: ptMessages,
|
pt: ptMessages,
|
||||||
ru: ruMessages,
|
ru: ruMessages,
|
||||||
|
tr: trMessages,
|
||||||
uk: ukMessages,
|
uk: ukMessages,
|
||||||
zh: zhMessages,
|
zh: zhMessages,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Book, Pencil, Share2, Tag } from "lucide-react";
|
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { AddressBook } from "@/lib/jmap/types";
|
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||||
|
import { ShareCollectionDialog } from "./share-collection-dialog";
|
||||||
|
|
||||||
function AddressBookEditRow({
|
function AddressBookEditRow({
|
||||||
initial,
|
initial,
|
||||||
@@ -70,9 +71,10 @@ export function AddressBookManagementSettings() {
|
|||||||
const tContacts = useTranslations("contacts");
|
const tContacts = useTranslations("contacts");
|
||||||
const tSettings = useTranslations("settings.contacts");
|
const tSettings = useTranslations("settings.contacts");
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
|
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||||
|
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -148,6 +150,16 @@ export function AddressBookManagementSettings() {
|
|||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!book.isShared && book.myRights?.mayShare && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSharingId(book.id)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title={t("share")}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -242,6 +254,24 @@ export function AddressBookManagementSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sharingId && client && (() => {
|
||||||
|
const book = addressBooks.find((b) => b.id === sharingId);
|
||||||
|
if (!book) return null;
|
||||||
|
return (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="addressBook"
|
||||||
|
collectionName={book.name}
|
||||||
|
shareWith={book.shareWith}
|
||||||
|
ownAccountId={client.getAccountId()}
|
||||||
|
onShare={async (principalId, rights) => {
|
||||||
|
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
|
||||||
|
}}
|
||||||
|
onClose={() => setSharingId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
|
|||||||
const tAdvanced = useTranslations('settings.advanced');
|
const tAdvanced = useTranslations('settings.advanced');
|
||||||
const tTour = useTranslations('tour');
|
const tTour = useTranslations('tour');
|
||||||
const { theme, setTheme } = useThemeStore();
|
const { theme, setTheme } = useThemeStore();
|
||||||
const { fontSize, density, animationsEnabled, senderFavicons, updateSetting } = useSettingsStore();
|
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
|
||||||
const { startTour, resetTourCompletion } = useTour();
|
const { startTour, resetTourCompletion } = useTour();
|
||||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||||
|
|
||||||
@@ -130,6 +130,10 @@ export function AppearanceSettings() {
|
|||||||
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
|
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem label={tAdvanced('show_avatars_in_junk.label')} description={tAdvanced('show_avatars_in_junk.description')}>
|
||||||
|
<ToggleSwitch checked={showAvatarsInJunk} onChange={(checked) => updateSetting('showAvatarsInJunk', checked)} />
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
|
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { useAuthStore } from '@/stores/auth-store';
|
|||||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import { SettingsSection } from './settings-section';
|
import { SettingsSection } from './settings-section';
|
||||||
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react';
|
||||||
|
import { ShareCollectionDialog } from './share-collection-dialog';
|
||||||
|
import type { CalendarRights } from '@/lib/jmap/types';
|
||||||
import { cn, formatDateTime } from '@/lib/utils';
|
import { cn, formatDateTime } from '@/lib/utils';
|
||||||
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||||
@@ -83,7 +85,7 @@ function CalendarColorPicker({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarEditForm({
|
export function CalendarEditForm({
|
||||||
initial,
|
initial,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -153,7 +155,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
|
|||||||
export function CalendarManagementSettings() {
|
export function CalendarManagementSettings() {
|
||||||
const t = useTranslations('calendar.management');
|
const t = useTranslations('calendar.management');
|
||||||
const { client, serverUrl, username } = useAuthStore();
|
const { client, serverUrl, username } = useAuthStore();
|
||||||
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||||
|
|
||||||
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
|
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
|
||||||
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
|
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
|
||||||
@@ -164,6 +166,7 @@ export function CalendarManagementSettings() {
|
|||||||
const [clearingId, setClearingId] = useState<string | null>(null);
|
const [clearingId, setClearingId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||||
|
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
|
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
|
||||||
@@ -522,6 +525,16 @@ export function CalendarManagementSettings() {
|
|||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
{cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSharingId(cal.id)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title={t('share')}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setClearingId(cal.id)}
|
onClick={() => setClearingId(cal.id)}
|
||||||
@@ -689,6 +702,24 @@ export function CalendarManagementSettings() {
|
|||||||
onClose={() => setEditingSubscription(null)}
|
onClose={() => setEditingSubscription(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sharingId && client && (() => {
|
||||||
|
const cal = calendars.find((c) => c.id === sharingId);
|
||||||
|
if (!cal) return null;
|
||||||
|
return (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="calendar"
|
||||||
|
collectionName={cal.name}
|
||||||
|
shareWith={cal.shareWith}
|
||||||
|
ownAccountId={client.getAccountId()}
|
||||||
|
onShare={async (principalId, rights) => {
|
||||||
|
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
|
||||||
|
}}
|
||||||
|
onClose={() => setSharingId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
import { Mail, X } from 'lucide-react';
|
import { Mail, X } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
||||||
|
isSupportedSubAddressDelimiter,
|
||||||
|
isValidSubAddressDelimiter,
|
||||||
|
} from '@/lib/sub-addressing';
|
||||||
|
|
||||||
|
const CUSTOM_DELIMITER_SENTINEL = '__custom__';
|
||||||
|
const DEFAULT_CUSTOM_DELIMITER = '~';
|
||||||
|
|
||||||
export function ComposingSettings() {
|
export function ComposingSettings() {
|
||||||
const t = useTranslations('settings.email_behavior');
|
const t = useTranslations('settings.email_behavior');
|
||||||
@@ -17,6 +25,7 @@ export function ComposingSettings() {
|
|||||||
autoSelectReplyIdentity,
|
autoSelectReplyIdentity,
|
||||||
attachmentReminderEnabled,
|
attachmentReminderEnabled,
|
||||||
attachmentReminderKeywords,
|
attachmentReminderKeywords,
|
||||||
|
subAddressDelimiter,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
|
|
||||||
@@ -40,6 +49,49 @@ export function ComposingSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label={t('sub_address_delimiter.label')}
|
||||||
|
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<Select
|
||||||
|
value={isSupportedSubAddressDelimiter(subAddressDelimiter) ? subAddressDelimiter : CUSTOM_DELIMITER_SENTINEL}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value === CUSTOM_DELIMITER_SENTINEL) {
|
||||||
|
if (isSupportedSubAddressDelimiter(subAddressDelimiter)) {
|
||||||
|
updateSetting('subAddressDelimiter', DEFAULT_CUSTOM_DELIMITER);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateSetting('subAddressDelimiter', value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
...SUPPORTED_SUB_ADDRESS_DELIMITERS.map((delim) => ({
|
||||||
|
value: delim,
|
||||||
|
label: t('sub_address_delimiter.option', { delimiter: delim }),
|
||||||
|
})),
|
||||||
|
{ value: CUSTOM_DELIMITER_SENTINEL, label: t('sub_address_delimiter.custom') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{!isSupportedSubAddressDelimiter(subAddressDelimiter) && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={1}
|
||||||
|
value={subAddressDelimiter}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value.slice(0, 1);
|
||||||
|
if (next && isValidSubAddressDelimiter(next)) {
|
||||||
|
updateSetting('subAddressDelimiter', next);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={t('sub_address_delimiter.custom_input_label')}
|
||||||
|
placeholder={DEFAULT_CUSTOM_DELIMITER}
|
||||||
|
className="w-16 px-2 py-1 text-sm font-mono text-center bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
|
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={attachmentReminderEnabled}
|
checked={attachmentReminderEnabled}
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
||||||
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
|
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
|
||||||
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Volume2 } from 'lucide-react';
|
import { CheckCircle2, Volume2, XCircle } from 'lucide-react';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
|
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||||
|
import {
|
||||||
|
DEFAULT_RELAY_BASE_URL,
|
||||||
|
WebPushUnsupportedError,
|
||||||
|
disableWebPush,
|
||||||
|
enableWebPush,
|
||||||
|
isWebPushEnabled,
|
||||||
|
isWebPushSupported,
|
||||||
|
} from '@/lib/web-push';
|
||||||
|
|
||||||
|
type PushStatus =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'busy' }
|
||||||
|
| { kind: 'enabled' }
|
||||||
|
| { kind: 'unsupported' }
|
||||||
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
export function NotificationSettings() {
|
export function NotificationSettings() {
|
||||||
const t = useTranslations('settings.notifications');
|
const t = useTranslations('settings.notifications');
|
||||||
@@ -21,6 +40,77 @@ export function NotificationSettings() {
|
|||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||||
|
const client = useAuthStore((s) => s.client);
|
||||||
|
const username = useAuthStore((s) => s.username);
|
||||||
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
|
|
||||||
|
const supported = typeof window !== 'undefined' && isWebPushSupported();
|
||||||
|
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
|
||||||
|
const [pushStatus, setPushStatus] = useState<PushStatus>(
|
||||||
|
supported ? { kind: 'idle' } : { kind: 'unsupported' },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supported) return;
|
||||||
|
void (async () => {
|
||||||
|
const enabled = await isWebPushEnabled();
|
||||||
|
if (enabled) setPushStatus({ kind: 'enabled' });
|
||||||
|
})();
|
||||||
|
}, [supported]);
|
||||||
|
|
||||||
|
const trimmedRelay = relayUrl.trim().replace(/\/+$/, '');
|
||||||
|
const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay);
|
||||||
|
const busy = pushStatus.kind === 'busy';
|
||||||
|
|
||||||
|
const handleEnablePush = async () => {
|
||||||
|
if (!client) {
|
||||||
|
setPushStatus({ kind: 'error', message: 'Sign in first' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidRelay) {
|
||||||
|
setPushStatus({ kind: 'error', message: 'Enter a valid https:// URL' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPushStatus({ kind: 'busy' });
|
||||||
|
try {
|
||||||
|
await enableWebPush({
|
||||||
|
client,
|
||||||
|
relayBaseUrl: trimmedRelay,
|
||||||
|
accountLabel: username ?? undefined,
|
||||||
|
});
|
||||||
|
setPushStatus({ kind: 'enabled' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof WebPushUnsupportedError) {
|
||||||
|
setPushStatus({ kind: 'unsupported' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPushStatus({
|
||||||
|
kind: 'error',
|
||||||
|
message: err instanceof Error ? err.message : 'Failed to enable push',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisablePush = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: t('push.confirm_disable_title'),
|
||||||
|
message: t('push.confirm_disable_message'),
|
||||||
|
confirmText: t('push.disable'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
setPushStatus({ kind: 'busy' });
|
||||||
|
try {
|
||||||
|
await disableWebPush({ client, relayBaseUrl: trimmedRelay });
|
||||||
|
setPushStatus({ kind: 'idle' });
|
||||||
|
} catch (err) {
|
||||||
|
setPushStatus({
|
||||||
|
kind: 'error',
|
||||||
|
message: err instanceof Error ? err.message : 'Failed to disable push',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
|
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
|
||||||
value: s.id,
|
value: s.id,
|
||||||
@@ -29,6 +119,46 @@ export function NotificationSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
<SettingsSection title={t('push.title')} description={t('push.description')}>
|
||||||
|
<div className="rounded-md border p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<label className="text-sm font-medium" htmlFor="push-relay-url">
|
||||||
|
{t('push.relay_label')}
|
||||||
|
</label>
|
||||||
|
<PushStatusBadge status={pushStatus} t={t} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('push.relay_desc')}</p>
|
||||||
|
<input
|
||||||
|
id="push-relay-url"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={relayUrl}
|
||||||
|
onChange={(e) => setRelayUrl(e.target.value)}
|
||||||
|
placeholder={t('push.relay_placeholder')}
|
||||||
|
disabled={busy || pushStatus.kind === 'unsupported'}
|
||||||
|
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleEnablePush}
|
||||||
|
disabled={busy || pushStatus.kind === 'unsupported' || !isValidRelay || !client}
|
||||||
|
>
|
||||||
|
{pushStatus.kind === 'enabled' ? t('push.reenable') : t('push.enable')}
|
||||||
|
</Button>
|
||||||
|
{pushStatus.kind === 'enabled' && (
|
||||||
|
<Button variant="outline" onClick={handleDisablePush} disabled={busy}>
|
||||||
|
{t('push.disable')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pushStatus.kind === 'unsupported' && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('push.ios_hint')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}>
|
<SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('sound_selection.choose')}
|
label={t('sound_selection.choose')}
|
||||||
@@ -118,6 +248,45 @@ export function NotificationSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PushStatusBadge({
|
||||||
|
status,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
status: PushStatus;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
if (status.kind === 'enabled') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
{t('push.status_active')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.kind === 'busy') {
|
||||||
|
return <span className="text-xs text-muted-foreground">{t('push.status_busy')}</span>;
|
||||||
|
}
|
||||||
|
if (status.kind === 'unsupported') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
{t('push.status_unsupported')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.kind === 'error') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-destructive" title={status.message}>
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
{status.message}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="text-xs text-muted-foreground">{t('push.status_inactive')}</span>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
|
||||||
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
|
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
|
type ShareKind = "calendar" | "addressBook";
|
||||||
|
type AnyRights = CalendarRights | AddressBookRights;
|
||||||
|
|
||||||
|
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
|
||||||
|
|
||||||
|
const CALENDAR_PRESETS: Record<Exclude<RolePreset, "custom">, CalendarRights> = {
|
||||||
|
freeBusy: {
|
||||||
|
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
|
||||||
|
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
|
||||||
|
},
|
||||||
|
read: {
|
||||||
|
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false,
|
||||||
|
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
|
||||||
|
},
|
||||||
|
readWrite: {
|
||||||
|
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||||
|
mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
|
||||||
|
},
|
||||||
|
manager: {
|
||||||
|
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||||
|
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights> = {
|
||||||
|
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
|
||||||
|
readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false },
|
||||||
|
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectCalendarPreset(r: CalendarRights): RolePreset {
|
||||||
|
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
|
||||||
|
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectAddressBookPreset(r: AddressBookRights): RolePreset {
|
||||||
|
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights][]) {
|
||||||
|
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
|
||||||
|
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShareCollectionDialogProps {
|
||||||
|
client: IJMAPClient;
|
||||||
|
kind: ShareKind;
|
||||||
|
collectionName: string;
|
||||||
|
shareWith: Record<string, AnyRights> | null | undefined;
|
||||||
|
ownAccountId: string;
|
||||||
|
onShare: (principalId: string, rights: AnyRights | null) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareCollectionDialog({
|
||||||
|
client,
|
||||||
|
kind,
|
||||||
|
collectionName,
|
||||||
|
shareWith,
|
||||||
|
ownAccountId,
|
||||||
|
onShare,
|
||||||
|
onClose,
|
||||||
|
}: ShareCollectionDialogProps) {
|
||||||
|
const t = useTranslations("sharing");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [principals, setPrincipals] = useState<Principal[]>([]);
|
||||||
|
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
|
// Load principals on mount
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingPrincipals(true);
|
||||||
|
client.getPrincipals().then((list) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
// Exclude the user themselves and any principal that already has a share
|
||||||
|
const existing = new Set(Object.keys(shareWith || {}));
|
||||||
|
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
||||||
|
setPrincipals(filtered);
|
||||||
|
setLoadingPrincipals(false);
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) setLoadingPrincipals(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [client, ownAccountId, shareWith]);
|
||||||
|
|
||||||
|
// Map principal id -> Principal for displayed shares
|
||||||
|
const allPrincipalsById = useMemo(() => {
|
||||||
|
const map = new Map<string, Principal>();
|
||||||
|
for (const p of principals) map.set(p.id, p);
|
||||||
|
return map;
|
||||||
|
}, [principals]);
|
||||||
|
|
||||||
|
// Close on Escape, focus trap, click outside
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleSetRights = async (principalId: string, preset: RolePreset) => {
|
||||||
|
if (preset === "custom") return; // custom is read-only here
|
||||||
|
const rights = kind === "calendar"
|
||||||
|
? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]
|
||||||
|
: ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
|
||||||
|
if (!rights) return;
|
||||||
|
setSavingId(principalId);
|
||||||
|
try {
|
||||||
|
await onShare(principalId, rights);
|
||||||
|
toast.success(t("share_updated"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (principalId: string) => {
|
||||||
|
setSavingId(principalId);
|
||||||
|
try {
|
||||||
|
await onShare(principalId, null);
|
||||||
|
toast.success(t("share_removed"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async (principal: Principal) => {
|
||||||
|
const defaultPreset: RolePreset = "read";
|
||||||
|
const rights = kind === "calendar"
|
||||||
|
? CALENDAR_PRESETS[defaultPreset]
|
||||||
|
: ADDRESS_BOOK_PRESETS[defaultPreset];
|
||||||
|
setSavingId(principal.id);
|
||||||
|
try {
|
||||||
|
await onShare(principal.id, rights);
|
||||||
|
// Move principal out of the "to add" list
|
||||||
|
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
|
||||||
|
setShowAdd(false);
|
||||||
|
setSearch("");
|
||||||
|
toast.success(t("share_added"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredPrincipals = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return principals;
|
||||||
|
return principals.filter((p) =>
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.email?.toLowerCase().includes(q) ||
|
||||||
|
p.description?.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [principals, search]);
|
||||||
|
|
||||||
|
const sharedEntries = useMemo(() => {
|
||||||
|
return Object.entries(shareWith || {}) as [string, AnyRights][];
|
||||||
|
}, [shareWith]);
|
||||||
|
|
||||||
|
const presetOptions = kind === "calendar"
|
||||||
|
? ["freeBusy", "read", "readWrite", "manager"] as const
|
||||||
|
: ["read", "readWrite", "manager"] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("title", { name: collectionName })}
|
||||||
|
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">{t("title", { name: collectionName })}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={tCommon("close")}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||||
|
|
||||||
|
{sharedEntries.length === 0 && !showAdd && (
|
||||||
|
<div className="text-sm text-muted-foreground italic py-4 text-center">
|
||||||
|
{t("no_shares")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sharedEntries.length > 0 && (
|
||||||
|
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
|
||||||
|
{sharedEntries.map(([principalId, rights]) => {
|
||||||
|
const principal = allPrincipalsById.get(principalId);
|
||||||
|
const preset = kind === "calendar"
|
||||||
|
? detectCalendarPreset(rights as CalendarRights)
|
||||||
|
: detectAddressBookPreset(rights as AddressBookRights);
|
||||||
|
return (
|
||||||
|
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{principal?.name || principal?.email || principalId}
|
||||||
|
</div>
|
||||||
|
{principal?.description && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{principal.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={preset}
|
||||||
|
onChange={(e) => handleSetRights(principalId, e.target.value as RolePreset)}
|
||||||
|
disabled={savingId === principalId}
|
||||||
|
className="appearance-none rounded-md border border-input bg-background pl-3 pr-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{presetOptions.map((p) => (
|
||||||
|
<option key={p} value={p}>{t(`preset.${p}`)}</option>
|
||||||
|
))}
|
||||||
|
{preset === "custom" && (
|
||||||
|
<option value="custom">{t("preset.custom")}</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(principalId)}
|
||||||
|
disabled={savingId === principalId}
|
||||||
|
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||||
|
aria-label={t("remove")}
|
||||||
|
title={t("remove")}
|
||||||
|
>
|
||||||
|
{savingId === principalId
|
||||||
|
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
: <Trash2 className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showAdd && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowAdd(true)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
{t("add_person")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<div className="space-y-2 border border-border rounded-md p-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t("search_placeholder")}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="max-h-48 overflow-y-auto -mx-1">
|
||||||
|
{loadingPrincipals && (
|
||||||
|
<div className="flex items-center justify-center py-4 text-muted-foreground">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
{t("loading_principals")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingPrincipals && filteredPrincipals.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-3">
|
||||||
|
{search.trim() ? t("no_match") : t("no_principals")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingPrincipals && filteredPrincipals.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => handleAdd(p)}
|
||||||
|
disabled={savingId === p.id}
|
||||||
|
className="w-full text-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate flex items-center gap-2">
|
||||||
|
{p.name}
|
||||||
|
{p.type === "group" && (
|
||||||
|
<span className="text-[10px] uppercase font-normal text-muted-foreground bg-muted rounded px-1 py-0.5">
|
||||||
|
{t("group")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{p.email && p.email !== p.name && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{p.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{savingId === p.id && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setSearch(""); }}>
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||||
|
<Button onClick={onClose}>{tCommon("close")}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -140,9 +140,11 @@ interface AvatarProps {
|
|||||||
contactPhotoUri?: string;
|
contactPhotoUri?: string;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
|
||||||
|
disableImages?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
|
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) {
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
||||||
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
|
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
|
||||||
@@ -222,9 +224,11 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
|
|||||||
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
|
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
|
||||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||||
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||||
const imgSrc = !imgError && !domainFailed
|
const imgSrc = disableImages
|
||||||
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
? null
|
||||||
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
|
: !imgError && !domainFailed
|
||||||
|
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
||||||
|
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
|
||||||
|
|
||||||
const handleImgError = useCallback(() => {
|
const handleImgError = useCallback(() => {
|
||||||
// If the plugin avatar just failed, mark it and fall through to the next source
|
// If the plugin avatar just failed, mark it and fall through to the next source
|
||||||
|
|||||||
@@ -45,9 +45,22 @@ export function FlagKR(props: FlagProps) {
|
|||||||
return (
|
return (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||||
<rect width="3" height="2" fill="#fff" />
|
<rect width="3" height="2" fill="#fff" />
|
||||||
<circle cx="1.5" cy="1" r="0.55" fill="#CD2E3A" />
|
<path d="M1.5 0.5 a0.45 0.45 0 1 1 0 0.9 a0.45 0.45 0 1 0 0 -0.9" fill="#CD2E3A" />
|
||||||
<path d="M1.5,1 a0.275,0.275 0 0,1 0,0.55 a0.275,0.275 0 0,0 0,-0.55" fill="#0047A0" />
|
<path d="M1.5 1.5 a0.45 0.45 0 1 1 0 -0.9 a0.45 0.45 0 1 0 0 0.9" fill="#0047A0" />
|
||||||
<path d="M1.5,1 a0.275,0.275 0 0,0 0,-0.55 a0.275,0.275 0 0,1 0,0.55" fill="#0047A0" />
|
<circle cx="1.5" cy="0.8" r="0.225" fill="#0047A0" />
|
||||||
|
<circle cx="1.5" cy="1.2" r="0.225" fill="#CD2E3A" />
|
||||||
|
<g stroke="#000" strokeWidth="0.06" strokeLinecap="round">
|
||||||
|
<line x1="0.42" y1="0.35" x2="0.78" y2="0.35" />
|
||||||
|
<line x1="0.42" y1="0.46" x2="0.78" y2="0.46" />
|
||||||
|
<line x1="0.42" y1="0.57" x2="0.78" y2="0.57" />
|
||||||
|
<line x1="2.22" y1="0.35" x2="2.58" y2="0.35" />
|
||||||
|
<line x1="2.22" y1="0.57" x2="2.58" y2="0.57" />
|
||||||
|
<line x1="0.42" y1="1.43" x2="0.78" y2="1.43" />
|
||||||
|
<line x1="0.42" y1="1.65" x2="0.78" y2="1.65" />
|
||||||
|
<line x1="2.22" y1="1.43" x2="2.58" y2="1.43" />
|
||||||
|
<line x1="2.22" y1="1.54" x2="2.58" y2="1.54" />
|
||||||
|
<line x1="2.22" y1="1.65" x2="2.58" y2="1.65" />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -139,6 +152,18 @@ export function FlagRU(props: FlagProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Turkey - Red with white crescent and star */
|
||||||
|
export function FlagTR(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="30" height="20" fill="#E30A17" />
|
||||||
|
<circle cx="10" cy="10" r="6" fill="#fff" />
|
||||||
|
<circle cx="11.5" cy="10" r="5" fill="#E30A17" />
|
||||||
|
<polygon points="19.5,7.8 19.994,9.32 21.592,9.32 20.299,10.26 20.793,11.78 19.5,10.84 18.207,11.78 18.701,10.26 17.408,9.32 19.006,9.32" fill="#fff" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Ukraine – Blue, Yellow horizontal */
|
/** Ukraine – Blue, Yellow horizontal */
|
||||||
export function FlagUA(props: FlagProps) {
|
export function FlagUA(props: FlagProps) {
|
||||||
return (
|
return (
|
||||||
@@ -179,6 +204,7 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
|||||||
pl: FlagPL,
|
pl: FlagPL,
|
||||||
pt: FlagBR,
|
pt: FlagBR,
|
||||||
ru: FlagRU,
|
ru: FlagRU,
|
||||||
|
tr: FlagTR,
|
||||||
uk: FlagUA,
|
uk: FlagUA,
|
||||||
zh: FlagCN,
|
zh: FlagCN,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { flagComponents } from './flag-icons';
|
import { flagComponents } from './flag-icons';
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
|
{ value: 'cs', label: 'Česky' },
|
||||||
{ value: 'en', label: 'English' },
|
{ value: 'en', label: 'English' },
|
||||||
{ value: 'fr', label: 'Français' },
|
{ value: 'fr', label: 'Français' },
|
||||||
{ value: 'ja', label: '日本語' },
|
{ value: 'ja', label: '日本語' },
|
||||||
@@ -20,6 +21,7 @@ const languages = [
|
|||||||
{ value: 'pl', label: 'Polski' },
|
{ value: 'pl', label: 'Polski' },
|
||||||
{ value: 'pt', label: 'Português' },
|
{ value: 'pt', label: 'Português' },
|
||||||
{ value: 'ru', label: 'Русский' },
|
{ value: 'ru', label: 'Русский' },
|
||||||
|
{ value: 'tr', label: 'Türkçe' },
|
||||||
{ value: 'uk', label: 'Українська' },
|
{ value: 'uk', label: 'Українська' },
|
||||||
{ value: 'zh', label: '简体中文' },
|
{ value: 'zh', label: '简体中文' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
interface PromptDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (value: string) => void;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
placeholder,
|
||||||
|
defaultValue = "",
|
||||||
|
confirmText,
|
||||||
|
cancelText,
|
||||||
|
}: PromptDialogProps) {
|
||||||
|
const t = useTranslations("confirm_dialog");
|
||||||
|
const id = useId();
|
||||||
|
const [value, setValue] = useState(defaultValue);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const dialogRef = useFocusTrap({
|
||||||
|
isActive: isOpen,
|
||||||
|
onEscape: onClose,
|
||||||
|
restoreFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setValue(defaultValue);
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [isOpen, defaultValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: MouseEvent) => {
|
||||||
|
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleBackdropClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleBackdropClick);
|
||||||
|
}, [isOpen, onClose, dialogRef]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const resolvedConfirmText = confirmText || t("confirm");
|
||||||
|
const resolvedCancelText = cancelText || t("cancel");
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const canSubmit = trimmed.length > 0;
|
||||||
|
|
||||||
|
const handleSubmit = (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
try {
|
||||||
|
onSubmit(trimmed);
|
||||||
|
} finally {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={`${id}-title`}
|
||||||
|
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="p-6">
|
||||||
|
<h2
|
||||||
|
id={`${id}-title`}
|
||||||
|
className="text-lg font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{message && (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{message}</p>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="mt-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
{resolvedCancelText}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="default" disabled={!canSubmit}>
|
||||||
|
{resolvedConfirmText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,14 @@ services:
|
|||||||
- PORT=3000
|
- PORT=3000
|
||||||
env_file:
|
env_file:
|
||||||
- .env.local
|
- .env.local
|
||||||
|
volumes:
|
||||||
|
# Encrypted user settings (SETTINGS_DATA_DIR).
|
||||||
|
- bulwark-settings:/app/data/settings
|
||||||
|
# Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR).
|
||||||
|
- bulwark-admin:/app/data/admin
|
||||||
|
# Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR).
|
||||||
|
# Persisting this preserves the admin's consent choice and stable instance id across upgrades.
|
||||||
|
- bulwark-telemetry:/app/data/telemetry
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
@@ -23,3 +31,8 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bulwark-settings:
|
||||||
|
bulwark-admin:
|
||||||
|
bulwark-telemetry:
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a memoized function that formats a calendar event date
|
||||||
|
* using the current locale for day and month names.
|
||||||
|
*
|
||||||
|
* The string will be in the format: "EEE, MMM d, yyyy"
|
||||||
|
*
|
||||||
|
* For example: "Wed, Apr 29, 2026" (en)
|
||||||
|
* "Qua, Abr 29, 2026" (pt)
|
||||||
|
*/
|
||||||
|
export function useFormatEventDate(): (date: Date) => string {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(date: Date): string => {
|
||||||
|
const dayOfWeek = format(date, "EEE").toLowerCase();
|
||||||
|
const month = format(date, "MMM").toLowerCase();
|
||||||
|
const day = format(date, "d");
|
||||||
|
const year = format(date, "yyyy");
|
||||||
|
return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`;
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ export interface UseKeyboardShortcutsOptions {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
emails: Email[];
|
emails: Email[];
|
||||||
selectedEmailId?: string;
|
selectedEmailId?: string;
|
||||||
|
selectionCount?: number;
|
||||||
handlers: KeyboardShortcutHandlers;
|
handlers: KeyboardShortcutHandlers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export function useKeyboardShortcuts({
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
emails,
|
emails,
|
||||||
selectedEmailId,
|
selectedEmailId,
|
||||||
|
selectionCount = 0,
|
||||||
handlers,
|
handlers,
|
||||||
}: UseKeyboardShortcutsOptions) {
|
}: UseKeyboardShortcutsOptions) {
|
||||||
const handlersRef = useRef(handlers);
|
const handlersRef = useRef(handlers);
|
||||||
@@ -90,6 +92,8 @@ export function useKeyboardShortcuts({
|
|||||||
// Shortcuts that should NOT work with modifiers
|
// Shortcuts that should NOT work with modifiers
|
||||||
if (hasModifier) return;
|
if (hasModifier) return;
|
||||||
|
|
||||||
|
const hasBatchTarget = !!selectedEmailId || selectionCount > 0;
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
// Navigation
|
// Navigation
|
||||||
case "j":
|
case "j":
|
||||||
@@ -152,7 +156,7 @@ export function useKeyboardShortcuts({
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "e":
|
case "e":
|
||||||
if (selectedEmailId) {
|
if (hasBatchTarget) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
h.onArchive?.();
|
h.onArchive?.();
|
||||||
}
|
}
|
||||||
@@ -161,28 +165,28 @@ export function useKeyboardShortcuts({
|
|||||||
case "#":
|
case "#":
|
||||||
case "delete":
|
case "delete":
|
||||||
case "backspace":
|
case "backspace":
|
||||||
if (selectedEmailId && (key === "#" || key === "delete" || key === "backspace")) {
|
if (hasBatchTarget) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
h.onDelete?.();
|
h.onDelete?.();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "u":
|
case "u":
|
||||||
if (selectedEmailId) {
|
if (hasBatchTarget) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
h.onMarkAsUnread?.();
|
h.onMarkAsUnread?.();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "i":
|
case "i":
|
||||||
if (selectedEmailId && event.shiftKey) {
|
if (hasBatchTarget && event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
h.onMarkAsRead?.();
|
h.onMarkAsRead?.();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "!":
|
case "!":
|
||||||
if (selectedEmailId) {
|
if (hasBatchTarget) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
h.onToggleSpam?.();
|
h.onToggleSpam?.();
|
||||||
}
|
}
|
||||||
@@ -220,7 +224,7 @@ export function useKeyboardShortcuts({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedEmailId]
|
[selectedEmailId, selectionCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ interface UseMailboxDropReturn {
|
|||||||
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
|
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
|
||||||
const [isOver, setIsOver] = useState(false);
|
const [isOver, setIsOver] = useState(false);
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, mailboxes } = useEmailStore();
|
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
|
||||||
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
|
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
|
||||||
|
|
||||||
// Determine if this is a valid drop target
|
// Determine if this is a valid drop target
|
||||||
@@ -115,8 +115,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the current mailbox view
|
// Refresh the current mailbox view (honors active search/filters)
|
||||||
await fetchEmails(client, selectedMailbox);
|
await refreshCurrentMailbox(client);
|
||||||
|
|
||||||
const mailboxPath = getMailboxPath(mailbox, mailboxes);
|
const mailboxPath = getMailboxPath(mailbox, mailboxes);
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
|||||||
} finally {
|
} finally {
|
||||||
endDrag();
|
endDrag();
|
||||||
}
|
}
|
||||||
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
|
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
|
||||||
|
|
||||||
const valid = isValidTarget();
|
const valid = isValidTarget();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
interface PromptDialogState {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
onSubmit: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE: PromptDialogState = {
|
||||||
|
isOpen: false,
|
||||||
|
title: "",
|
||||||
|
defaultValue: "",
|
||||||
|
onSubmit: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PromptOptions {
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePromptDialog() {
|
||||||
|
const [state, setState] = useState<PromptDialogState>(INITIAL_STATE);
|
||||||
|
const resolveRef = useRef<((value: string | null) => void) | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (resolveRef.current) {
|
||||||
|
resolveRef.current(null);
|
||||||
|
resolveRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prompt = useCallback(
|
||||||
|
(options: PromptOptions): Promise<string | null> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolveRef.current = resolve;
|
||||||
|
setState({
|
||||||
|
isOpen: true,
|
||||||
|
title: options.title,
|
||||||
|
message: options.message,
|
||||||
|
placeholder: options.placeholder,
|
||||||
|
defaultValue: options.defaultValue ?? "",
|
||||||
|
confirmText: options.confirmText,
|
||||||
|
cancelText: options.cancelText,
|
||||||
|
onSubmit: (value) => {
|
||||||
|
resolveRef.current = null;
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
if (resolveRef.current) {
|
||||||
|
resolveRef.current(null);
|
||||||
|
resolveRef.current = null;
|
||||||
|
}
|
||||||
|
setState(INITIAL_STATE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dialogProps: {
|
||||||
|
isOpen: state.isOpen,
|
||||||
|
onClose: close,
|
||||||
|
onSubmit: state.onSubmit,
|
||||||
|
title: state.title,
|
||||||
|
message: state.message,
|
||||||
|
placeholder: state.placeholder,
|
||||||
|
defaultValue: state.defaultValue,
|
||||||
|
confirmText: state.confirmText,
|
||||||
|
cancelText: state.cancelText,
|
||||||
|
},
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@ export function useTimeGridInteractions({
|
|||||||
dayDate: Date,
|
dayDate: Date,
|
||||||
) => {
|
) => {
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
|
if (e.pointerType === "touch") return;
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return;
|
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
|||||||
// Use static imports for better compatibility
|
// Use static imports for better compatibility
|
||||||
let messages;
|
let messages;
|
||||||
switch (locale) {
|
switch (locale) {
|
||||||
|
case 'cs':
|
||||||
|
messages = (await import('../locales/cs/common.json')).default;
|
||||||
|
break;
|
||||||
case 'fr':
|
case 'fr':
|
||||||
messages = (await import('../locales/fr/common.json')).default;
|
messages = (await import('../locales/fr/common.json')).default;
|
||||||
break;
|
break;
|
||||||
@@ -44,6 +47,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
|||||||
case 'ru':
|
case 'ru':
|
||||||
messages = (await import('../locales/ru/common.json')).default;
|
messages = (await import('../locales/ru/common.json')).default;
|
||||||
break;
|
break;
|
||||||
|
case 'tr':
|
||||||
|
messages = (await import('../locales/tr/common.json')).default;
|
||||||
|
break;
|
||||||
case 'uk':
|
case 'uk':
|
||||||
messages = (await import('../locales/uk/common.json')).default;
|
messages = (await import('../locales/uk/common.json')).default;
|
||||||
break;
|
break;
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
|||||||
| 'as-needed';
|
| 'as-needed';
|
||||||
|
|
||||||
export const routing = defineRouting({
|
export const routing = defineRouting({
|
||||||
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
|
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
|
||||||
defaultLocale: 'en',
|
defaultLocale: 'en',
|
||||||
localePrefix
|
localePrefix
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ configManager.load()
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
console.info("Admin dashboard initialized");
|
console.info("Admin dashboard initialized");
|
||||||
})
|
})
|
||||||
|
.then(async () => {
|
||||||
|
// Anonymous telemetry - on by default. Admins can disable via the
|
||||||
|
// admin UI, the BULWARK_TELEMETRY env var, or by clearing the endpoint.
|
||||||
|
// See https://bulwarkmail.org/docs/legal/privacy/telemetry
|
||||||
|
const { startScheduler, markProcessStart } = await import("./lib/telemetry");
|
||||||
|
markProcessStart();
|
||||||
|
await startScheduler();
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function makeCalendar(overrides: Partial<Calendar> = {}): Calendar {
|
|||||||
mayWriteOwn: true,
|
mayWriteOwn: true,
|
||||||
mayUpdatePrivate: true,
|
mayUpdatePrivate: true,
|
||||||
mayRSVP: true,
|
mayRSVP: true,
|
||||||
mayAdmin: false,
|
mayShare: false,
|
||||||
mayDelete: false,
|
mayDelete: false,
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
isValidFrameOrigin,
|
||||||
|
sanitizeFrameOrigins,
|
||||||
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
|
|
||||||
|
describe('isValidFrameOrigin', () => {
|
||||||
|
it('accepts plain https origins', () => {
|
||||||
|
expect(isValidFrameOrigin('https://www.youtube-nocookie.com')).toBe(true);
|
||||||
|
expect(isValidFrameOrigin('https://meet.example.com')).toBe(true);
|
||||||
|
expect(isValidFrameOrigin('https://a.b.c.example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a wildcard subdomain', () => {
|
||||||
|
expect(isValidFrameOrigin('https://*.example.com')).toBe(true);
|
||||||
|
expect(isValidFrameOrigin('https://*.youtube.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an explicit port', () => {
|
||||||
|
expect(isValidFrameOrigin('https://meet.example.com:8443')).toBe(true);
|
||||||
|
expect(isValidFrameOrigin('https://*.example.com:443')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-https schemes', () => {
|
||||||
|
expect(isValidFrameOrigin('http://example.com')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('ftp://example.com')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('data:text/html,foo')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('javascript:alert(1)')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bare schemes and wildcard hosts', () => {
|
||||||
|
expect(isValidFrameOrigin('https://')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://*')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://*.com')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://localhost')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects paths, queries, and fragments', () => {
|
||||||
|
expect(isValidFrameOrigin('https://example.com/embed')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://example.com/')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://example.com?x=1')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://example.com#x')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects userinfo, IPs, and IPv6', () => {
|
||||||
|
expect(isValidFrameOrigin('https://user:pass@example.com')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://1.2.3.4')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://[::1]')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects values that try to break out of the directive', () => {
|
||||||
|
expect(isValidFrameOrigin("https://example.com'; script-src 'unsafe-eval")).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://example.com" data:')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://example.com data:')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://example.com\nhttps://evil.com')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://example.com;https://evil.com')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('https://exa,mple.com')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-strings and obvious garbage', () => {
|
||||||
|
expect(isValidFrameOrigin(undefined)).toBe(false);
|
||||||
|
expect(isValidFrameOrigin(null)).toBe(false);
|
||||||
|
expect(isValidFrameOrigin(42)).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('not-a-url')).toBe(false);
|
||||||
|
expect(isValidFrameOrigin('a'.repeat(300))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeFrameOrigins', () => {
|
||||||
|
it('returns empty for non-array input', () => {
|
||||||
|
expect(sanitizeFrameOrigins(undefined)).toEqual([]);
|
||||||
|
expect(sanitizeFrameOrigins(null)).toEqual([]);
|
||||||
|
expect(sanitizeFrameOrigins('https://example.com')).toEqual([]);
|
||||||
|
expect(sanitizeFrameOrigins({})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps valid entries and drops invalid ones silently', () => {
|
||||||
|
expect(
|
||||||
|
sanitizeFrameOrigins([
|
||||||
|
'https://www.youtube-nocookie.com',
|
||||||
|
'http://insecure.com',
|
||||||
|
'https://meet.example.com:8443',
|
||||||
|
'https://example.com/path',
|
||||||
|
42,
|
||||||
|
'https://*.vimeo.com',
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
'https://www.youtube-nocookie.com',
|
||||||
|
'https://meet.example.com:8443',
|
||||||
|
'https://*.vimeo.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes case-insensitively', () => {
|
||||||
|
expect(
|
||||||
|
sanitizeFrameOrigins([
|
||||||
|
'https://Example.com',
|
||||||
|
'https://example.com',
|
||||||
|
'https://EXAMPLE.com',
|
||||||
|
]),
|
||||||
|
).toEqual(['https://Example.com']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
computeReplyThreadingHeaders,
|
||||||
|
stripMessageIdBrackets,
|
||||||
|
} from '../email-threading';
|
||||||
|
|
||||||
|
describe('stripMessageIdBrackets', () => {
|
||||||
|
it('strips surrounding angle brackets', () => {
|
||||||
|
expect(stripMessageIdBrackets('<abc@example.com>')).toBe('abc@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles whitespace and missing brackets', () => {
|
||||||
|
expect(stripMessageIdBrackets(' abc@example.com ')).toBe('abc@example.com');
|
||||||
|
expect(stripMessageIdBrackets('<abc@example.com')).toBe('abc@example.com');
|
||||||
|
expect(stripMessageIdBrackets('abc@example.com>')).toBe('abc@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeReplyThreadingHeaders', () => {
|
||||||
|
it('returns null when the parent has no Message-ID', () => {
|
||||||
|
expect(computeReplyThreadingHeaders(undefined)).toBeNull();
|
||||||
|
expect(computeReplyThreadingHeaders({})).toBeNull();
|
||||||
|
expect(computeReplyThreadingHeaders({ messageId: '' })).toBeNull();
|
||||||
|
expect(computeReplyThreadingHeaders({ messageId: ' ' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets In-Reply-To to the parent Message-ID and seeds References with it', () => {
|
||||||
|
const result = computeReplyThreadingHeaders({
|
||||||
|
messageId: '<root@example.com>',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
inReplyTo: ['root@example.com'],
|
||||||
|
references: ['root@example.com'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends the parent to existing References per RFC 5322', () => {
|
||||||
|
const result = computeReplyThreadingHeaders({
|
||||||
|
messageId: '<msg-2@example.com>',
|
||||||
|
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
inReplyTo: ['msg-2@example.com'],
|
||||||
|
references: ['msg-0@example.com', 'msg-1@example.com', 'msg-2@example.com'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('de-duplicates if the parent already appears in References', () => {
|
||||||
|
const result = computeReplyThreadingHeaders({
|
||||||
|
messageId: '<msg-1@example.com>',
|
||||||
|
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
|
||||||
|
});
|
||||||
|
expect(result?.references).toEqual([
|
||||||
|
'msg-0@example.com',
|
||||||
|
'msg-1@example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts bare Message-IDs without angle brackets', () => {
|
||||||
|
const result = computeReplyThreadingHeaders({
|
||||||
|
messageId: 'msg-2@example.com',
|
||||||
|
references: ['msg-1@example.com'],
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
inReplyTo: ['msg-2@example.com'],
|
||||||
|
references: ['msg-1@example.com', 'msg-2@example.com'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// JMAP RFC 8621 §4.1.2.3 returns messageId as String[]|null. Verify we
|
||||||
|
// don't crash on that shape even though most call sites pass a string.
|
||||||
|
it('accepts an array-shaped messageId per JMAP spec', () => {
|
||||||
|
const result = computeReplyThreadingHeaders({
|
||||||
|
messageId: ['<msg-2@example.com>'],
|
||||||
|
references: ['<msg-1@example.com>'],
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
inReplyTo: ['msg-2@example.com'],
|
||||||
|
references: ['msg-1@example.com', 'msg-2@example.com'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an empty messageId array', () => {
|
||||||
|
expect(computeReplyThreadingHeaders({ messageId: [] })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { JMAPClient } from '../jmap/client';
|
||||||
|
|
||||||
|
function createClient(): JMAPClient {
|
||||||
|
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
||||||
|
Object.assign(client, {
|
||||||
|
apiUrl: 'https://jmap.example.com/api',
|
||||||
|
accountId: 'account-1',
|
||||||
|
username: 'user@example.com',
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JMAPMethodCall {
|
||||||
|
0: string;
|
||||||
|
1: Record<string, unknown>;
|
||||||
|
2: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CapturedRequest {
|
||||||
|
using?: string[];
|
||||||
|
methodCalls: JMAPMethodCall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock fetch to script three sequential JMAP requests sendEmail makes:
|
||||||
|
* Mailbox/get → Identity/get → Email/set + EmailSubmission/set.
|
||||||
|
* Returns the captured request bodies for assertions.
|
||||||
|
*/
|
||||||
|
function mockSendEmailFlow() {
|
||||||
|
const captured: CapturedRequest[] = [];
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||||
|
|
||||||
|
fetchSpy.mockImplementation(async (_url, init) => {
|
||||||
|
const body = JSON.parse((init as { body: string }).body) as CapturedRequest;
|
||||||
|
captured.push(body);
|
||||||
|
const callIdx = captured.length - 1;
|
||||||
|
|
||||||
|
let payload: unknown;
|
||||||
|
if (callIdx === 0) {
|
||||||
|
payload = {
|
||||||
|
methodResponses: [[
|
||||||
|
'Mailbox/get',
|
||||||
|
{
|
||||||
|
list: [
|
||||||
|
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
|
||||||
|
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
]],
|
||||||
|
};
|
||||||
|
} else if (callIdx === 1) {
|
||||||
|
payload = {
|
||||||
|
methodResponses: [[
|
||||||
|
'Identity/get',
|
||||||
|
{ list: [{ id: 'identity-1', email: 'user@example.com', mayDelete: false }] },
|
||||||
|
'0',
|
||||||
|
]],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
payload = {
|
||||||
|
methodResponses: [
|
||||||
|
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'],
|
||||||
|
['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: () => Promise.resolve(JSON.stringify(payload)),
|
||||||
|
json: () => Promise.resolve(payload),
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
return captured;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JMAPClient.sendEmail threading headers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes inReplyTo and references on the Email/set create when supplied', async () => {
|
||||||
|
const client = createClient();
|
||||||
|
const captured = mockSendEmailFlow();
|
||||||
|
|
||||||
|
await client.sendEmail(
|
||||||
|
['recipient@example.com'],
|
||||||
|
'Re: testmail',
|
||||||
|
'reply body',
|
||||||
|
undefined, undefined, 'identity-1', 'user@example.com',
|
||||||
|
undefined, undefined, undefined, undefined,
|
||||||
|
['<parent@example.com>'],
|
||||||
|
['<root@example.com>', '<parent@example.com>'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Third request is the Email/set + EmailSubmission/set batch.
|
||||||
|
const setCall = captured[2].methodCalls[0];
|
||||||
|
expect(setCall[0]).toBe('Email/set');
|
||||||
|
const create = setCall[1].create as Record<string, Record<string, unknown>>;
|
||||||
|
const draft = Object.values(create)[0];
|
||||||
|
|
||||||
|
// Bare msg-ids per RFC 8621 — angle brackets stripped.
|
||||||
|
expect(draft.inReplyTo).toEqual(['parent@example.com']);
|
||||||
|
expect(draft.references).toEqual(['root@example.com', 'parent@example.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits threading fields when no parent ids are supplied', async () => {
|
||||||
|
const client = createClient();
|
||||||
|
const captured = mockSendEmailFlow();
|
||||||
|
|
||||||
|
await client.sendEmail(
|
||||||
|
['recipient@example.com'],
|
||||||
|
'Fresh thread',
|
||||||
|
'body',
|
||||||
|
undefined, undefined, 'identity-1', 'user@example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCall = captured[2].methodCalls[0];
|
||||||
|
const create = setCall[1].create as Record<string, Record<string, unknown>>;
|
||||||
|
const draft = Object.values(create)[0];
|
||||||
|
|
||||||
|
expect(draft.inReplyTo).toBeUndefined();
|
||||||
|
expect(draft.references).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops empty / whitespace-only ids rather than sending blank entries', async () => {
|
||||||
|
const client = createClient();
|
||||||
|
const captured = mockSendEmailFlow();
|
||||||
|
|
||||||
|
await client.sendEmail(
|
||||||
|
['recipient@example.com'],
|
||||||
|
'Re: testmail',
|
||||||
|
'body',
|
||||||
|
undefined, undefined, 'identity-1', 'user@example.com',
|
||||||
|
undefined, undefined, undefined, undefined,
|
||||||
|
['<>', ' ', '<real@example.com>'],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCall = captured[2].methodCalls[0];
|
||||||
|
const create = setCall[1].create as Record<string, Record<string, unknown>>;
|
||||||
|
const draft = Object.values(create)[0];
|
||||||
|
|
||||||
|
expect(draft.inReplyTo).toEqual(['real@example.com']);
|
||||||
|
expect(draft.references).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,6 +47,8 @@ function resetStore() {
|
|||||||
'email-banner': [],
|
'email-banner': [],
|
||||||
'email-footer': [],
|
'email-footer': [],
|
||||||
'composer-toolbar': [],
|
'composer-toolbar': [],
|
||||||
|
'composer-sidebar': [],
|
||||||
|
'composer-sidebar-right': [],
|
||||||
'sidebar-widget': [],
|
'sidebar-widget': [],
|
||||||
'email-detail-sidebar': [],
|
'email-detail-sidebar': [],
|
||||||
'settings-section': [],
|
'settings-section': [],
|
||||||
|
|||||||
@@ -28,23 +28,11 @@ describe('extractTheme', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects oversized theme', async () => {
|
it('rejects oversized theme', async () => {
|
||||||
const zip = new JSZip();
|
// Theme size limit is 2 MB; create a file just past it.
|
||||||
zip.file('manifest.json', JSON.stringify({
|
const oversizedFile = new File([new ArrayBuffer(2 * 1024 * 1024 + 1)], 'big.zip');
|
||||||
id: 'big-theme',
|
|
||||||
name: 'Big',
|
|
||||||
version: '1.0.0',
|
|
||||||
author: 'Test',
|
|
||||||
type: 'theme',
|
|
||||||
variants: ['light'],
|
|
||||||
}));
|
|
||||||
// Make a large file > 1MB
|
|
||||||
zip.file('theme.css', 'x'.repeat(1024 * 1024 + 1));
|
|
||||||
|
|
||||||
// Manually create oversized File
|
|
||||||
const oversizedFile = new File([new ArrayBuffer(1024 * 1024 + 1)], 'big.zip');
|
|
||||||
const result = await extractTheme(oversizedFile);
|
const result = await extractTheme(oversizedFile);
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors).toContain('Theme ZIP exceeds 1 MB size limit');
|
expect(result.errors).toContain('Theme ZIP exceeds 2 MB size limit');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects non-ZIP file', async () => {
|
it('rejects non-ZIP file', async () => {
|
||||||
@@ -139,6 +127,129 @@ describe('extractTheme', () => {
|
|||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
expect(result.manifest!.id).toBe('nested-theme');
|
expect(result.manifest!.id).toBe('nested-theme');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Theme API v2 (advanced manifest) ──────────────────────────────
|
||||||
|
|
||||||
|
it('compiles a v2 manifest with tokens and no theme.css', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
id: 'tokens-only',
|
||||||
|
name: 'Tokens Only',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Test',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light', 'dark'],
|
||||||
|
apiVersion: 2,
|
||||||
|
tokens: {
|
||||||
|
light: { primary: '#1373d9', background: '#ffffff' },
|
||||||
|
dark: { primary: '#58c9ff', background: '#1a202c' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const file = await createZipFile(zip);
|
||||||
|
const result = await extractTheme(file);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.css).toContain('--color-primary: #1373d9');
|
||||||
|
expect(result.css).toContain('--color-primary: #58c9ff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('concatenates compiled tokens with author-supplied theme.css', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
id: 'tokens-plus-css',
|
||||||
|
name: 'Tokens + CSS',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Test',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light'],
|
||||||
|
apiVersion: 2,
|
||||||
|
tokens: { light: { primary: '#000' } },
|
||||||
|
}));
|
||||||
|
zip.file('theme.css', '@font-face { font-family: "X"; src: local("X"); }');
|
||||||
|
const file = await createZipFile(zip);
|
||||||
|
const result = await extractTheme(file);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.css).toContain('--color-primary: #000');
|
||||||
|
expect(result.css).toContain('@font-face');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts a skin.css when shipped with a v2 manifest', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
id: 'with-skin',
|
||||||
|
name: 'With Skin',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Test',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light'],
|
||||||
|
apiVersion: 2,
|
||||||
|
tokens: { light: { primary: '#000' } },
|
||||||
|
}));
|
||||||
|
zip.file('skin.css', '[data-tour="email-list"] { font-size: 13px; }');
|
||||||
|
const file = await createZipFile(zip);
|
||||||
|
const result = await extractTheme(file);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.skin).not.toBeNull();
|
||||||
|
expect(result.skin!).toContain('[data-tour="email-list"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips dangerous patterns from skin.css', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
id: 'evil-skin',
|
||||||
|
name: 'Evil',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Test',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light'],
|
||||||
|
apiVersion: 2,
|
||||||
|
tokens: { light: { primary: '#000' } },
|
||||||
|
}));
|
||||||
|
zip.file('skin.css', '@import url("https://x.com/p.css"); button { background: javascript:alert(1); }');
|
||||||
|
const file = await createZipFile(zip);
|
||||||
|
const result = await extractTheme(file);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.skin).not.toBeNull();
|
||||||
|
expect(result.skin!).not.toContain('javascript:');
|
||||||
|
expect(result.skin!).not.toContain('@import');
|
||||||
|
expect(result.warnings.some((w) => w.toLowerCase().includes('skin'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores skin.css when manifest is not v2', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
id: 'v1-with-skin',
|
||||||
|
name: 'V1',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Test',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light'],
|
||||||
|
}));
|
||||||
|
zip.file('theme.css', ':root { --color-primary: #000; }');
|
||||||
|
zip.file('skin.css', 'body { display: none; }');
|
||||||
|
const file = await createZipFile(zip);
|
||||||
|
const result = await extractTheme(file);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.skin).toBeNull();
|
||||||
|
expect(result.warnings.some((w) => w.includes('skin.css ignored'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a v2 manifest with invalid density', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('manifest.json', JSON.stringify({
|
||||||
|
id: 'bad-density',
|
||||||
|
name: 'Bad',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'Test',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light'],
|
||||||
|
density: 'gigantic',
|
||||||
|
tokens: { light: { primary: '#000' } },
|
||||||
|
}));
|
||||||
|
const file = await createZipFile(zip);
|
||||||
|
const result = await extractTheme(file);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some((e) => e.includes('density'))).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extractPlugin', () => {
|
describe('extractPlugin', () => {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
suggestTagsForDomain,
|
suggestTagsForDomain,
|
||||||
isValidTag,
|
isValidTag,
|
||||||
getTagValidationError,
|
getTagValidationError,
|
||||||
|
isSupportedSubAddressDelimiter,
|
||||||
|
isValidSubAddressDelimiter,
|
||||||
|
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
||||||
|
DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||||
MAX_TAG_LENGTH,
|
MAX_TAG_LENGTH,
|
||||||
} from '../sub-addressing';
|
} from '../sub-addressing';
|
||||||
|
|
||||||
@@ -354,3 +358,122 @@ describe('getTagValidationError', () => {
|
|||||||
expect(getTagValidationError('日本語')).toBe('INVALID_CHARS');
|
expect(getTagValidationError('日本語')).toBe('INVALID_CHARS');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('custom delimiter', () => {
|
||||||
|
describe('parseSubAddress with non-default delimiter', () => {
|
||||||
|
it('should parse with "-" delimiter', () => {
|
||||||
|
const result = parseSubAddress('user-shopping@example.com', '-');
|
||||||
|
expect(result.baseUser).toBe('user');
|
||||||
|
expect(result.tag).toBe('shopping');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse with "." delimiter', () => {
|
||||||
|
const result = parseSubAddress('user.shopping@example.com', '.');
|
||||||
|
expect(result.baseUser).toBe('user');
|
||||||
|
expect(result.tag).toBe('shopping');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse with "=" delimiter', () => {
|
||||||
|
const result = parseSubAddress('user=shopping@example.com', '=');
|
||||||
|
expect(result.baseUser).toBe('user');
|
||||||
|
expect(result.tag).toBe('shopping');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore "+" when "-" is configured as the delimiter', () => {
|
||||||
|
const result = parseSubAddress('user+shopping@example.com', '-');
|
||||||
|
expect(result.baseUser).toBe('user+shopping');
|
||||||
|
expect(result.tag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split on first occurrence when delimiter appears multiple times', () => {
|
||||||
|
const result = parseSubAddress('alice-shop-orders@example.com', '-');
|
||||||
|
expect(result.baseUser).toBe('alice');
|
||||||
|
expect(result.tag).toBe('shop-orders');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateSubAddress with non-default delimiter', () => {
|
||||||
|
it('should generate using "-" delimiter', () => {
|
||||||
|
expect(generateSubAddress('user@example.com', 'shopping', '-')).toBe('user-shopping@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate using "." delimiter', () => {
|
||||||
|
expect(generateSubAddress('user@example.com', 'shopping', '.')).toBe('user.shopping@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace existing tag using the configured delimiter', () => {
|
||||||
|
expect(generateSubAddress('user-old@example.com', 'new', '-')).toBe('user-new@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not strip a "+" sign in the local part when delimiter is "-"', () => {
|
||||||
|
// "+" is not the delimiter so it should remain part of the base user
|
||||||
|
expect(generateSubAddress('user+plus@example.com', 'tag', '-')).toBe('user+plus-tag@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSupportedSubAddressDelimiter', () => {
|
||||||
|
it('accepts every supported delimiter', () => {
|
||||||
|
for (const delim of SUPPORTED_SUB_ADDRESS_DELIMITERS) {
|
||||||
|
expect(isSupportedSubAddressDelimiter(delim)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported characters', () => {
|
||||||
|
expect(isSupportedSubAddressDelimiter('_')).toBe(false);
|
||||||
|
expect(isSupportedSubAddressDelimiter('++')).toBe(false);
|
||||||
|
expect(isSupportedSubAddressDelimiter('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('default delimiter is supported', () => {
|
||||||
|
expect(isSupportedSubAddressDelimiter(DEFAULT_SUB_ADDRESS_DELIMITER)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidSubAddressDelimiter', () => {
|
||||||
|
it('accepts every preset delimiter', () => {
|
||||||
|
for (const delim of SUPPORTED_SUB_ADDRESS_DELIMITERS) {
|
||||||
|
expect(isValidSubAddressDelimiter(delim)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts atext special characters as custom delimiters', () => {
|
||||||
|
const customs = ['~', '!', '#', '$', '%', '&', "'", '*', '/', '?', '^', '_', '`', '{', '|', '}'];
|
||||||
|
for (const c of customs) {
|
||||||
|
expect(isValidSubAddressDelimiter(c)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects alphanumeric characters', () => {
|
||||||
|
expect(isValidSubAddressDelimiter('a')).toBe(false);
|
||||||
|
expect(isValidSubAddressDelimiter('Z')).toBe(false);
|
||||||
|
expect(isValidSubAddressDelimiter('0')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects "@", whitespace, and quotes', () => {
|
||||||
|
expect(isValidSubAddressDelimiter('@')).toBe(false);
|
||||||
|
expect(isValidSubAddressDelimiter(' ')).toBe(false);
|
||||||
|
expect(isValidSubAddressDelimiter('\t')).toBe(false);
|
||||||
|
expect(isValidSubAddressDelimiter('"')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects multi-character strings', () => {
|
||||||
|
expect(isValidSubAddressDelimiter('++')).toBe(false);
|
||||||
|
expect(isValidSubAddressDelimiter('abc')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty / non-string inputs', () => {
|
||||||
|
expect(isValidSubAddressDelimiter('')).toBe(false);
|
||||||
|
expect(isValidSubAddressDelimiter(null)).toBe(false);
|
||||||
|
expect(isValidSubAddressDelimiter(undefined)).toBe(false);
|
||||||
|
expect(isValidSubAddressDelimiter(1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through parse/generate with a custom "~" delimiter', () => {
|
||||||
|
const generated = generateSubAddress('user@example.com', 'shopping', '~');
|
||||||
|
expect(generated).toBe('user~shopping@example.com');
|
||||||
|
const parsed = parseSubAddress(generated, '~');
|
||||||
|
expect(parsed.baseUser).toBe('user');
|
||||||
|
expect(parsed.tag).toBe('shopping');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { compileAdvancedTheme, isAdvancedManifest } from '../theme-compiler';
|
||||||
|
import type { ThemeManifest } from '../plugin-types';
|
||||||
|
|
||||||
|
const baseManifest = (overrides: Partial<ThemeManifest> = {}): ThemeManifest => ({
|
||||||
|
id: 't',
|
||||||
|
name: 'T',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'tester',
|
||||||
|
description: '',
|
||||||
|
type: 'theme',
|
||||||
|
variants: ['light', 'dark'],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAdvancedManifest', () => {
|
||||||
|
it('returns false for plain v1 manifests', () => {
|
||||||
|
expect(isAdvancedManifest(baseManifest())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ apiVersion: 2 as const },
|
||||||
|
{ tokens: { light: { primary: '#000' } } },
|
||||||
|
{ extends: 'builtin-nord' },
|
||||||
|
{ derive: true },
|
||||||
|
{ density: 'compact' as const },
|
||||||
|
{ radii: { md: '6px' } },
|
||||||
|
{ typography: { fontSans: 'Inter' } },
|
||||||
|
])('returns true when manifest has %p', (extra) => {
|
||||||
|
expect(isAdvancedManifest(baseManifest(extra))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compileAdvancedTheme', () => {
|
||||||
|
it('emits :root and .dark blocks from token sets', () => {
|
||||||
|
const { css, errors } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
tokens: {
|
||||||
|
light: { primary: '#1373d9', background: '#ffffff' },
|
||||||
|
dark: { primary: '#58c9ff', background: '#1a202c' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(css).toMatch(/:root\s*\{[\s\S]*--color-primary:\s*#1373d9/);
|
||||||
|
expect(css).toMatch(/\.dark\s*\{[\s\S]*--color-primary:\s*#58c9ff/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits .dark block for light-only themes', () => {
|
||||||
|
const { css } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
variants: ['light'],
|
||||||
|
tokens: { light: { primary: '#000' }, dark: { primary: '#fff' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(css).toContain(':root');
|
||||||
|
expect(css).not.toContain('.dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits common tokens into both :root and .dark', () => {
|
||||||
|
const { css } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
tokens: {
|
||||||
|
common: { ring: '#abc' },
|
||||||
|
light: { background: '#fff' },
|
||||||
|
dark: { background: '#000' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const rootMatch = css.match(/:root\s*\{([\s\S]*?)\}/)?.[1] ?? '';
|
||||||
|
const darkMatch = css.match(/\.dark\s*\{([\s\S]*?)\}/)?.[1] ?? '';
|
||||||
|
expect(rootMatch).toContain('--color-ring: #abc');
|
||||||
|
expect(darkMatch).toContain('--color-ring: #abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives a contrasting *-foreground when derive: true', () => {
|
||||||
|
const { css } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
derive: true,
|
||||||
|
tokens: { light: { primary: '#000000' }, dark: { primary: '#ffffff' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(css).toMatch(/:root\s*\{[\s\S]*--color-primary-foreground:\s*#ffffff/);
|
||||||
|
expect(css).toMatch(/\.dark\s*\{[\s\S]*--color-primary-foreground:\s*#0f172a/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects an author-provided *-foreground over derive', () => {
|
||||||
|
const { css } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
derive: true,
|
||||||
|
tokens: {
|
||||||
|
light: { primary: '#000000', 'primary-foreground': '#ff00ff' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(css).toContain('--color-primary-foreground: #ff00ff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits radii, typography, and density vars', () => {
|
||||||
|
const { css } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
tokens: { light: { primary: '#000' } },
|
||||||
|
radii: { sm: '2px', md: '6px', full: '9999px' },
|
||||||
|
typography: { fontSans: 'Inter, sans-serif', baseFontSize: '15px' },
|
||||||
|
density: 'compact',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(css).toContain('--radius-sm: 2px');
|
||||||
|
expect(css).toContain('--radius-full: 9999px');
|
||||||
|
expect(css).toContain('--font-sans: Inter, sans-serif');
|
||||||
|
expect(css).toContain('--font-size-base: 15px');
|
||||||
|
expect(css).toContain('--density-row-height: 28px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops tokens with unsafe values and warns', () => {
|
||||||
|
const { css, warnings } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
tokens: {
|
||||||
|
light: {
|
||||||
|
primary: '#000',
|
||||||
|
evil: 'red; background: url("https://x.com/track.png")',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(css).toContain('--color-primary: #000');
|
||||||
|
expect(css).not.toContain('https://x.com');
|
||||||
|
expect(warnings.some((w) => w.includes('evil'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops tokens with unsafe keys and warns', () => {
|
||||||
|
const { css, warnings } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
tokens: { light: { 'primary }; body { background: red': '#fff', primary: '#000' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(css).toContain('--color-primary: #000');
|
||||||
|
expect(css).not.toContain('body { background');
|
||||||
|
expect(warnings.some((w) => w.includes('invalid key'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when no structured fields are present', () => {
|
||||||
|
const { errors } = compileAdvancedTheme(baseManifest());
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inlines parent CSS when extends + resolver supplied', () => {
|
||||||
|
const { css, warnings } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
extends: 'parent-theme',
|
||||||
|
tokens: { light: { primary: '#fff' } },
|
||||||
|
}),
|
||||||
|
{ resolveExtends: (id) => (id === 'parent-theme' ? ':root { --x: 1; }' : null) },
|
||||||
|
);
|
||||||
|
expect(css).toContain('--x: 1');
|
||||||
|
expect(css).toContain('--color-primary: #fff');
|
||||||
|
expect(warnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when extends parent cannot be resolved', () => {
|
||||||
|
const { warnings } = compileAdvancedTheme(
|
||||||
|
baseManifest({
|
||||||
|
extends: 'missing',
|
||||||
|
tokens: { light: { primary: '#fff' } },
|
||||||
|
}),
|
||||||
|
{ resolveExtends: () => null },
|
||||||
|
);
|
||||||
|
expect(warnings.some((w) => w.includes('missing'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends user-supplied CSS after compiled output', () => {
|
||||||
|
const { css } = compileAdvancedTheme(
|
||||||
|
baseManifest({ tokens: { light: { primary: '#fff' } } }),
|
||||||
|
{ userCSS: '@font-face { font-family: "X"; src: local("X"); }' },
|
||||||
|
);
|
||||||
|
const compiledIdx = css.indexOf('--color-primary');
|
||||||
|
const userIdx = css.indexOf('@font-face');
|
||||||
|
expect(compiledIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(userIdx).toBeGreaterThan(compiledIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import {
|
import {
|
||||||
sanitizeThemeCSS,
|
sanitizeThemeCSS,
|
||||||
|
sanitizeSkinCSS,
|
||||||
validateThemeSelectors,
|
validateThemeSelectors,
|
||||||
injectThemeCSS,
|
injectThemeCSS,
|
||||||
removeThemeCSS,
|
removeThemeCSS,
|
||||||
|
injectThemeSkinCSS,
|
||||||
|
removeThemeSkinCSS,
|
||||||
validateThemeCSSSafety,
|
validateThemeCSSSafety,
|
||||||
} from '../theme-loader';
|
} from '../theme-loader';
|
||||||
|
|
||||||
@@ -144,6 +147,64 @@ describe('theme-loader', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('injectThemeSkinCSS / removeThemeSkinCSS', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
removeThemeSkinCSS();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects a separate <style> tag from the colour block', () => {
|
||||||
|
injectThemeCSS(':root { --color-primary: red; }');
|
||||||
|
injectThemeSkinCSS('button { padding: 4px; }', 'thunderbird');
|
||||||
|
expect(document.getElementById('active-theme')).not.toBeNull();
|
||||||
|
expect(document.getElementById('active-theme-skin')).not.toBeNull();
|
||||||
|
expect(document.getElementById('active-theme-skin')?.textContent).toContain('button');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets data-theme-skin on body to the active theme id', () => {
|
||||||
|
injectThemeSkinCSS('button { padding: 4px; }', 'my-theme');
|
||||||
|
expect(document.body.getAttribute('data-theme-skin')).toBe('my-theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the skin tag and body attribute on remove', () => {
|
||||||
|
injectThemeSkinCSS('button { padding: 4px; }', 'my-theme');
|
||||||
|
removeThemeSkinCSS();
|
||||||
|
expect(document.getElementById('active-theme-skin')).toBeNull();
|
||||||
|
expect(document.body.getAttribute('data-theme-skin')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when removing without a prior inject', () => {
|
||||||
|
expect(() => removeThemeSkinCSS()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeSkinCSS', () => {
|
||||||
|
it('preserves component-level selectors', () => {
|
||||||
|
const css = '[data-tour="email-list"] { font-size: 13px; } button { padding: 4px; }';
|
||||||
|
const { css: cleaned, warnings } = sanitizeSkinCSS(css);
|
||||||
|
expect(cleaned).toBe(css);
|
||||||
|
expect(warnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips dangerous patterns', () => {
|
||||||
|
const { css: cleaned, warnings } = sanitizeSkinCSS(
|
||||||
|
'@import url("https://x.com/p.css"); button { background: javascript:alert(1); }',
|
||||||
|
);
|
||||||
|
expect(cleaned).not.toContain('@import');
|
||||||
|
expect(cleaned).not.toContain('javascript:');
|
||||||
|
expect(warnings.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips @charset and @namespace', () => {
|
||||||
|
const { css: cleaned, warnings } = sanitizeSkinCSS(
|
||||||
|
'@charset "utf-8"; @namespace url(http://www.w3.org/1999/xhtml); button { padding: 4px; }',
|
||||||
|
);
|
||||||
|
expect(cleaned).not.toContain('@charset');
|
||||||
|
expect(cleaned).not.toContain('@namespace');
|
||||||
|
expect(cleaned).toContain('button');
|
||||||
|
expect(warnings.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('validateThemeCSSSafety', () => {
|
describe('validateThemeCSSSafety', () => {
|
||||||
it('accepts valid theme CSS', () => {
|
it('accepts valid theme CSS', () => {
|
||||||
const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }';
|
const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }';
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const lookup = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('node:dns/promises', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:dns/promises')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
|
||||||
|
lookup: (...args: unknown[]) => lookup(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPublicHttpUrl', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
lookup.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const mod = await import('@/lib/security/url-guard');
|
||||||
|
return mod.isPublicHttpUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts public https URLs whose DNS resolves to a public address', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('https://example.com/jmap')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed URLs', async () => {
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('not a url')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-http(s) protocols', async () => {
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('file:///etc/passwd')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('gopher://example.com/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('javascript:alert(1)')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects URLs with embedded credentials', async () => {
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('https://user:pass@example.com/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('https://user@example.com/')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects loopback hostnames without DNS', async () => {
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('http://localhost/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://service.localhost/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://server.local/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://kube.internal/api')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://1.0.0.127.in-addr.arpa/')).toBe(false);
|
||||||
|
expect(lookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects literal IPv4 loopback and RFC-1918 ranges', async () => {
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('http://127.0.0.1/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://10.0.0.5/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://10.255.255.255/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://172.16.0.1/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://172.31.255.254/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://192.168.1.1/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://0.0.0.0/')).toBe(false);
|
||||||
|
expect(lookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects literal AWS / GCP / Azure metadata IP', async () => {
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('http://169.254.169.254/latest/meta-data/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://169.254.0.1/')).toBe(false);
|
||||||
|
expect(lookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects IPv6 loopback, ULA, and link-local literals', async () => {
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('http://[::1]/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://[::]/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://[fc00::1]/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://[fd12:3456::1]/')).toBe(false);
|
||||||
|
expect(await isPublicHttpUrl('http://[fe80::1]/')).toBe(false);
|
||||||
|
expect(lookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when DNS resolves to a private address (rebinding)', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('https://evil.example.com/')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when any resolved address is private (mixed)', async () => {
|
||||||
|
lookup.mockResolvedValue([
|
||||||
|
{ address: '93.184.216.34', family: 4 },
|
||||||
|
{ address: '10.0.0.1', family: 4 },
|
||||||
|
]);
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('https://mixed.example.com/')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when DNS resolves to IPv6 loopback', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '::1', family: 6 }]);
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('https://evil6.example.com/')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when DNS lookup throws', async () => {
|
||||||
|
lookup.mockRejectedValue(new Error('ENOTFOUND'));
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('https://nonexistent.example.com/')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when DNS returns no records', async () => {
|
||||||
|
lookup.mockResolvedValue([]);
|
||||||
|
const isPublicHttpUrl = await load();
|
||||||
|
expect(await isPublicHttpUrl('https://empty.example.com/')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,11 +34,11 @@ describe("parseVCard", () => {
|
|||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
const components = result[0].name?.components || [];
|
const components = result[0].name?.components || [];
|
||||||
expect(components).toEqual([
|
expect(components).toEqual([
|
||||||
{ kind: "prefix", value: "Mr." },
|
{ kind: "title", value: "Mr." },
|
||||||
{ kind: "given", value: "John" },
|
{ kind: "given", value: "John" },
|
||||||
{ kind: "additional", value: "Michael" },
|
{ kind: "given2", value: "Michael" },
|
||||||
{ kind: "surname", value: "Doe" },
|
{ kind: "surname", value: "Doe" },
|
||||||
{ kind: "suffix", value: "Jr." },
|
{ kind: "generation", value: "Jr." },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,6 +52,21 @@ describe("parseVCard", () => {
|
|||||||
expect(components.find((c) => c.kind === "surname")?.value).toBe("Doe");
|
expect(components.find((c) => c.kind === "surname")?.value).toBe("Doe");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps prefix and middle name to RFC 9553 standard kinds (issue #224)", () => {
|
||||||
|
// N: family;given;additional;prefix;suffix (RFC 6350 order)
|
||||||
|
const withPrefix = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;;Mr.;\r\nEMAIL:j@example.com\r\nEND:VCARD`);
|
||||||
|
const c1 = withPrefix[0].name?.components || [];
|
||||||
|
expect(c1.find((c) => c.kind === "surname")?.value).toBe("Smith");
|
||||||
|
expect(c1.find((c) => c.kind === "given")?.value).toBe("John");
|
||||||
|
expect(c1.find((c) => c.kind === "title")?.value).toBe("Mr.");
|
||||||
|
|
||||||
|
const withMiddle = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;Mike;;\r\nEMAIL:j@example.com\r\nEND:VCARD`);
|
||||||
|
const c2 = withMiddle[0].name?.components || [];
|
||||||
|
expect(c2.find((c) => c.kind === "surname")?.value).toBe("Smith");
|
||||||
|
expect(c2.find((c) => c.kind === "given")?.value).toBe("John");
|
||||||
|
expect(c2.find((c) => c.kind === "given2")?.value).toBe("Mike");
|
||||||
|
});
|
||||||
|
|
||||||
it("parses vCard with phone, org, and address", () => {
|
it("parses vCard with phone, org, and address", () => {
|
||||||
const vcf = [
|
const vcf = [
|
||||||
"BEGIN:VCARD",
|
"BEGIN:VCARD",
|
||||||
@@ -204,6 +219,60 @@ describe("parseVCard", () => {
|
|||||||
expect(result[0].kind).toBe("group");
|
expect(result[0].kind).toBe("group");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("decodes ENCODING=QUOTED-PRINTABLE values with UTF-8 charset", () => {
|
||||||
|
const vcf = [
|
||||||
|
"BEGIN:VCARD",
|
||||||
|
"VERSION:2.1",
|
||||||
|
"N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;Hans;;;",
|
||||||
|
"FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans M=C3=BCller",
|
||||||
|
"NOTE;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Caf=C3=A9 stra=C3=9Fe",
|
||||||
|
"EMAIL:hans@example.com",
|
||||||
|
"END:VCARD",
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
const result = parseVCard(vcf);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
const card = result[0];
|
||||||
|
|
||||||
|
const components = card.name?.components || [];
|
||||||
|
expect(components.find((c) => c.kind === "given")?.value).toBe("Hans");
|
||||||
|
expect(components.find((c) => c.kind === "surname")?.value).toBe("Müller");
|
||||||
|
expect(card.notes?.n0?.note).toBe("Café straße");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins QUOTED-PRINTABLE soft line breaks (= at end of line)", () => {
|
||||||
|
const vcf = [
|
||||||
|
"BEGIN:VCARD",
|
||||||
|
"VERSION:2.1",
|
||||||
|
"FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans=20J=",
|
||||||
|
"=C3=BCrgen=20M=C3=BCller",
|
||||||
|
"EMAIL:hj@example.com",
|
||||||
|
"END:VCARD",
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
const result = parseVCard(vcf);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
const components = result[0].name?.components || [];
|
||||||
|
const given = components.find((c) => c.kind === "given")?.value;
|
||||||
|
const surname = components.find((c) => c.kind === "surname")?.value;
|
||||||
|
expect(given).toBe("Hans");
|
||||||
|
expect(surname).toBe("Jürgen Müller");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes bare QUOTED-PRINTABLE encoding parameter (vCard 2.1 style)", () => {
|
||||||
|
const vcf = [
|
||||||
|
"BEGIN:VCARD",
|
||||||
|
"VERSION:2.1",
|
||||||
|
"FN;QUOTED-PRINTABLE;CHARSET=UTF-8:Caf=C3=A9",
|
||||||
|
"EMAIL:c@example.com",
|
||||||
|
"END:VCARD",
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
const result = parseVCard(vcf);
|
||||||
|
const components = result[0].name?.components || [];
|
||||||
|
expect(components.find((c) => c.kind === "given")?.value).toBe("Café");
|
||||||
|
});
|
||||||
|
|
||||||
it("parses GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, SOURCE", () => {
|
it("parses GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, SOURCE", () => {
|
||||||
const vcf = [
|
const vcf = [
|
||||||
"BEGIN:VCARD",
|
"BEGIN:VCARD",
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const lookup = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('node:dns/promises', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:dns/promises')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
|
||||||
|
lookup: (...args: unknown[]) => lookup(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyJmapAuth SSRF protection', () => {
|
||||||
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
lookup.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const mod = await import('@/lib/auth/verify-jmap-auth');
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects loopback literal without issuing fetch', async () => {
|
||||||
|
const { verifyJmapAuth, JmapAuthVerificationError } = await load();
|
||||||
|
await expect(verifyJmapAuth('http://127.0.0.1', 'Bearer x')).rejects.toBeInstanceOf(
|
||||||
|
JmapAuthVerificationError,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects AWS IMDS endpoint without issuing fetch', async () => {
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
await expect(
|
||||||
|
verifyJmapAuth('http://169.254.169.254', 'Bearer x'),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects RFC-1918 literals without issuing fetch', async () => {
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
for (const target of ['http://10.0.0.5', 'http://172.16.0.1', 'http://192.168.1.1']) {
|
||||||
|
await expect(verifyJmapAuth(target, 'Bearer x')).rejects.toMatchObject({ status: 400 });
|
||||||
|
}
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects localhost hostname without issuing fetch', async () => {
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
await expect(verifyJmapAuth('http://localhost', 'Bearer x')).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects IPv6 loopback literal without issuing fetch', async () => {
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
await expect(verifyJmapAuth('http://[::1]', 'Bearer x')).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects hostnames whose DNS resolves to a private IP without issuing fetch', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
await expect(verifyJmapAuth('https://internal.example.com', 'Bearer x')).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects file:// URLs', async () => {
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
await expect(verifyJmapAuth('file:///etc/passwd', 'Bearer x')).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to follow a redirect to a private address', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/.well-known/jmap' } }),
|
||||||
|
);
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to follow a redirect to AWS IMDS', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a public host that returns a valid JMAP session', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ apiUrl: 'https://example.com/api', accounts: {} }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).resolves.toBe(
|
||||||
|
'https://example.com',
|
||||||
|
);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith(
|
||||||
|
'https://example.com/.well-known/jmap',
|
||||||
|
expect.objectContaining({ redirect: 'manual' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid Authorization header before any fetch', async () => {
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
await expect(verifyJmapAuth('https://example.com', 'NotAuth')).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with trusted=true, accepts a hostname resolving to a private IP', async () => {
|
||||||
|
lookup.mockResolvedValue([{ address: '10.0.20.5', family: 4 }]);
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ apiUrl: 'https://mail.internal/api', accounts: {} }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
await expect(
|
||||||
|
verifyJmapAuth('https://mail.internal', 'Bearer x', { trusted: true }),
|
||||||
|
).resolves.toBe('https://mail.internal');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith(
|
||||||
|
'https://mail.internal/.well-known/jmap',
|
||||||
|
expect.objectContaining({ redirect: 'manual' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with trusted=true, still rejects unsupported protocols', async () => {
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
await expect(
|
||||||
|
verifyJmapAuth('file:///etc/passwd', 'Bearer x', { trusted: true }),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with trusted=true, still rejects an invalid Authorization header', async () => {
|
||||||
|
const { verifyJmapAuth } = await load();
|
||||||
|
await expect(
|
||||||
|
verifyJmapAuth('https://mail.internal', 'NotAuth', { trusted: true }),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,8 @@ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
||||||
|
|
||||||
function getAdminDir(): string {
|
function getAdminDir(): string {
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
||||||
@@ -34,6 +35,7 @@ class ConfigManager {
|
|||||||
this.policyCache = {
|
this.policyCache = {
|
||||||
...DEFAULT_POLICY,
|
...DEFAULT_POLICY,
|
||||||
...policy,
|
...policy,
|
||||||
|
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -64,6 +66,12 @@ class ConfigManager {
|
|||||||
if (envVal !== undefined) {
|
if (envVal !== undefined) {
|
||||||
return parseEnvValue(envVal, mapping.type) as T;
|
return parseEnvValue(envVal, mapping.type) as T;
|
||||||
}
|
}
|
||||||
|
if (mapping.fileEnvVar) {
|
||||||
|
const fileVal = readFileEnv(process.env[mapping.fileEnvVar]);
|
||||||
|
if (fileVal !== null) {
|
||||||
|
return parseEnvValue(fileVal, mapping.type) as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (defaultValue !== undefined) return defaultValue;
|
if (defaultValue !== undefined) return defaultValue;
|
||||||
return mapping.defaultValue as T;
|
return mapping.defaultValue as T;
|
||||||
}
|
}
|
||||||
@@ -94,9 +102,16 @@ class ConfigManager {
|
|||||||
const envVal = process.env[mapping.envVar];
|
const envVal = process.env[mapping.envVar];
|
||||||
if (envVal !== undefined) {
|
if (envVal !== undefined) {
|
||||||
result[key] = { value: parseEnvValue(envVal, mapping.type), source: 'env' };
|
result[key] = { value: parseEnvValue(envVal, mapping.type), source: 'env' };
|
||||||
} else {
|
continue;
|
||||||
result[key] = { value: mapping.defaultValue, source: 'default' };
|
|
||||||
}
|
}
|
||||||
|
if (mapping.fileEnvVar) {
|
||||||
|
const fileVal = readFileEnv(process.env[mapping.fileEnvVar]);
|
||||||
|
if (fileVal !== null) {
|
||||||
|
result[key] = { value: parseEnvValue(fileVal, mapping.type), source: 'env' };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[key] = { value: mapping.defaultValue, source: 'default' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -129,7 +144,12 @@ class ConfigManager {
|
|||||||
* Update the settings policy. Writes to disk.
|
* Update the settings policy. Writes to disk.
|
||||||
*/
|
*/
|
||||||
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
||||||
this.policyCache = { ...DEFAULT_POLICY, ...policy };
|
this.policyCache = {
|
||||||
|
...DEFAULT_POLICY,
|
||||||
|
...policy,
|
||||||
|
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||||
|
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||||
|
};
|
||||||
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Computes the union of CSP `frame-src` origins declared by installed and
|
||||||
|
* enabled plugins. The proxy reads this on each request so that plugins can
|
||||||
|
* embed external content (YouTube, Vimeo, Jitsi, …) without us hard-coding
|
||||||
|
* domains in the host CSP.
|
||||||
|
*
|
||||||
|
* Origins are validated at install time and re-validated here as defense in
|
||||||
|
* depth - any malformed value is dropped so a corrupted registry can never
|
||||||
|
* inject arbitrary CSP fragments.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getPluginRegistry } from './plugin-registry';
|
||||||
|
|
||||||
|
// `https://host`, `https://host:port`, or `https://*.host[:port]`
|
||||||
|
//
|
||||||
|
// Each label is alphanumeric with optional inner dashes; the final TLD label
|
||||||
|
// MUST start with a letter so we reject raw IPv4 literals.
|
||||||
|
//
|
||||||
|
// Disallowed by the regex (intentionally):
|
||||||
|
// - any scheme other than https
|
||||||
|
// - paths, queries, fragments
|
||||||
|
// - userinfo, IPv4 literals, IPv6 literals (`[::1]`)
|
||||||
|
// - bare wildcards (`https://*`)
|
||||||
|
const FRAME_ORIGIN_RE =
|
||||||
|
/^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i;
|
||||||
|
|
||||||
|
export function isValidFrameOrigin(origin: unknown): origin is string {
|
||||||
|
if (typeof origin !== 'string') return false;
|
||||||
|
if (origin.length > 200) return false;
|
||||||
|
if (!FRAME_ORIGIN_RE.test(origin)) return false;
|
||||||
|
// Reject control characters / whitespace as a final safeguard against
|
||||||
|
// anything that would let an attacker break out of the directive.
|
||||||
|
if (/[\s'"`;,()]/.test(origin)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitises a list of candidate origins from a manifest. Drops invalid
|
||||||
|
* entries silently and dedupes (case-insensitive on the host).
|
||||||
|
*/
|
||||||
|
export function sanitizeFrameOrigins(input: unknown): string[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const value of input) {
|
||||||
|
if (!isValidFrameOrigin(value)) continue;
|
||||||
|
const key = value.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory cache. The proxy fires on every page navigation; reading the
|
||||||
|
// registry JSON every time is fine but cheap to skip when nothing has
|
||||||
|
// changed. Five seconds is short enough to make plugin install/uninstall
|
||||||
|
// feel snappy without measurable overhead.
|
||||||
|
let cachedAt = 0;
|
||||||
|
let cachedOrigins: string[] = [];
|
||||||
|
const CACHE_TTL_MS = 5_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the union of frame origins declared by every enabled plugin in
|
||||||
|
* the server-side registry, deduped and validated.
|
||||||
|
*
|
||||||
|
* Returns an empty array on any failure (missing file, parse error, …) so
|
||||||
|
* a broken registry only ever shrinks the CSP - never widens it.
|
||||||
|
*/
|
||||||
|
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registry = await getPluginRegistry();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const plugin of registry.plugins) {
|
||||||
|
if (!plugin.enabled) continue;
|
||||||
|
const origins = sanitizeFrameOrigins(plugin.frameOrigins);
|
||||||
|
for (const o of origins) {
|
||||||
|
const key = o.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedOrigins = out;
|
||||||
|
cachedAt = now;
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
cachedOrigins = [];
|
||||||
|
cachedAt = now;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force the next call to re-read the registry. Used by install/uninstall. */
|
||||||
|
export function invalidateFrameOriginsCache(): void {
|
||||||
|
cachedAt = 0;
|
||||||
|
cachedOrigins = [];
|
||||||
|
}
|
||||||
@@ -41,6 +41,11 @@ export interface ServerPlugin {
|
|||||||
configSchema?: Record<string, PluginConfigField>;
|
configSchema?: Record<string, PluginConfigField>;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/**
|
||||||
|
* Validated CSP origins (https-only, single-origin form) the plugin may
|
||||||
|
* embed. Merged into the host frame-src by the proxy.
|
||||||
|
*/
|
||||||
|
frameOrigins?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerTheme {
|
export interface ServerTheme {
|
||||||
|
|||||||
+5
-3
@@ -39,6 +39,7 @@ export interface FeatureGates {
|
|||||||
folderIconsEnabled: boolean;
|
folderIconsEnabled: boolean;
|
||||||
hoverActionsConfigEnabled: boolean;
|
hoverActionsConfigEnabled: boolean;
|
||||||
filesEnabled: boolean;
|
filesEnabled: boolean;
|
||||||
|
contactsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||||
@@ -58,6 +59,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
|||||||
folderIconsEnabled: true,
|
folderIconsEnabled: true,
|
||||||
hoverActionsConfigEnabled: true,
|
hoverActionsConfigEnabled: true,
|
||||||
filesEnabled: true,
|
filesEnabled: true,
|
||||||
|
contactsEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ThemePolicy {
|
export interface ThemePolicy {
|
||||||
@@ -106,7 +108,7 @@ export interface AuditEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Config keys that map to environment variables */
|
/** Config keys that map to environment variables */
|
||||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
|
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||||
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
||||||
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
||||||
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
||||||
@@ -124,7 +126,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
|
|||||||
oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false },
|
oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false },
|
||||||
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
|
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
|
||||||
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
||||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
|
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||||
@@ -134,7 +136,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
|
|||||||
settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false },
|
settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false },
|
||||||
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
|
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
|
||||||
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
||||||
sessionSecret: { envVar: 'SESSION_SECRET', type: 'string', defaultValue: '' },
|
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Keys that should never be exposed to the client config endpoint */
|
/** Keys that should never be exposed to the client config endpoint */
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
|
||||||
const VERIFY_TIMEOUT_MS = 10000;
|
const VERIFY_TIMEOUT_MS = 10000;
|
||||||
|
const MAX_REDIRECTS = 3;
|
||||||
|
|
||||||
export class JmapAuthVerificationError extends Error {
|
export class JmapAuthVerificationError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -37,19 +40,55 @@ export function validateProxyAuthHeader(authHeader: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyJmapAuth(serverUrl: string, authHeader: string): Promise<string> {
|
export async function verifyJmapAuth(
|
||||||
|
serverUrl: string,
|
||||||
|
authHeader: string,
|
||||||
|
options: { trusted?: boolean } = {},
|
||||||
|
): Promise<string> {
|
||||||
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
|
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
|
||||||
validateProxyAuthHeader(authHeader);
|
validateProxyAuthHeader(authHeader);
|
||||||
|
|
||||||
|
if (!options.trusted && !(await isPublicHttpUrl(normalizedServerUrl))) {
|
||||||
|
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${normalizedServerUrl}/.well-known/jmap`, {
|
let currentUrl = `${normalizedServerUrl}/.well-known/jmap`;
|
||||||
method: 'GET',
|
let response: Response | undefined;
|
||||||
headers: { Authorization: authHeader },
|
|
||||||
signal: controller.signal,
|
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||||
});
|
if (!options.trusted && !(await isPublicHttpUrl(currentUrl))) {
|
||||||
|
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await fetch(currentUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: authHeader },
|
||||||
|
signal: controller.signal,
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (!location) {
|
||||||
|
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
|
||||||
|
}
|
||||||
|
currentUrl = new URL(location, currentUrl).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
throw new JmapAuthVerificationError('Too many redirects verifying JMAP session', 502);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new JmapAuthVerificationError(
|
throw new JmapAuthVerificationError(
|
||||||
@@ -77,4 +116,4 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro
|
|||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function createBirthdayCalendar(name?: string, color?: string): Calendar
|
|||||||
mayWriteOwn: false,
|
mayWriteOwn: false,
|
||||||
mayUpdatePrivate: false,
|
mayUpdatePrivate: false,
|
||||||
mayRSVP: false,
|
mayRSVP: false,
|
||||||
mayAdmin: false,
|
mayShare: false,
|
||||||
mayDelete: false,
|
mayDelete: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+61
-4
@@ -78,6 +78,10 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
supportsCalendars(): boolean { return true; }
|
supportsCalendars(): boolean { return true; }
|
||||||
supportsSieve(): boolean { return true; }
|
supportsSieve(): boolean { return true; }
|
||||||
supportsFiles(): boolean { return true; }
|
supportsFiles(): boolean { return true; }
|
||||||
|
supportsPrincipals(): boolean { return false; }
|
||||||
|
async getPrincipals(): Promise<never[]> { return []; }
|
||||||
|
async setCalendarShare(): Promise<void> { /* demo: no-op */ }
|
||||||
|
async setAddressBookShare(): Promise<void> { /* demo: no-op */ }
|
||||||
|
|
||||||
// ── Push / state ──────────────────────────────────────────────
|
// ── Push / state ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -91,6 +95,18 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
getLastStates(): AccountStates { return { ...this.lastStates }; }
|
getLastStates(): AccountStates { return { ...this.lastStates }; }
|
||||||
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
|
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
|
||||||
|
|
||||||
|
// PushSubscription endpoints have no meaning in demo mode - the demo client
|
||||||
|
// never makes real network calls so there's nothing for the relay to push to.
|
||||||
|
async listPushSubscriptions() { return []; }
|
||||||
|
async createPushSubscription(): Promise<string> {
|
||||||
|
throw new Error('Push subscriptions are not available in demo mode');
|
||||||
|
}
|
||||||
|
async verifyPushSubscription(): Promise<void> {
|
||||||
|
throw new Error('Push subscriptions are not available in demo mode');
|
||||||
|
}
|
||||||
|
async updatePushSubscription(): Promise<boolean> { return false; }
|
||||||
|
async destroyPushSubscription(): Promise<void> { /* no-op */ }
|
||||||
|
|
||||||
// ── Quota ─────────────────────────────────────────────────────
|
// ── Quota ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
async getQuota(): Promise<{ used: number; total: number } | null> {
|
async getQuota(): Promise<{ used: number; total: number } | null> {
|
||||||
@@ -312,6 +328,33 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async markMailboxAsRead(mailboxId: string): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
for (const email of this.data.emails) {
|
||||||
|
if (email.mailboxIds[mailboxId] && email.keywords.$seen !== true) {
|
||||||
|
email.keywords.$seen = true;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllAsRead(excludeMailboxIds: string[] = []): Promise<number> {
|
||||||
|
const excluded = new Set(excludeMailboxIds);
|
||||||
|
let count = 0;
|
||||||
|
for (const email of this.data.emails) {
|
||||||
|
if (email.keywords.$seen === true) continue;
|
||||||
|
const mbIds = Object.keys(email.mailboxIds);
|
||||||
|
const onlyInExcluded = mbIds.length > 0 && mbIds.every(id => excluded.has(id));
|
||||||
|
if (onlyInExcluded) continue;
|
||||||
|
email.keywords.$seen = true;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
async markAsSpam(emailId: string): Promise<void> {
|
async markAsSpam(emailId: string): Promise<void> {
|
||||||
const email = this.data.emails.find(e => e.id === emailId);
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
|
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
|
||||||
@@ -352,6 +395,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
draftId?: string,
|
draftId?: string,
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||||
_fromName?: string,
|
_fromName?: string,
|
||||||
|
htmlBody?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
|
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
|
||||||
const id = draftId || generateDemoId('email');
|
const id = draftId || generateDemoId('email');
|
||||||
@@ -371,9 +415,13 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
sentAt: new Date().toISOString(),
|
sentAt: new Date().toISOString(),
|
||||||
preview: body.substring(0, 200),
|
preview: body.substring(0, 200),
|
||||||
hasAttachment: !!attachments?.length,
|
hasAttachment: !!attachments?.length,
|
||||||
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
|
textBody: [{ partId: htmlBody ? 'text' : '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
|
||||||
htmlBody: [],
|
htmlBody: htmlBody
|
||||||
bodyValues: { '1': { value: body } },
|
? [{ partId: 'html', blobId: generateDemoId('blob'), size: htmlBody.length, type: 'text/html' }]
|
||||||
|
: [],
|
||||||
|
bodyValues: htmlBody
|
||||||
|
? { text: { value: body }, html: { value: htmlBody } }
|
||||||
|
: { '1': { value: body } },
|
||||||
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
|
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
|
||||||
messageId: `<${id}@demo.example.com>`,
|
messageId: `<${id}@demo.example.com>`,
|
||||||
};
|
};
|
||||||
@@ -399,6 +447,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
_fromName?: string,
|
_fromName?: string,
|
||||||
htmlBody?: string,
|
htmlBody?: string,
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||||
|
inReplyTo?: string[],
|
||||||
|
references?: string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Remove draft if updating
|
// Remove draft if updating
|
||||||
if (draftId) {
|
if (draftId) {
|
||||||
@@ -424,6 +474,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } },
|
bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } },
|
||||||
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
|
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
|
||||||
messageId: `<${generateDemoId('msg')}@demo.example.com>`,
|
messageId: `<${generateDemoId('msg')}@demo.example.com>`,
|
||||||
|
inReplyTo: inReplyTo?.length ? inReplyTo : undefined,
|
||||||
|
references: references?.length ? references : undefined,
|
||||||
};
|
};
|
||||||
this.data.emails.push(email);
|
this.data.emails.push(email);
|
||||||
this.recalcMailboxCounts();
|
this.recalcMailboxCounts();
|
||||||
@@ -525,6 +577,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
if (book) Object.assign(book, updates);
|
if (book) Object.assign(book, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteAddressBook(addressBookId: string): Promise<void> {
|
||||||
|
this.data.addressBooks = this.data.addressBooks.filter(b => b.id !== addressBookId);
|
||||||
|
this.data.contacts = this.data.contacts.filter(c => !c.addressBookIds?.[addressBookId]);
|
||||||
|
}
|
||||||
|
|
||||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||||
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
||||||
return [...this.data.contacts];
|
return [...this.data.contacts];
|
||||||
@@ -582,7 +639,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
includeInAvailability: 'all',
|
includeInAvailability: 'all',
|
||||||
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
|
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
|
||||||
timeZone: null, shareWith: null,
|
timeZone: null, shareWith: null,
|
||||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||||
...calendar,
|
...calendar,
|
||||||
} as Calendar;
|
} as Calendar;
|
||||||
this.data.calendars.push(full);
|
this.data.calendars.push(full);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function createDemoCalendars(): Calendar[] {
|
|||||||
defaultAlertsWithoutTime: null,
|
defaultAlertsWithoutTime: null,
|
||||||
timeZone: null,
|
timeZone: null,
|
||||||
shareWith: null,
|
shareWith: null,
|
||||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false },
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'demo-calendar-work',
|
id: 'demo-calendar-work',
|
||||||
@@ -33,7 +33,7 @@ export function createDemoCalendars(): Calendar[] {
|
|||||||
defaultAlertsWithoutTime: null,
|
defaultAlertsWithoutTime: null,
|
||||||
timeZone: null,
|
timeZone: null,
|
||||||
shareWith: null,
|
shareWith: null,
|
||||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'demo-calendar-birthdays',
|
id: 'demo-calendar-birthdays',
|
||||||
@@ -49,7 +49,7 @@ export function createDemoCalendars(): Calendar[] {
|
|||||||
defaultAlertsWithoutTime: null,
|
defaultAlertsWithoutTime: null,
|
||||||
timeZone: null,
|
timeZone: null,
|
||||||
shareWith: null,
|
shareWith: null,
|
||||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export function collapseBlockedImageContainers(html: string): string {
|
|||||||
const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
|
const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
|
||||||
const hasLinks = el.querySelector('a[href]');
|
const hasLinks = el.querySelector('a[href]');
|
||||||
if (!hasVisibleText && !hasVisibleMedia && !hasLinks) {
|
if (!hasVisibleText && !hasVisibleMedia && !hasLinks) {
|
||||||
|
el.setAttribute('data-blocked-collapsed-style', el.style.cssText);
|
||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
el.style.height = '0';
|
el.style.height = '0';
|
||||||
el.style.padding = '0';
|
el.style.padding = '0';
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* RFC 5322 §3.6.4 reply threading.
|
||||||
|
*
|
||||||
|
* Computes the In-Reply-To and References headers an outgoing reply must
|
||||||
|
* carry so MUAs can stitch the conversation back together.
|
||||||
|
*
|
||||||
|
* In-Reply-To = parent.Message-ID
|
||||||
|
* References = parent.References (if any) + parent.Message-ID
|
||||||
|
*
|
||||||
|
* Bare msg-ids only — angle brackets are stripped because JMAP RFC 8621
|
||||||
|
* §4.1.2.3 stores Message-IDs without them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ParentThreadingInfo {
|
||||||
|
// JMAP RFC 8621 §4.1.2.3 specifies messageId as String[]|null, but the
|
||||||
|
// codebase has historically typed it as string. Accept either shape.
|
||||||
|
messageId?: string | string[];
|
||||||
|
references?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplyThreadingHeaders {
|
||||||
|
inReplyTo: string[];
|
||||||
|
references: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripMessageIdBrackets(id: string): string {
|
||||||
|
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeReplyThreadingHeaders(
|
||||||
|
parent: ParentThreadingInfo | undefined,
|
||||||
|
): ReplyThreadingHeaders | null {
|
||||||
|
const rawId = Array.isArray(parent?.messageId) ? parent.messageId[0] : parent?.messageId;
|
||||||
|
const parentId = rawId ? stripMessageIdBrackets(rawId) : '';
|
||||||
|
if (!parentId) return null;
|
||||||
|
|
||||||
|
const ancestors = (parent?.references ?? [])
|
||||||
|
.map(stripMessageIdBrackets)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// De-dupe while preserving order; the parent's id closes the chain.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const references: string[] = [];
|
||||||
|
for (const id of [...ancestors, parentId]) {
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
references.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inReplyTo: [parentId], references };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from "./types";
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription } from "./types";
|
||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +51,20 @@ export interface IJMAPClient {
|
|||||||
getLastStates(): AccountStates;
|
getLastStates(): AccountStates;
|
||||||
setLastStates(states: AccountStates): void;
|
setLastStates(states: AccountStates): void;
|
||||||
|
|
||||||
|
// ── PushSubscription (RFC 8620 §7.2) ───────────────────────────
|
||||||
|
// Browser-driven Web Push setup: register a relay URL the JMAP server can
|
||||||
|
// forward StateChange events to. Mobile uses the same primitives.
|
||||||
|
listPushSubscriptions(): Promise<PushSubscription[]>;
|
||||||
|
createPushSubscription(params: {
|
||||||
|
deviceClientId: string;
|
||||||
|
url: string;
|
||||||
|
types: string[];
|
||||||
|
expires?: string;
|
||||||
|
}): Promise<string>;
|
||||||
|
verifyPushSubscription(id: string, verificationCode: string): Promise<void>;
|
||||||
|
updatePushSubscription(id: string, patch: { expires?: string; types?: string[] }): Promise<boolean>;
|
||||||
|
destroyPushSubscription(id: string): Promise<void>;
|
||||||
|
|
||||||
// ── Quota ─────────────────────────────────────────────────────
|
// ── Quota ─────────────────────────────────────────────────────
|
||||||
getQuota(): Promise<{ used: number; total: number } | null>;
|
getQuota(): Promise<{ used: number; total: number } | null>;
|
||||||
|
|
||||||
@@ -94,6 +108,8 @@ export interface IJMAPClient {
|
|||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||||
emptyMailbox(mailboxId: string): Promise<number>;
|
emptyMailbox(mailboxId: string): Promise<number>;
|
||||||
|
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
|
||||||
|
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
|
||||||
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
||||||
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
|
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
|
||||||
|
|
||||||
@@ -113,6 +129,7 @@ export interface IJMAPClient {
|
|||||||
draftId?: string,
|
draftId?: string,
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||||
fromName?: string,
|
fromName?: string,
|
||||||
|
htmlBody?: string,
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
|
|
||||||
sendEmail(
|
sendEmail(
|
||||||
@@ -127,6 +144,8 @@ export interface IJMAPClient {
|
|||||||
fromName?: string,
|
fromName?: string,
|
||||||
htmlBody?: string,
|
htmlBody?: string,
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||||
|
inReplyTo?: string[],
|
||||||
|
references?: string[],
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
sendImipReply(opts: {
|
sendImipReply(opts: {
|
||||||
@@ -188,6 +207,7 @@ export interface IJMAPClient {
|
|||||||
getAllAddressBooks(): Promise<AddressBook[]>;
|
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||||
createAddressBook(name: string): Promise<AddressBook>;
|
createAddressBook(name: string): Promise<AddressBook>;
|
||||||
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
||||||
|
deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void>;
|
||||||
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||||
getAllContacts(): Promise<ContactCard[]>;
|
getAllContacts(): Promise<ContactCard[]>;
|
||||||
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
||||||
@@ -225,6 +245,12 @@ export interface IJMAPClient {
|
|||||||
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
|
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
|
||||||
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
|
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Sharing (RFC 9670 Principals) ─────────────────────────────
|
||||||
|
supportsPrincipals(): boolean;
|
||||||
|
getPrincipals(targetAccountId?: string): Promise<Principal[]>;
|
||||||
|
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
||||||
|
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
||||||
|
|
||||||
// ── Sieve / Filters ──────────────────────────────────────────
|
// ── Sieve / Filters ──────────────────────────────────────────
|
||||||
getSieveAccountId(): string;
|
getSieveAccountId(): string;
|
||||||
getSieveCapabilities(): SieveCapabilities | null;
|
getSieveCapabilities(): SieveCapabilities | null;
|
||||||
|
|||||||
+297
-6
@@ -1,4 +1,4 @@
|
|||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter } from "./types";
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription } from "./types";
|
||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
import type { IJMAPClient } from "./client-interface";
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
import { toWildcardQuery } from "./search-utils";
|
||||||
@@ -294,6 +294,12 @@ function foldIcsLine(line: string): string {
|
|||||||
return chunks.join('\r\n');
|
return chunks.join('\r\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JMAP RFC 8621 stores Message-IDs without angle brackets. Strip any that
|
||||||
|
// snuck in (e.g. when echoing values that originated from RFC 5322 headers).
|
||||||
|
function stripMessageIdBrackets(id: string): string {
|
||||||
|
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
export class JMAPClient implements IJMAPClient {
|
export class JMAPClient implements IJMAPClient {
|
||||||
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
||||||
|
|
||||||
@@ -1361,6 +1367,99 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return totalDestroyed;
|
return totalDestroyed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number> {
|
||||||
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
let totalMarked = 0;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const queryResponse = await this.request([
|
||||||
|
["Email/query", {
|
||||||
|
accountId: targetAccountId,
|
||||||
|
filter: {
|
||||||
|
operator: "AND",
|
||||||
|
conditions: [
|
||||||
|
{ inMailbox: mailboxId },
|
||||||
|
{ notKeyword: "$seen" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
limit: 500,
|
||||||
|
}, "0"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
|
||||||
|
if (ids.length === 0) break;
|
||||||
|
|
||||||
|
const updates = Object.fromEntries(
|
||||||
|
ids.map((id) => [id, { "keywords/$seen": true }])
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.request([
|
||||||
|
["Email/set", { accountId: targetAccountId, update: updates }, "0"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
totalMarked += ids.length;
|
||||||
|
hasMore = ids.length === 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalMarked;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise<number> {
|
||||||
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
const excludeSet = new Set(excludeMailboxIds);
|
||||||
|
let totalMarked = 0;
|
||||||
|
let hasMore = true;
|
||||||
|
let position = 0;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const response = await this.request([
|
||||||
|
["Email/query", {
|
||||||
|
accountId: targetAccountId,
|
||||||
|
filter: { notKeyword: "$seen" },
|
||||||
|
limit: 500,
|
||||||
|
position,
|
||||||
|
}, "0"],
|
||||||
|
["Email/get", {
|
||||||
|
accountId: targetAccountId,
|
||||||
|
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
|
||||||
|
properties: ["id", "mailboxIds"],
|
||||||
|
}, "1"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const queryResult = response.methodResponses?.[0]?.[1];
|
||||||
|
const getResult = response.methodResponses?.[1]?.[1];
|
||||||
|
const ids: string[] = queryResult?.ids || [];
|
||||||
|
const emails: Array<{ id: string; mailboxIds?: Record<string, boolean> }> = getResult?.list || [];
|
||||||
|
|
||||||
|
if (ids.length === 0) break;
|
||||||
|
|
||||||
|
const targetIds = excludeSet.size === 0
|
||||||
|
? ids
|
||||||
|
: emails
|
||||||
|
.filter(e => {
|
||||||
|
const mbIds = e.mailboxIds ? Object.keys(e.mailboxIds) : [];
|
||||||
|
return mbIds.some(id => !excludeSet.has(id));
|
||||||
|
})
|
||||||
|
.map(e => e.id);
|
||||||
|
|
||||||
|
if (targetIds.length > 0) {
|
||||||
|
const updates = Object.fromEntries(
|
||||||
|
targetIds.map((id) => [id, { "keywords/$seen": true }])
|
||||||
|
);
|
||||||
|
await this.request([
|
||||||
|
["Email/set", { accountId: targetAccountId, update: updates }, "0"],
|
||||||
|
]);
|
||||||
|
totalMarked += targetIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore = ids.length === 500;
|
||||||
|
position += ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalMarked;
|
||||||
|
}
|
||||||
|
|
||||||
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
|
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
|
||||||
@@ -1840,7 +1939,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
fromEmail?: string,
|
fromEmail?: string,
|
||||||
draftId?: string,
|
draftId?: string,
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||||
fromName?: string
|
fromName?: string,
|
||||||
|
htmlBody?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const mailboxes = await this.getMailboxes();
|
const mailboxes = await this.getMailboxes();
|
||||||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||||||
@@ -1859,7 +1959,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
keywords: Record<string, boolean>;
|
keywords: Record<string, boolean>;
|
||||||
mailboxIds: Record<string, boolean>;
|
mailboxIds: Record<string, boolean>;
|
||||||
bodyValues: Record<string, { value: string }>;
|
bodyValues: Record<string, { value: string }>;
|
||||||
textBody: { partId: string }[];
|
textBody: { partId: string; type?: string }[];
|
||||||
|
htmlBody?: { partId: string; type: string }[];
|
||||||
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
|
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1871,8 +1972,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
subject,
|
subject,
|
||||||
keywords: { "$draft": true },
|
keywords: { "$draft": true },
|
||||||
mailboxIds: { [draftsMailbox.id]: true },
|
mailboxIds: { [draftsMailbox.id]: true },
|
||||||
bodyValues: { "1": { value: body } },
|
bodyValues: htmlBody
|
||||||
textBody: [{ partId: "1" }],
|
? { "text": { value: body }, "html": { value: htmlBody } }
|
||||||
|
: { "1": { value: body } },
|
||||||
|
textBody: htmlBody
|
||||||
|
? [{ partId: "text", type: "text/plain" }]
|
||||||
|
: [{ partId: "1" }],
|
||||||
|
...(htmlBody ? { htmlBody: [{ partId: "html", type: "text/html" }] } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (attachments?.length) {
|
if (attachments?.length) {
|
||||||
@@ -1934,7 +2040,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
draftId?: string,
|
draftId?: string,
|
||||||
fromName?: string,
|
fromName?: string,
|
||||||
htmlBody?: string,
|
htmlBody?: string,
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||||
|
inReplyTo?: string[],
|
||||||
|
references?: string[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const emailId = `send-${Date.now()}`;
|
const emailId = `send-${Date.now()}`;
|
||||||
const mailboxes = await this.getMailboxes();
|
const mailboxes = await this.getMailboxes();
|
||||||
@@ -1974,6 +2082,11 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per RFC 8621 §4.1.2.3 inReplyTo/references are arrays of bare msg-ids
|
||||||
|
// (no angle brackets). Stalwart may return them either way, so normalize.
|
||||||
|
const normalizedInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean);
|
||||||
|
const normalizedReferences = references?.map(stripMessageIdBrackets).filter(Boolean);
|
||||||
|
|
||||||
// Always create a new email with the final body content
|
// Always create a new email with the final body content
|
||||||
const emailCreate: Record<string, unknown> = {
|
const emailCreate: Record<string, unknown> = {
|
||||||
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
|
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
|
||||||
@@ -1982,6 +2095,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
cc: cc?.map(email => ({ email })),
|
cc: cc?.map(email => ({ email })),
|
||||||
bcc: bcc?.map(email => ({ email })),
|
bcc: bcc?.map(email => ({ email })),
|
||||||
subject,
|
subject,
|
||||||
|
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
|
||||||
|
references: normalizedReferences?.length ? normalizedReferences : undefined,
|
||||||
keywords: { "$seen": true, "$draft": true },
|
keywords: { "$seen": true, "$draft": true },
|
||||||
mailboxIds: { [draftsMailbox.id]: true },
|
mailboxIds: { [draftsMailbox.id]: true },
|
||||||
};
|
};
|
||||||
@@ -2733,6 +2848,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return this.hasCapability("urn:ietf:params:jmap:sieve");
|
return this.hasCapability("urn:ietf:params:jmap:sieve");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
supportsPrincipals(): boolean {
|
||||||
|
return this.hasCapability("urn:ietf:params:jmap:principals");
|
||||||
|
}
|
||||||
|
|
||||||
getSieveAccountId(): string {
|
getSieveAccountId(): string {
|
||||||
const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"];
|
const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"];
|
||||||
return sieveAccount || this.accountId;
|
return sieveAccount || this.accountId;
|
||||||
@@ -3105,6 +3224,102 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
throw new Error("Failed to update address book");
|
throw new Error("Failed to update address book");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void> {
|
||||||
|
const accountId = targetAccountId || this.getContactsAccountId();
|
||||||
|
const response = await this.request([
|
||||||
|
["AddressBook/set", { accountId, destroy: [addressBookId] }, "0"],
|
||||||
|
], this.contactUsing());
|
||||||
|
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
if (result?.notDestroyed?.[addressBookId]) {
|
||||||
|
const err = result.notDestroyed[addressBookId];
|
||||||
|
throw new Error(err.description || "Failed to delete address book");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sharing (RFC 9670) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private principalsUsing(): string[] {
|
||||||
|
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:principals"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all principals visible to the user (RFC 9670). Stalwart returns the
|
||||||
|
* full directory regardless of `filter`, so we fetch the whole list and let
|
||||||
|
* callers filter client-side.
|
||||||
|
*/
|
||||||
|
async getPrincipals(targetAccountId?: string): Promise<Principal[]> {
|
||||||
|
if (!this.supportsPrincipals()) return [];
|
||||||
|
const accountId = targetAccountId || this.accountId;
|
||||||
|
try {
|
||||||
|
const response = await this.request([
|
||||||
|
["Principal/query", { accountId }, "0"],
|
||||||
|
["Principal/get", {
|
||||||
|
accountId,
|
||||||
|
"#ids": { resultOf: "0", name: "Principal/query", path: "/ids" },
|
||||||
|
}, "1"],
|
||||||
|
], this.principalsUsing());
|
||||||
|
|
||||||
|
const getResp = response.methodResponses?.find((r) => r[0] === "Principal/get");
|
||||||
|
if (!getResp) return [];
|
||||||
|
const list = (getResp[1].list || []) as Principal[];
|
||||||
|
return list.map((p) => ({ ...p, accountId }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch principals:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add, update, or remove a principal's rights on a calendar.
|
||||||
|
* Pass `rights: null` to revoke access.
|
||||||
|
*/
|
||||||
|
async setCalendarShare(
|
||||||
|
calendarId: string,
|
||||||
|
principalId: string,
|
||||||
|
rights: CalendarRights | null,
|
||||||
|
targetAccountId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
const response = await this.request([
|
||||||
|
["Calendar/set", {
|
||||||
|
accountId,
|
||||||
|
update: { [calendarId]: { [`shareWith/${principalId}`]: rights } },
|
||||||
|
}, "0"],
|
||||||
|
], this.calendarUsing());
|
||||||
|
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
if (result?.notUpdated?.[calendarId]) {
|
||||||
|
const err = result.notUpdated[calendarId];
|
||||||
|
throw new Error(err.description || "Failed to update calendar share");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add, update, or remove a principal's rights on an address book.
|
||||||
|
* Pass `rights: null` to revoke access.
|
||||||
|
*/
|
||||||
|
async setAddressBookShare(
|
||||||
|
addressBookId: string,
|
||||||
|
principalId: string,
|
||||||
|
rights: AddressBookRights | null,
|
||||||
|
targetAccountId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const accountId = targetAccountId || this.getContactsAccountId();
|
||||||
|
const response = await this.request([
|
||||||
|
["AddressBook/set", {
|
||||||
|
accountId,
|
||||||
|
update: { [addressBookId]: { [`shareWith/${principalId}`]: rights } },
|
||||||
|
}, "0"],
|
||||||
|
], this.contactUsing());
|
||||||
|
|
||||||
|
const result = response.methodResponses?.[0]?.[1];
|
||||||
|
if (result?.notUpdated?.[addressBookId]) {
|
||||||
|
const err = result.notUpdated[addressBookId];
|
||||||
|
throw new Error(err.description || "Failed to update address book share");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async fetchPaginatedContacts(
|
private async fetchPaginatedContacts(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
filter?: Record<string, unknown>,
|
filter?: Record<string, unknown>,
|
||||||
@@ -5098,4 +5313,80 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PushSubscription (RFC 8620 §7.2) ──────────────────────────────
|
||||||
|
// Used by the PWA Web Push integration. The mobile app does the same dance
|
||||||
|
// through its own JMAP client - keep these in sync.
|
||||||
|
|
||||||
|
async listPushSubscriptions(): Promise<PushSubscription[]> {
|
||||||
|
const response = await this.request(
|
||||||
|
[['PushSubscription/get', { ids: null }, '0']],
|
||||||
|
['urn:ietf:params:jmap:core'],
|
||||||
|
);
|
||||||
|
const [, body] = response.methodResponses[0] ?? [];
|
||||||
|
return ((body as { list?: PushSubscription[] } | undefined)?.list) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPushSubscription(params: {
|
||||||
|
deviceClientId: string;
|
||||||
|
url: string;
|
||||||
|
types: string[];
|
||||||
|
expires?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const created: Record<string, unknown> = {
|
||||||
|
deviceClientId: params.deviceClientId,
|
||||||
|
url: params.url,
|
||||||
|
types: params.types,
|
||||||
|
};
|
||||||
|
if (params.expires) created.expires = params.expires;
|
||||||
|
|
||||||
|
const response = await this.request(
|
||||||
|
[['PushSubscription/set', { create: { new: created } }, '0']],
|
||||||
|
['urn:ietf:params:jmap:core'],
|
||||||
|
);
|
||||||
|
const [, body] = response.methodResponses[0] ?? [];
|
||||||
|
const result = (body as { created?: { new?: { id?: string } }; notCreated?: { new?: unknown } } | undefined);
|
||||||
|
const id = result?.created?.new?.id;
|
||||||
|
if (!id) {
|
||||||
|
throw new Error(
|
||||||
|
`PushSubscription/set create failed: ${JSON.stringify(result?.notCreated?.new ?? body)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyPushSubscription(id: string, verificationCode: string): Promise<void> {
|
||||||
|
const response = await this.request(
|
||||||
|
[['PushSubscription/set', { update: { [id]: { verificationCode } } }, '0']],
|
||||||
|
['urn:ietf:params:jmap:core'],
|
||||||
|
);
|
||||||
|
const [, body] = response.methodResponses[0] ?? [];
|
||||||
|
const notUpdated = (body as { notUpdated?: Record<string, unknown> } | undefined)?.notUpdated?.[id];
|
||||||
|
if (notUpdated) {
|
||||||
|
throw new Error(`PushSubscription verification failed: ${JSON.stringify(notUpdated)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns false when the server rejects the update (e.g. the subscription
|
||||||
|
// was already destroyed) - the caller treats that as a signal to recreate.
|
||||||
|
async updatePushSubscription(
|
||||||
|
id: string,
|
||||||
|
patch: { expires?: string; types?: string[] },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const response = await this.request(
|
||||||
|
[['PushSubscription/set', { update: { [id]: patch } }, '0']],
|
||||||
|
['urn:ietf:params:jmap:core'],
|
||||||
|
);
|
||||||
|
const [, body] = response.methodResponses[0] ?? [];
|
||||||
|
const r = body as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> } | undefined;
|
||||||
|
if (r?.notUpdated?.[id]) return false;
|
||||||
|
return r?.updated?.[id] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroyPushSubscription(id: string): Promise<void> {
|
||||||
|
await this.request(
|
||||||
|
[['PushSubscription/set', { destroy: [id] }, '0']],
|
||||||
|
['urn:ietf:params:jmap:core'],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+15
-2
@@ -368,6 +368,7 @@ export interface AddressBook {
|
|||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
isSubscribed?: boolean;
|
isSubscribed?: boolean;
|
||||||
myRights?: AddressBookRights;
|
myRights?: AddressBookRights;
|
||||||
|
shareWith?: Record<string, AddressBookRights> | null;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountName?: string;
|
accountName?: string;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
@@ -376,10 +377,22 @@ export interface AddressBook {
|
|||||||
export interface AddressBookRights {
|
export interface AddressBookRights {
|
||||||
mayRead: boolean;
|
mayRead: boolean;
|
||||||
mayWrite: boolean;
|
mayWrite: boolean;
|
||||||
mayShare: boolean;
|
mayShare?: boolean;
|
||||||
mayDelete: boolean;
|
mayDelete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JMAP Principals (RFC 9670)
|
||||||
|
export interface Principal {
|
||||||
|
id: string;
|
||||||
|
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
timeZone?: string | null;
|
||||||
|
capabilities?: Record<string, unknown>;
|
||||||
|
accountId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VacationResponse {
|
export interface VacationResponse {
|
||||||
id: string;
|
id: string;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
@@ -442,7 +455,7 @@ export interface CalendarRights {
|
|||||||
mayWriteOwn: boolean;
|
mayWriteOwn: boolean;
|
||||||
mayUpdatePrivate: boolean;
|
mayUpdatePrivate: boolean;
|
||||||
mayRSVP: boolean;
|
mayRSVP: boolean;
|
||||||
mayAdmin: boolean;
|
mayShare: boolean;
|
||||||
mayDelete: boolean;
|
mayDelete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -7,8 +7,21 @@ export interface OAuthMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const CACHE_MAX_ENTRIES = 64;
|
||||||
const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>();
|
const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>();
|
||||||
|
|
||||||
|
function rememberMetadata(serverUrl: string, metadata: OAuthMetadata): void {
|
||||||
|
// Bound the cache so callers that can supply arbitrary serverUrl values
|
||||||
|
// (e.g. unauthenticated routes that fall back to user input) cannot
|
||||||
|
// exhaust memory. Map preserves insertion order, so the oldest entry is
|
||||||
|
// always the first one yielded by keys().
|
||||||
|
if (metadataCache.size >= CACHE_MAX_ENTRIES) {
|
||||||
|
const oldest = metadataCache.keys().next().value;
|
||||||
|
if (oldest !== undefined) metadataCache.delete(oldest);
|
||||||
|
}
|
||||||
|
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||||
|
}
|
||||||
|
|
||||||
export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata | null> {
|
export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata | null> {
|
||||||
const cached = metadataCache.get(serverUrl);
|
const cached = metadataCache.get(serverUrl);
|
||||||
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
|
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
|
||||||
@@ -38,7 +51,7 @@ export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata |
|
|||||||
revocation_endpoint: data.revocation_endpoint,
|
revocation_endpoint: data.revocation_endpoint,
|
||||||
end_session_endpoint: data.end_session_endpoint,
|
end_session_endpoint: data.end_session_endpoint,
|
||||||
};
|
};
|
||||||
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
|
rememberMetadata(serverUrl, metadata);
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
errors.push(`${url} response missing required endpoints`);
|
errors.push(`${url} response missing required endpoints`);
|
||||||
|
|||||||
@@ -2,18 +2,23 @@ import { logger } from '@/lib/logger';
|
|||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
function getClientSecret(): string {
|
||||||
|
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
||||||
|
if (adminSecret) return adminSecret;
|
||||||
|
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
||||||
|
}
|
||||||
|
|
||||||
export function getRequiredConfig() {
|
export function getRequiredConfig() {
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||||
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
const serverUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||||
const issuerUrl = process.env.OAUTH_ISSUER_URL;
|
const issuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
|
||||||
if (!clientId || !serverUrl) {
|
if (!clientId || !serverUrl) {
|
||||||
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
||||||
}
|
}
|
||||||
const discoveryUrl = issuerUrl?.trim() || serverUrl;
|
const discoveryUrl = issuerUrl?.trim() || serverUrl;
|
||||||
if (issuerUrl !== undefined && !issuerUrl.trim()) {
|
if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
|
||||||
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
||||||
}
|
}
|
||||||
return { clientId, serverUrl, discoveryUrl };
|
return { clientId, serverUrl, discoveryUrl };
|
||||||
@@ -36,8 +41,9 @@ export async function getMetadata(): Promise<OAuthMetadata | null> {
|
|||||||
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
||||||
const { clientId } = getRequiredConfig();
|
const { clientId } = getRequiredConfig();
|
||||||
const params = new URLSearchParams({ ...base, client_id: clientId });
|
const params = new URLSearchParams({ ...base, client_id: clientId });
|
||||||
if (CLIENT_SECRET) {
|
const secret = getClientSecret();
|
||||||
params.set('client_secret', CLIENT_SECRET);
|
if (secret) {
|
||||||
|
params.set('client_secret', secret);
|
||||||
}
|
}
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export interface PluginAPI {
|
|||||||
registerSettingsSection: (section: SettingsSection) => Disposable;
|
registerSettingsSection: (section: SettingsSection) => Disposable;
|
||||||
registerComposerAction: (action: ComposerAction) => Disposable;
|
registerComposerAction: (action: ComposerAction) => Disposable;
|
||||||
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
|
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
|
||||||
|
registerComposerSidebar: (widget: SidebarWidget) => Disposable;
|
||||||
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
|
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
|
||||||
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
|
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
|
||||||
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
|
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
|
||||||
@@ -609,6 +610,12 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
|||||||
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
registerComposerSidebar: (widget: SidebarWidget) => {
|
||||||
|
requirePermission(plugin, 'ui:composer-sidebar');
|
||||||
|
const slot = widget.side === 'right' ? 'composer-sidebar-right' : 'composer-sidebar';
|
||||||
|
return registerSlot(plugin.id, slot, widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
||||||
|
},
|
||||||
|
|
||||||
registerDetailSidebar: (widget: SidebarWidget) => {
|
registerDetailSidebar: (widget: SidebarWidget) => {
|
||||||
requirePermission(plugin, 'ui:sidebar-widget');
|
requirePermission(plugin, 'ui:sidebar-widget');
|
||||||
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
||||||
|
|||||||
@@ -365,6 +365,18 @@ export const themeHooks = {
|
|||||||
onThemeChange: new HookBus(),
|
onThemeChange: new HookBus(),
|
||||||
onCustomThemeChange: new HookBus(),
|
onCustomThemeChange: new HookBus(),
|
||||||
onLocaleChange: new HookBus(),
|
onLocaleChange: new HookBus(),
|
||||||
|
/**
|
||||||
|
* Transform hook fired immediately before a theme's compiled CSS is
|
||||||
|
* injected into the document.
|
||||||
|
*
|
||||||
|
* handler(css: string, ctx: { themeId: string | null; variant: 'light' | 'dark' }): string | undefined
|
||||||
|
*
|
||||||
|
* Return a new CSS string to override what gets injected, or `undefined`
|
||||||
|
* to pass through unchanged. Use this to inject extra `@font-face` rules,
|
||||||
|
* patch a third-party theme's variables for accessibility, or implement
|
||||||
|
* site-wide design-token overrides.
|
||||||
|
*/
|
||||||
|
onThemeBeforeApply: new HookBus(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// §7.15 Toast Hooks
|
// §7.15 Toast Hooks
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user