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 |
+19
-1
@@ -84,6 +84,23 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# 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
|
||||
# =============================================================================
|
||||
@@ -191,7 +208,8 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
# =============================================================================
|
||||
|
||||
# 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
|
||||
|
||||
# =============================================================================
|
||||
|
||||
+48
-2
@@ -1,11 +1,57 @@
|
||||
# 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
|
||||
- **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
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ RUN apk upgrade --no-cache && \
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
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
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@
|
||||
|
||||
## 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`.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
@@ -53,6 +53,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
</tr>
|
||||
</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
|
||||
|
||||
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:
|
||||
|
||||
@@ -31,6 +31,7 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan
|
||||
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
||||
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
||||
@@ -104,6 +105,7 @@ export default function CalendarPage() {
|
||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||
const [defaultModalAllDay, setDefaultModalAllDay] = useState(false);
|
||||
const [miniMonth, setMiniMonth] = useState(new Date());
|
||||
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||
@@ -326,11 +328,12 @@ export default function CalendarPage() {
|
||||
setSelectedDate(date);
|
||||
}, [setSelectedDate]);
|
||||
|
||||
const openCreateModal = useCallback((date?: Date, endDate?: Date) => {
|
||||
const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => {
|
||||
setEditEvent(null);
|
||||
const d = date || selectedDate;
|
||||
setDefaultModalDate(d);
|
||||
setDefaultModalEndDate(endDate);
|
||||
setDefaultModalAllDay(allDay ?? false);
|
||||
setSelectedDate(d);
|
||||
setShowEventModal(true);
|
||||
}, [selectedDate, setSelectedDate]);
|
||||
@@ -395,6 +398,21 @@ export default function CalendarPage() {
|
||||
openEventContextMenu(e, event);
|
||||
}, [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) => {
|
||||
if (isMobile) return;
|
||||
if (calendarHoverPreview === 'off') return;
|
||||
@@ -961,6 +979,7 @@ export default function CalendarPage() {
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onCreateAtTime={openCreateModal}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
isMobile={isMobile}
|
||||
@@ -978,6 +997,7 @@ export default function CalendarPage() {
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onCreateAtTime={openCreateModal}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
timeFormat={timeFormat}
|
||||
@@ -997,6 +1017,7 @@ export default function CalendarPage() {
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onCreateAtTime={openCreateModal}
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
@@ -1211,12 +1232,13 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultAllDay={defaultModalAllDay}
|
||||
defaultCalendarId={defaultCalendarIdForCreate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
onPreviewChange={setPendingPreview}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={false}
|
||||
@@ -1282,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 && (
|
||||
<EventDetailPopover
|
||||
event={detailEvent}
|
||||
@@ -1308,12 +1362,13 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultAllDay={defaultModalAllDay}
|
||||
defaultCalendarId={defaultCalendarIdForCreate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={true}
|
||||
/>
|
||||
|
||||
+134
-18
@@ -51,6 +51,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
@@ -171,6 +172,11 @@ export default function Home() {
|
||||
createMailbox,
|
||||
renameMailbox,
|
||||
deleteMailbox,
|
||||
batchDelete,
|
||||
batchArchive,
|
||||
batchMarkAsRead,
|
||||
batchMarkAsSpam,
|
||||
batchUndoSpam,
|
||||
} = useEmailStore();
|
||||
|
||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||
@@ -353,27 +359,77 @@ export default function Home() {
|
||||
onToggleStar: () => {
|
||||
if (selectedEmail) handleToggleStar();
|
||||
},
|
||||
onArchive: () => {
|
||||
if (selectedEmail) handleArchive();
|
||||
onArchive: async () => {
|
||||
if (selectedEmailIds.size > 0 && client) {
|
||||
try {
|
||||
await batchArchive(client);
|
||||
} catch (error) {
|
||||
console.error("Failed to batch archive:", error);
|
||||
}
|
||||
} else if (selectedEmail) {
|
||||
handleArchive();
|
||||
}
|
||||
},
|
||||
onDelete: () => {
|
||||
if (selectedEmail) handleDelete();
|
||||
onDelete: async () => {
|
||||
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 () => {
|
||||
if (selectedEmail && client) {
|
||||
if (!client) return;
|
||||
if (selectedEmailIds.size > 0) {
|
||||
await batchMarkAsRead(client, false);
|
||||
} else if (selectedEmail) {
|
||||
await markAsRead(client, selectedEmail.id, false);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
onToggleSpam: () => {
|
||||
if (selectedEmail) {
|
||||
// Check if we're in junk folder
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
const isInJunk = currentMailbox?.role === 'junk';
|
||||
onToggleSpam: async () => {
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
const isInJunk = currentMailbox?.role === 'junk';
|
||||
if (selectedEmailIds.size > 0 && client) {
|
||||
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) {
|
||||
handleUndoSpam();
|
||||
} else {
|
||||
@@ -408,13 +464,14 @@ export default function Home() {
|
||||
clearSelection();
|
||||
},
|
||||
// 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
|
||||
useKeyboardShortcuts({
|
||||
enabled: isAuthenticated && !showComposer,
|
||||
emails,
|
||||
selectedEmailId: selectedEmail?.id,
|
||||
selectionCount: selectedEmailIds.size,
|
||||
handlers: keyboardHandlers,
|
||||
});
|
||||
|
||||
@@ -595,11 +652,50 @@ export default function Home() {
|
||||
});
|
||||
}, [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)
|
||||
useEffect(() => {
|
||||
if (!selectedEmail || !client) return;
|
||||
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
||||
if (!selectedEmail.bodyValues) {
|
||||
// If the email lacks bodyValues, it was auto-selected from the list and needs full content.
|
||||
// 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
|
||||
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
||||
: undefined;
|
||||
@@ -694,6 +790,8 @@ export default function Home() {
|
||||
fromName?: string;
|
||||
identityId?: string;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
}) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -701,7 +799,7 @@ export default function Home() {
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
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);
|
||||
|
||||
// Mark the original email with $answered or $forwarded keyword
|
||||
@@ -1393,6 +1491,12 @@ export default function Home() {
|
||||
|
||||
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
|
||||
await sendEmail(
|
||||
client,
|
||||
@@ -1404,7 +1508,11 @@ export default function Home() {
|
||||
primaryIdentity?.id,
|
||||
primaryIdentity?.email,
|
||||
undefined,
|
||||
primaryIdentity?.name || undefined
|
||||
primaryIdentity?.name || undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
threading?.inReplyTo,
|
||||
threading?.references,
|
||||
);
|
||||
|
||||
// Mark the original email as answered
|
||||
@@ -1446,7 +1554,13 @@ export default function Home() {
|
||||
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);
|
||||
|
||||
// On mobile, switch to viewer
|
||||
@@ -1463,7 +1577,6 @@ export default function Home() {
|
||||
try {
|
||||
// In unified view each email carries its own accountId. Use that
|
||||
// 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 perAccountClient = emailAccountId
|
||||
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
||||
@@ -2055,6 +2168,9 @@ export default function Home() {
|
||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||
receivedAt: selectedEmail.receivedAt,
|
||||
attachments: selectedEmail.attachments,
|
||||
messageId: selectedEmail.messageId,
|
||||
inReplyTo: selectedEmail.inReplyTo,
|
||||
references: selectedEmail.references,
|
||||
} : undefined)}
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
KeyRound,
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
Activity,
|
||||
Mail,
|
||||
Calendar,
|
||||
BookUser,
|
||||
@@ -56,6 +57,7 @@ const NAV_GROUPS = [
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
|
||||
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
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';
|
||||
|
||||
interface Extension {
|
||||
@@ -262,10 +263,11 @@ function ExtensionCard({
|
||||
onInstall: () => void;
|
||||
}) {
|
||||
const isPlugin = extension.type === 'plugin';
|
||||
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||
<div className="p-4">
|
||||
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<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 className="min-w-0 flex-1">
|
||||
<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 && (
|
||||
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
||||
)}
|
||||
@@ -315,7 +319,7 @@ function ExtensionCard({
|
||||
</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 gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -328,27 +332,34 @@ function ExtensionCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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={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>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
|
||||
<Eye className="w-3 h-3" />
|
||||
Preview
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
|
||||
+19
-1
@@ -31,6 +31,7 @@ export default function AdminDashboardPage() {
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [themeCount, setThemeCount] = 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');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +39,7 @@ export default function AdminDashboardPage() {
|
||||
}, []);
|
||||
|
||||
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/audit?limit=10'),
|
||||
apiFetch('/api/config'),
|
||||
@@ -46,6 +47,7 @@ export default function AdminDashboardPage() {
|
||||
apiFetch('/api/admin/plugins').catch(() => null),
|
||||
apiFetch('/api/admin/themes').catch(() => null),
|
||||
apiFetch('/api/admin/policy').catch(() => null),
|
||||
apiFetch('/api/admin/telemetry').catch(() => null),
|
||||
]);
|
||||
|
||||
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;
|
||||
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) {
|
||||
try {
|
||||
@@ -165,6 +173,16 @@ export default function AdminDashboardPage() {
|
||||
</SettingItem>
|
||||
</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 */}
|
||||
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
||||
<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' },
|
||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||
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' },
|
||||
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' },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -53,11 +53,19 @@ export async function GET(
|
||||
|
||||
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, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||
'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 {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
|
||||
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
|
||||
@@ -232,7 +232,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
|
||||
@@ -139,7 +139,7 @@ export async function POST(request: NextRequest) {
|
||||
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.',
|
||||
error: 'Stalwart denied OAuthClient/query - your Stalwart account likely lacks admin permissions.',
|
||||
detail: queryEntry?.[1],
|
||||
}, { status: 403 });
|
||||
}
|
||||
@@ -187,7 +187,7 @@ export async function POST(request: NextRequest) {
|
||||
const setEntry = setRes.methodResponses?.[0];
|
||||
if (!setEntry || setEntry[0] === 'error') {
|
||||
return NextResponse.json({
|
||||
error: 'Stalwart denied OAuthClient/set — admin permissions required.',
|
||||
error: 'Stalwart denied OAuthClient/set - admin permissions required.',
|
||||
detail: setEntry?.[1],
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -2,15 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||
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.
|
||||
* This endpoint is accessible from the client-side plugin API.
|
||||
* - Admin sessions receive every field, including those declared
|
||||
* `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(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
@@ -20,13 +25,34 @@ export async function GET(
|
||||
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);
|
||||
if (!plugin) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
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' },
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
setStalwartAuthContextInStore,
|
||||
} 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 = {
|
||||
...getCookieOptions(),
|
||||
@@ -37,10 +39,37 @@ export async function POST(request: NextRequest) {
|
||||
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 cookieName = sessionCookieName(slot);
|
||||
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 cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||
@@ -50,6 +79,8 @@ export async function POST(request: NextRequest) {
|
||||
authHeader,
|
||||
});
|
||||
|
||||
void recordLogin(username, normalizedServerUrl);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
|
||||
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
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 {
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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 normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
||||
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
||||
|
||||
await setStalwartAuthContext(slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
@@ -32,6 +62,8 @@ export async function POST(request: NextRequest) {
|
||||
authHeader,
|
||||
});
|
||||
|
||||
void recordLogin(username, normalizedServerUrl);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
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.
|
||||
@@ -84,22 +86,39 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||
|
||||
// Use the server-side JMAP_SERVER_URL if set (may differ from the
|
||||
// public URL the browser uses, e.g. inside Docker).
|
||||
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
|
||||
// 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 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);
|
||||
if (!tokenEndpoint) {
|
||||
// Also try with the client-provided URL in case the internal one differs
|
||||
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
|
||||
if (!clientEndpoint) {
|
||||
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
let upstreamUrl: string;
|
||||
if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||
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) {
|
||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
@@ -108,6 +127,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
async function attemptAllStrategies(
|
||||
tokenEndpoint: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
slot: number,
|
||||
@@ -126,6 +146,7 @@ async function attemptAllStrategies(
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||
@@ -137,6 +158,7 @@ async function attemptAllStrategies(
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||
@@ -148,6 +170,7 @@ async function attemptAllStrategies(
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
}
|
||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||
@@ -159,6 +182,7 @@ async function attemptAllStrategies(
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
}
|
||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
@@ -680,3 +705,54 @@ body {
|
||||
.tiptap .ProseMirror-selectednode img {
|
||||
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;
|
||||
onHoverLeave?: () => 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;
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
@@ -39,6 +40,7 @@ export function CalendarDayView({
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onContextMenuEmpty,
|
||||
onCreateAtTime,
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
@@ -144,7 +146,13 @@ export function CalendarDayView({
|
||||
</div>
|
||||
|
||||
{(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 && (
|
||||
<>
|
||||
<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)}
|
||||
onClick={() => handleSlotClick(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"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
/>
|
||||
|
||||
@@ -24,6 +24,7 @@ interface CalendarMonthViewProps {
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||
onCreateAtTime?: (date: Date) => void;
|
||||
firstDayOfWeek?: number;
|
||||
isMobile?: boolean;
|
||||
@@ -39,6 +40,7 @@ export function CalendarMonthView({
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onContextMenuEmpty,
|
||||
onCreateAtTime,
|
||||
firstDayOfWeek = 1,
|
||||
isMobile,
|
||||
@@ -174,6 +176,7 @@ export function CalendarMonthView({
|
||||
aria-label={fullDateLabel}
|
||||
onClick={() => onSelectDate(day)}
|
||||
onDoubleClick={() => onCreateAtTime?.(day)}
|
||||
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
|
||||
onDragOver={(e) => handleCellDragOver(e, key)}
|
||||
onDragLeave={handleCellDragLeave}
|
||||
onDrop={(e) => handleCellDrop(e, day)}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface CalendarWeekViewProps {
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => 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;
|
||||
firstDayOfWeek?: number;
|
||||
timeFormat?: "12h" | "24h";
|
||||
@@ -44,6 +45,7 @@ export function CalendarWeekView({
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onContextMenuEmpty,
|
||||
onCreateAtTime,
|
||||
firstDayOfWeek = 1,
|
||||
timeFormat = "24h",
|
||||
@@ -219,7 +221,11 @@ export function CalendarWeekView({
|
||||
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||
>
|
||||
{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">
|
||||
@@ -378,6 +384,7 @@ export function CalendarWeekView({
|
||||
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
|
||||
onClick={() => handleSlotClick(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"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
getParticipantList,
|
||||
} from "@/lib/calendar-participants";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
|
||||
interface EventDetailPopoverProps {
|
||||
event: CalendarEvent;
|
||||
@@ -253,6 +254,8 @@ export function EventDetailPopover({
|
||||
|
||||
const hasParticipants = participants.length > 0;
|
||||
|
||||
const formatEventDate = useFormatEventDate();
|
||||
|
||||
const popover = (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
@@ -329,7 +332,7 @@ export function EventDetailPopover({
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{format(startDate, "EEE, MMM d, yyyy")}
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
|
||||
+111
-107
@@ -21,6 +21,7 @@ import {
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -35,6 +36,7 @@ interface EventModalProps {
|
||||
calendars: Calendar[];
|
||||
defaultDate?: Date;
|
||||
defaultEndDate?: Date;
|
||||
defaultAllDay?: boolean;
|
||||
defaultCalendarId?: string;
|
||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||
@@ -114,6 +116,7 @@ export function EventModal({
|
||||
calendars,
|
||||
defaultDate,
|
||||
defaultEndDate,
|
||||
defaultAllDay,
|
||||
defaultCalendarId,
|
||||
onSave,
|
||||
onDelete,
|
||||
@@ -128,6 +131,7 @@ export function EventModal({
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
const isEdit = !!event;
|
||||
const formatEventDate = useFormatEventDate();
|
||||
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
||||
|
||||
const userIsOrganizer = useMemo(() => {
|
||||
@@ -199,7 +203,7 @@ export function EventModal({
|
||||
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||
const [endDate, setEndDate] = useState(formatDateInput(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>(() => {
|
||||
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
|
||||
@@ -493,14 +497,14 @@ export function EventModal({
|
||||
|
||||
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 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>
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
<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" />
|
||||
</button>
|
||||
</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="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" />
|
||||
@@ -515,7 +519,7 @@ export function EventModal({
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
@@ -548,48 +552,48 @@ export function EventModal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("accepted")}
|
||||
className={userCurrentStatus === "accepted"
|
||||
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||
: "text-success border-success/30 hover:bg-success/10"}
|
||||
>
|
||||
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.accepted")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("tentative")}
|
||||
className={userCurrentStatus === "tentative"
|
||||
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
||||
>
|
||||
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.tentative")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
||||
onClick={() => handleRsvp("declined")}
|
||||
className={userCurrentStatus === "declined"
|
||||
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||
: "text-destructive hover:bg-destructive/10"}
|
||||
>
|
||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.declined")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("accepted")}
|
||||
className={userCurrentStatus === "accepted"
|
||||
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||
: "text-success border-success/30 hover:bg-success/10"}
|
||||
>
|
||||
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.accepted")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||
onClick={() => handleRsvp("tentative")}
|
||||
className={userCurrentStatus === "tentative"
|
||||
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
||||
>
|
||||
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.tentative")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
||||
onClick={() => handleRsvp("declined")}
|
||||
className={userCurrentStatus === "declined"
|
||||
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||
: "text-destructive hover:bg-destructive/10"}
|
||||
>
|
||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
||||
{t("participants.declined")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -636,7 +640,7 @@ export function EventModal({
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{format(startD, "EEE, MMM d, yyyy")}
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
@@ -765,16 +769,16 @@ export function EventModal({
|
||||
|
||||
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 className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEdit ? t("events.edit") : t("events.create")}
|
||||
</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")}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEdit ? t("events.edit") : t("events.create")}
|
||||
</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")}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</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>
|
||||
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
|
||||
@@ -984,69 +988,69 @@ export function EventModal({
|
||||
</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 gap-1">
|
||||
{isEdit && onDelete && (
|
||||
showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{t("form.delete_confirm")}
|
||||
</span>
|
||||
{hasParticipants && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("participants.cancel_notification")}
|
||||
</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 className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{isEdit && onDelete && (
|
||||
showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{t("form.delete_confirm")}
|
||||
</span>
|
||||
{hasParticipants && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("participants.cancel_notification")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-red-600 dark:text-red-400"
|
||||
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||
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")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{isEdit && onDuplicate && !showDeleteConfirm && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDuplicate}
|
||||
aria-label={t("events.duplicate")}
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-red-600 dark:text-red-400"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-1" />
|
||||
{t("events.duplicate")}
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
||||
{t("form.cancel")}
|
||||
)
|
||||
)}
|
||||
{isEdit && onDuplicate && !showDeleteConfirm && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDuplicate}
|
||||
aria-label={t("events.duplicate")}
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-1" />
|
||||
{t("events.duplicate")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
|
||||
{t("form.save")}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
|
||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||
@@ -67,6 +68,8 @@ interface EmailComposerProps {
|
||||
fromName?: string;
|
||||
identityId?: string;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
}) => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
onDiscardDraft?: (draftId: string) => void;
|
||||
@@ -87,6 +90,11 @@ interface EmailComposerProps {
|
||||
receivedAt?: string;
|
||||
accountId?: 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 timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||
@@ -721,7 +730,7 @@ export function EmailComposer({
|
||||
// Generate sub-addressed email if tag is set
|
||||
const fromEmail = currentIdentity?.email
|
||||
? subAddressTag
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag)
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||
: currentIdentity.email
|
||||
: undefined;
|
||||
|
||||
@@ -736,7 +745,8 @@ export function EmailComposer({
|
||||
fromEmail,
|
||||
draftId || undefined,
|
||||
uploadedAttachments,
|
||||
currentIdentity?.name || undefined
|
||||
currentIdentity?.name || undefined,
|
||||
plainTextMode ? undefined : body
|
||||
);
|
||||
|
||||
setDraftId(savedDraftId);
|
||||
@@ -812,19 +822,27 @@ export function EmailComposer({
|
||||
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
|
||||
} => {
|
||||
const known = inlineImagesRef.current;
|
||||
if (known.length === 0) return { html, attachments: [] };
|
||||
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
||||
const used = new Map<string, typeof known[number]>();
|
||||
|
||||
doc.querySelectorAll('img[data-cid]').forEach((img) => {
|
||||
const cid = img.getAttribute('data-cid');
|
||||
if (!cid) return;
|
||||
const entry = known.find((e) => e.cid === cid);
|
||||
if (!entry) return;
|
||||
img.setAttribute('src', `cid:${cid}`);
|
||||
img.removeAttribute('data-cid');
|
||||
used.set(cid, entry);
|
||||
if (known.length > 0) {
|
||||
doc.querySelectorAll('img[data-cid]').forEach((img) => {
|
||||
const cid = img.getAttribute('data-cid');
|
||||
if (!cid) return;
|
||||
const entry = known.find((e) => e.cid === cid);
|
||||
if (!entry) return;
|
||||
img.setAttribute('src', `cid:${cid}`);
|
||||
img.removeAttribute('data-cid');
|
||||
used.set(cid, entry);
|
||||
});
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -889,7 +907,7 @@ export function EmailComposer({
|
||||
|
||||
const fromEmail = currentIdentity?.email
|
||||
? subAddressTag
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag)
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||
: currentIdentity.email
|
||||
: undefined;
|
||||
|
||||
@@ -905,6 +923,11 @@ export function EmailComposer({
|
||||
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)
|
||||
const finalBody = plainTextMode
|
||||
? appendPlainTextSignature(body, currentIdentity)
|
||||
@@ -968,12 +991,22 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
// 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({
|
||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||
to: toAddresses.map(e => ({ email: e })),
|
||||
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
||||
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
|
||||
subject,
|
||||
inReplyTo: mimeInReplyTo,
|
||||
references: mimeReferences,
|
||||
textBody: finalBody,
|
||||
htmlBody: finalHtmlBody,
|
||||
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
|
||||
@@ -986,6 +1019,8 @@ export function EmailComposer({
|
||||
to: toAddresses.map(e => ({ email: e })),
|
||||
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
||||
subject,
|
||||
inReplyTo: mimeInReplyTo,
|
||||
references: mimeReferences,
|
||||
};
|
||||
|
||||
// 5. Sign if enabled
|
||||
@@ -1042,6 +1077,8 @@ export function EmailComposer({
|
||||
fromName: currentIdentity?.name || undefined,
|
||||
identityId: currentIdentity?.id,
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||
inReplyTo: threadingHeaders?.inReplyTo,
|
||||
references: threadingHeaders?.references,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1179,7 +1216,7 @@ export function EmailComposer({
|
||||
>
|
||||
{identities.map((identity) => {
|
||||
const displayEmail = subAddressTag
|
||||
? generateSubAddress(identity.email, subAddressTag)
|
||||
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
|
||||
: identity.email;
|
||||
return (
|
||||
<option key={identity.id} value={identity.id}>
|
||||
@@ -1192,7 +1229,7 @@ export function EmailComposer({
|
||||
<span className="text-sm text-foreground flex-1 truncate">
|
||||
{subAddressTag ? (
|
||||
<span className="font-mono">
|
||||
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
|
||||
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Email, Identity } from '@/lib/jmap/types';
|
||||
import { parseSubAddress } from '@/lib/sub-addressing';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
interface EmailIdentityBadgeProps {
|
||||
email: Email;
|
||||
@@ -20,12 +21,13 @@ export function EmailIdentityBadge({
|
||||
className,
|
||||
}: EmailIdentityBadgeProps) {
|
||||
const t = useTranslations('identities.badge');
|
||||
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||
|
||||
const fromAddress = email.from?.[0]?.email;
|
||||
if (!fromAddress) return null;
|
||||
|
||||
// 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)
|
||||
const matchingIdentity = identities.find(
|
||||
@@ -37,7 +39,7 @@ export function EmailIdentityBadge({
|
||||
if (!matchingIdentity) {
|
||||
// Check all TO addresses for sub-address tags matching user's identities
|
||||
for (const recipient of email.to || []) {
|
||||
const parsedTo = parseSubAddress(recipient.email);
|
||||
const parsedTo = parseSubAddress(recipient.email, subAddressDelimiter);
|
||||
if (parsedTo.tag) {
|
||||
// Check if this base email matches any of the user's identities
|
||||
const matchingToIdentity = identities.find(
|
||||
@@ -70,7 +72,7 @@ export function EmailIdentityBadge({
|
||||
title={t('sub_address_tag', { tag: displayTag })}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
<span className="font-mono">+{displayTag}</span>
|
||||
<span className="font-mono">{subAddressDelimiter}{displayTag}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +116,7 @@ export function EmailIdentityBadge({
|
||||
aria-label={t('sub_address_tag', { tag: displayTag })}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
<span className="font-mono">{t('subaddress_tag', { tag: displayTag })}</span>
|
||||
<span className="font-mono">{subAddressDelimiter}{displayTag}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2285,7 +2285,7 @@ export function EmailViewer({
|
||||
|
||||
// Sanitize and prepare email HTML content
|
||||
const emailContent = useMemo(() => {
|
||||
if (!email) return { html: "", isHtml: false };
|
||||
if (!email) return { html: "", isHtml: false, hasStyleTag: false };
|
||||
|
||||
// Check if we have body values
|
||||
if (email.bodyValues) {
|
||||
@@ -2338,8 +2338,7 @@ export function EmailViewer({
|
||||
);
|
||||
|
||||
if (shouldBlockExternal) {
|
||||
sanitizeConfig.FORBID_TAGS.push('link');
|
||||
sanitizeConfig.FORBID_ATTR.push('background');
|
||||
sanitizeConfig.FORBID_TAGS = [...sanitizeConfig.FORBID_TAGS, 'link'];
|
||||
}
|
||||
|
||||
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) {
|
||||
const style = htmlNode.style.cssText;
|
||||
if (style && style.includes('url(')) {
|
||||
const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
|
||||
if (urlMatch) {
|
||||
node.setAttribute('data-blocked-style', style);
|
||||
htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
|
||||
blockedExternalContent = true;
|
||||
}
|
||||
@@ -2395,7 +2402,8 @@ export function EmailViewer({
|
||||
|
||||
return {
|
||||
html: cleanHtml,
|
||||
isHtml: true
|
||||
isHtml: true,
|
||||
hasStyleTag: /<style[\s>]/i.test(htmlContent),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2405,7 +2413,8 @@ export function EmailViewer({
|
||||
|
||||
return {
|
||||
html: plainTextToSafeHtml(textContent),
|
||||
isHtml: false
|
||||
isHtml: false,
|
||||
hasStyleTag: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2419,15 +2428,21 @@ export function EmailViewer({
|
||||
|
||||
return {
|
||||
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
|
||||
isHtml: false
|
||||
isHtml: false,
|
||||
hasStyleTag: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
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
|
||||
const effectiveEmailContent = useMemo(() => {
|
||||
@@ -2439,26 +2454,26 @@ export function EmailViewer({
|
||||
}
|
||||
);
|
||||
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) {
|
||||
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false };
|
||||
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false };
|
||||
}
|
||||
// TNEF (winmail.dat) extracted content
|
||||
if (tnefHtml) {
|
||||
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) {
|
||||
return { html: plainTextToSafeHtml(tnefText), isHtml: false };
|
||||
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false };
|
||||
}
|
||||
// Embedded message/rfc822 unwrapped content
|
||||
if (embeddedEmailHtml) {
|
||||
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) {
|
||||
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false };
|
||||
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false };
|
||||
}
|
||||
return emailContent;
|
||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||
@@ -2600,10 +2615,14 @@ export function EmailViewer({
|
||||
|
||||
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>
|
||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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; }
|
||||
a { color: #1a73e8; }
|
||||
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>`;
|
||||
}, [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 iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
@@ -2623,9 +2715,13 @@ export function EmailViewer({
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const height = doc.documentElement.scrollHeight;
|
||||
iframe.style.height = height + 'px';
|
||||
lastBodyHeightRef.current = height;
|
||||
});
|
||||
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
|
||||
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 */}
|
||||
<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 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -3629,15 +3720,6 @@ export function EmailViewer({
|
||||
)}
|
||||
{/* Main email content */}
|
||||
<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) === */}
|
||||
{toolbarPosition === 'top' && (
|
||||
<div className={cn(
|
||||
@@ -4634,14 +4716,28 @@ export function EmailViewer({
|
||||
<PluginSlot name="email-banner" extraProps={{ email }} />
|
||||
|
||||
{/* Email Body */}
|
||||
<div className="email-content-wrapper overflow-x-auto">
|
||||
{effectiveEmailContent.isHtml ? (
|
||||
<div className={cn(
|
||||
"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
|
||||
ref={iframeRef}
|
||||
srcDoc={emailIframeSrcDoc}
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
title="Email content"
|
||||
className="w-full border-0 rounded"
|
||||
className="w-full border-0"
|
||||
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
@@ -4662,8 +4758,8 @@ export function EmailViewer({
|
||||
|
||||
<PluginSlot name="email-footer" />
|
||||
|
||||
{/* Quick Reply Section - hidden for drafts */}
|
||||
{!isDraft && (<div className={cn(
|
||||
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn(
|
||||
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
||||
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
|
||||
)}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback } from "react";
|
||||
import React, { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
@@ -10,6 +10,10 @@ import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import Color from "@tiptap/extension-color";
|
||||
import { ResizableImage } from "@/components/email/resizable-image";
|
||||
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 {
|
||||
Bold,
|
||||
@@ -29,6 +33,10 @@ import {
|
||||
RemoveFormatting,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Table as TableIcon,
|
||||
Trash2,
|
||||
Rows3,
|
||||
Columns3,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface InlineImageUpload {
|
||||
@@ -79,6 +87,43 @@ function ToolbarSeparator() {
|
||||
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({
|
||||
content,
|
||||
onChange,
|
||||
@@ -109,6 +154,27 @@ export function RichTextEditor({
|
||||
Placeholder.configure({
|
||||
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,
|
||||
editorProps: {
|
||||
@@ -188,6 +254,20 @@ export function RichTextEditor({
|
||||
.run();
|
||||
}, [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) {
|
||||
return (
|
||||
<div className={cn("min-h-[100px]", className)} />
|
||||
@@ -309,6 +389,89 @@ export function RichTextEditor({
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</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 />
|
||||
|
||||
<ToolbarButton
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import {
|
||||
generateSubAddress,
|
||||
extractDomain,
|
||||
@@ -35,6 +36,7 @@ export function SubAddressHelper({
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore();
|
||||
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||
|
||||
// Get suggestions based on recipient (memoized for performance)
|
||||
const suggestions = useMemo(() => {
|
||||
@@ -47,7 +49,7 @@ export function SubAddressHelper({
|
||||
}, [recipientEmails]);
|
||||
|
||||
// Generate preview
|
||||
const preview = tag ? generateSubAddress(baseEmail, tag) : baseEmail;
|
||||
const preview = tag ? generateSubAddress(baseEmail, tag, subAddressDelimiter) : baseEmail;
|
||||
|
||||
// Close popover when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -226,7 +228,7 @@ export function SubAddressHelper({
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="mb-3 text-xs text-muted-foreground">
|
||||
{t('help_text')}
|
||||
{t('help_text', { delimiter: subAddressDelimiter })}
|
||||
</div>
|
||||
|
||||
{/* Use Address Button */}
|
||||
|
||||
@@ -335,7 +335,7 @@ export function NavigationRail({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Admin (Stalwart admins) — hard nav because /admin lives outside the [locale] tree */}
|
||||
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
href="/admin"
|
||||
|
||||
@@ -16,12 +16,13 @@ import nlMessages from '@/locales/nl/common.json';
|
||||
import plMessages from '@/locales/pl/common.json';
|
||||
import ptMessages from '@/locales/pt/common.json';
|
||||
import ruMessages from '@/locales/ru/common.json';
|
||||
import trMessages from '@/locales/tr/common.json';
|
||||
import ukMessages from '@/locales/uk/common.json';
|
||||
import zhMessages from '@/locales/zh/common.json';
|
||||
|
||||
// Pre-loaded translations (loaded at build time, not runtime)
|
||||
const ALL_MESSAGES = {
|
||||
cs: csMessages,
|
||||
cs: csMessages,
|
||||
en: enMessages,
|
||||
fr: frMessages,
|
||||
ja: jaMessages,
|
||||
@@ -34,6 +35,7 @@ const ALL_MESSAGES = {
|
||||
pl: plMessages,
|
||||
pt: ptMessages,
|
||||
ru: ruMessages,
|
||||
tr: trMessages,
|
||||
uk: ukMessages,
|
||||
zh: zhMessages,
|
||||
};
|
||||
|
||||
@@ -4,8 +4,16 @@ import { useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
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 {
|
||||
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
||||
isSupportedSubAddressDelimiter,
|
||||
isValidSubAddressDelimiter,
|
||||
} from '@/lib/sub-addressing';
|
||||
|
||||
const CUSTOM_DELIMITER_SENTINEL = '__custom__';
|
||||
const DEFAULT_CUSTOM_DELIMITER = '~';
|
||||
|
||||
export function ComposingSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
@@ -17,6 +25,7 @@ export function ComposingSettings() {
|
||||
autoSelectReplyIdentity,
|
||||
attachmentReminderEnabled,
|
||||
attachmentReminderKeywords,
|
||||
subAddressDelimiter,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
@@ -40,6 +49,49 @@ export function ComposingSettings() {
|
||||
/>
|
||||
</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')}>
|
||||
<ToggleSwitch
|
||||
checked={attachmentReminderEnabled}
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
||||
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
|
||||
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
||||
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 { 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() {
|
||||
const t = useTranslations('settings.notifications');
|
||||
@@ -21,6 +40,77 @@ export function NotificationSettings() {
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
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) => ({
|
||||
value: s.id,
|
||||
@@ -29,6 +119,46 @@ export function NotificationSettings() {
|
||||
|
||||
return (
|
||||
<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')}>
|
||||
<SettingItem
|
||||
label={t('sound_selection.choose')}
|
||||
@@ -118,6 +248,45 @@ export function NotificationSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</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>;
|
||||
}
|
||||
|
||||
@@ -152,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 */
|
||||
export function FlagUA(props: FlagProps) {
|
||||
return (
|
||||
@@ -192,6 +204,7 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
||||
pl: FlagPL,
|
||||
pt: FlagBR,
|
||||
ru: FlagRU,
|
||||
tr: FlagTR,
|
||||
uk: FlagUA,
|
||||
zh: FlagCN,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { cn } from '@/lib/utils';
|
||||
import { flagComponents } from './flag-icons';
|
||||
|
||||
const languages = [
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
@@ -21,6 +21,7 @@ const languages = [
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'tr', label: 'Türkçe' },
|
||||
{ value: 'uk', label: 'Українська' },
|
||||
{ value: 'zh', label: '简体中文' },
|
||||
];
|
||||
|
||||
@@ -13,6 +13,9 @@ services:
|
||||
- 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:
|
||||
test:
|
||||
[
|
||||
@@ -32,3 +35,4 @@ services:
|
||||
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;
|
||||
emails: Email[];
|
||||
selectedEmailId?: string;
|
||||
selectionCount?: number;
|
||||
handlers: KeyboardShortcutHandlers;
|
||||
}
|
||||
|
||||
@@ -58,6 +59,7 @@ export function useKeyboardShortcuts({
|
||||
enabled = true,
|
||||
emails,
|
||||
selectedEmailId,
|
||||
selectionCount = 0,
|
||||
handlers,
|
||||
}: UseKeyboardShortcutsOptions) {
|
||||
const handlersRef = useRef(handlers);
|
||||
@@ -90,6 +92,8 @@ export function useKeyboardShortcuts({
|
||||
// Shortcuts that should NOT work with modifiers
|
||||
if (hasModifier) return;
|
||||
|
||||
const hasBatchTarget = !!selectedEmailId || selectionCount > 0;
|
||||
|
||||
switch (key) {
|
||||
// Navigation
|
||||
case "j":
|
||||
@@ -152,7 +156,7 @@ export function useKeyboardShortcuts({
|
||||
break;
|
||||
|
||||
case "e":
|
||||
if (selectedEmailId) {
|
||||
if (hasBatchTarget) {
|
||||
event.preventDefault();
|
||||
h.onArchive?.();
|
||||
}
|
||||
@@ -161,28 +165,28 @@ export function useKeyboardShortcuts({
|
||||
case "#":
|
||||
case "delete":
|
||||
case "backspace":
|
||||
if (selectedEmailId && (key === "#" || key === "delete" || key === "backspace")) {
|
||||
if (hasBatchTarget) {
|
||||
event.preventDefault();
|
||||
h.onDelete?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "u":
|
||||
if (selectedEmailId) {
|
||||
if (hasBatchTarget) {
|
||||
event.preventDefault();
|
||||
h.onMarkAsUnread?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "i":
|
||||
if (selectedEmailId && event.shiftKey) {
|
||||
if (hasBatchTarget && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
h.onMarkAsRead?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "!":
|
||||
if (selectedEmailId) {
|
||||
if (hasBatchTarget) {
|
||||
event.preventDefault();
|
||||
h.onToggleSpam?.();
|
||||
}
|
||||
@@ -220,7 +224,7 @@ export function useKeyboardShortcuts({
|
||||
break;
|
||||
}
|
||||
},
|
||||
[selectedEmailId]
|
||||
[selectedEmailId, selectionCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -47,6 +47,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
case 'ru':
|
||||
messages = (await import('../locales/ru/common.json')).default;
|
||||
break;
|
||||
case 'tr':
|
||||
messages = (await import('../locales/tr/common.json')).default;
|
||||
break;
|
||||
case 'uk':
|
||||
messages = (await import('../locales/uk/common.json')).default;
|
||||
break;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'as-needed';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['cs', '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',
|
||||
localePrefix
|
||||
});
|
||||
|
||||
@@ -51,6 +51,14 @@ configManager.load()
|
||||
.then(() => {
|
||||
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) => {
|
||||
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -28,23 +28,11 @@ describe('extractTheme', () => {
|
||||
});
|
||||
|
||||
it('rejects oversized theme', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
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');
|
||||
// Theme size limit is 2 MB; create a file just past it.
|
||||
const oversizedFile = new File([new ArrayBuffer(2 * 1024 * 1024 + 1)], 'big.zip');
|
||||
const result = await extractTheme(oversizedFile);
|
||||
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 () => {
|
||||
@@ -139,6 +127,129 @@ describe('extractTheme', () => {
|
||||
expect(result.valid).toBe(true);
|
||||
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', () => {
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
suggestTagsForDomain,
|
||||
isValidTag,
|
||||
getTagValidationError,
|
||||
isSupportedSubAddressDelimiter,
|
||||
isValidSubAddressDelimiter,
|
||||
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
||||
DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
MAX_TAG_LENGTH,
|
||||
} from '../sub-addressing';
|
||||
|
||||
@@ -354,3 +358,122 @@ describe('getTagValidationError', () => {
|
||||
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 {
|
||||
sanitizeThemeCSS,
|
||||
sanitizeSkinCSS,
|
||||
validateThemeSelectors,
|
||||
injectThemeCSS,
|
||||
removeThemeCSS,
|
||||
injectThemeSkinCSS,
|
||||
removeThemeSkinCSS,
|
||||
validateThemeCSSSafety,
|
||||
} 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', () => {
|
||||
it('accepts valid theme CSS', () => {
|
||||
const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }';
|
||||
|
||||
@@ -138,4 +138,38 @@ describe('verifyJmapAuth SSRF protection', () => {
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
||||
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
||||
|
||||
function getAdminDir(): string {
|
||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
||||
@@ -35,6 +35,7 @@ class ConfigManager {
|
||||
this.policyCache = {
|
||||
...DEFAULT_POLICY,
|
||||
...policy,
|
||||
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||
};
|
||||
} else {
|
||||
@@ -143,7 +144,12 @@ class ConfigManager {
|
||||
* Update the settings policy. Writes to disk.
|
||||
*/
|
||||
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>);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 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
|
||||
* depth - any malformed value is dropped so a corrupted registry can never
|
||||
* inject arbitrary CSP fragments.
|
||||
*/
|
||||
|
||||
@@ -65,7 +65,7 @@ const CACHE_TTL_MS = 5_000;
|
||||
* 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.
|
||||
* a broken registry only ever shrinks the CSP - never widens it.
|
||||
*/
|
||||
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface FeatureGates {
|
||||
folderIconsEnabled: boolean;
|
||||
hoverActionsConfigEnabled: boolean;
|
||||
filesEnabled: boolean;
|
||||
contactsEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
@@ -58,6 +59,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
folderIconsEnabled: true,
|
||||
hoverActionsConfigEnabled: true,
|
||||
filesEnabled: true,
|
||||
contactsEnabled: true,
|
||||
};
|
||||
|
||||
export interface ThemePolicy {
|
||||
|
||||
@@ -40,11 +40,15 @@ 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);
|
||||
validateProxyAuthHeader(authHeader);
|
||||
|
||||
if (!(await isPublicHttpUrl(normalizedServerUrl))) {
|
||||
if (!options.trusted && !(await isPublicHttpUrl(normalizedServerUrl))) {
|
||||
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
|
||||
}
|
||||
|
||||
@@ -56,7 +60,7 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
if (!(await isPublicHttpUrl(currentUrl))) {
|
||||
if (!options.trusted && !(await isPublicHttpUrl(currentUrl))) {
|
||||
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
|
||||
}
|
||||
|
||||
|
||||
+24
-3
@@ -95,6 +95,18 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
getLastStates(): AccountStates { return { ...this.lastStates }; }
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
async getQuota(): Promise<{ used: number; total: number } | null> {
|
||||
@@ -383,6 +395,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
draftId?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
_fromName?: string,
|
||||
htmlBody?: string,
|
||||
): Promise<string> {
|
||||
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
|
||||
const id = draftId || generateDemoId('email');
|
||||
@@ -402,9 +415,13 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
sentAt: new Date().toISOString(),
|
||||
preview: body.substring(0, 200),
|
||||
hasAttachment: !!attachments?.length,
|
||||
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
bodyValues: { '1': { value: body } },
|
||||
textBody: [{ partId: htmlBody ? 'text' : '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
|
||||
htmlBody: htmlBody
|
||||
? [{ 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') })),
|
||||
messageId: `<${id}@demo.example.com>`,
|
||||
};
|
||||
@@ -430,6 +447,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
_fromName?: string,
|
||||
htmlBody?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
inReplyTo?: string[],
|
||||
references?: string[],
|
||||
): Promise<void> {
|
||||
// Remove draft if updating
|
||||
if (draftId) {
|
||||
@@ -455,6 +474,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } },
|
||||
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
|
||||
messageId: `<${generateDemoId('msg')}@demo.example.com>`,
|
||||
inReplyTo: inReplyTo?.length ? inReplyTo : undefined,
|
||||
references: references?.length ? references : undefined,
|
||||
};
|
||||
this.data.emails.push(email);
|
||||
this.recalcMailboxCounts();
|
||||
|
||||
@@ -151,6 +151,7 @@ export function collapseBlockedImageContainers(html: string): string {
|
||||
const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
|
||||
const hasLinks = el.querySelector('a[href]');
|
||||
if (!hasVisibleText && !hasVisibleMedia && !hasLinks) {
|
||||
el.setAttribute('data-blocked-collapsed-style', el.style.cssText);
|
||||
el.style.display = 'none';
|
||||
el.style.height = '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, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal } 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";
|
||||
|
||||
/**
|
||||
@@ -51,6 +51,20 @@ export interface IJMAPClient {
|
||||
getLastStates(): AccountStates;
|
||||
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 ─────────────────────────────────────────────────────
|
||||
getQuota(): Promise<{ used: number; total: number } | null>;
|
||||
|
||||
@@ -115,6 +129,7 @@ export interface IJMAPClient {
|
||||
draftId?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
fromName?: string,
|
||||
htmlBody?: string,
|
||||
): Promise<string>;
|
||||
|
||||
sendEmail(
|
||||
@@ -129,6 +144,8 @@ export interface IJMAPClient {
|
||||
fromName?: string,
|
||||
htmlBody?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
inReplyTo?: string[],
|
||||
references?: string[],
|
||||
): Promise<void>;
|
||||
|
||||
sendImipReply(opts: {
|
||||
|
||||
+104
-6
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types";
|
||||
import type { 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 { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -294,6 +294,12 @@ function foldIcsLine(line: string): string {
|
||||
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 {
|
||||
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
||||
|
||||
@@ -1933,7 +1939,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
fromEmail?: string,
|
||||
draftId?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
fromName?: string
|
||||
fromName?: string,
|
||||
htmlBody?: string
|
||||
): Promise<string> {
|
||||
const mailboxes = await this.getMailboxes();
|
||||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||||
@@ -1952,7 +1959,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
keywords: Record<string, boolean>;
|
||||
mailboxIds: Record<string, boolean>;
|
||||
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 }[];
|
||||
}
|
||||
|
||||
@@ -1964,8 +1972,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
subject,
|
||||
keywords: { "$draft": true },
|
||||
mailboxIds: { [draftsMailbox.id]: true },
|
||||
bodyValues: { "1": { value: body } },
|
||||
textBody: [{ partId: "1" }],
|
||||
bodyValues: htmlBody
|
||||
? { "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) {
|
||||
@@ -2027,7 +2040,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
draftId?: string,
|
||||
fromName?: 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> {
|
||||
const emailId = `send-${Date.now()}`;
|
||||
const mailboxes = await this.getMailboxes();
|
||||
@@ -2067,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
|
||||
const emailCreate: Record<string, unknown> = {
|
||||
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
|
||||
@@ -2075,6 +2095,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
cc: cc?.map(email => ({ email })),
|
||||
bcc: bcc?.map(email => ({ email })),
|
||||
subject,
|
||||
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
|
||||
references: normalizedReferences?.length ? normalizedReferences : undefined,
|
||||
keywords: { "$seen": true, "$draft": true },
|
||||
mailboxIds: { [draftsMailbox.id]: true },
|
||||
};
|
||||
@@ -5291,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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -7,8 +7,21 @@ export interface OAuthMetadata {
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const CACHE_MAX_ENTRIES = 64;
|
||||
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> {
|
||||
const cached = metadataCache.get(serverUrl);
|
||||
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,
|
||||
end_session_endpoint: data.end_session_endpoint,
|
||||
};
|
||||
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
rememberMetadata(serverUrl, metadata);
|
||||
return metadata;
|
||||
}
|
||||
errors.push(`${url} response missing required endpoints`);
|
||||
|
||||
@@ -365,6 +365,18 @@ export const themeHooks = {
|
||||
onThemeChange: new HookBus(),
|
||||
onCustomThemeChange: 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
|
||||
|
||||
+18
-1
@@ -1,9 +1,11 @@
|
||||
// IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews)
|
||||
|
||||
const DB_NAME = 'bulwark-plugins';
|
||||
const DB_VERSION = 1;
|
||||
// Bumped to 2 to add the theme-skin store; existing stores are preserved.
|
||||
const DB_VERSION = 2;
|
||||
const STORE_PLUGINS = 'plugin-code';
|
||||
const STORE_THEMES = 'theme-css';
|
||||
const STORE_THEME_SKINS = 'theme-skin';
|
||||
const STORE_PREVIEWS = 'previews';
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
@@ -18,6 +20,9 @@ function openDB(): Promise<IDBDatabase> {
|
||||
if (!db.objectStoreNames.contains(STORE_THEMES)) {
|
||||
db.createObjectStore(STORE_THEMES);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_THEME_SKINS)) {
|
||||
db.createObjectStore(STORE_THEME_SKINS);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_PREVIEWS)) {
|
||||
db.createObjectStore(STORE_PREVIEWS);
|
||||
}
|
||||
@@ -83,6 +88,18 @@ export const pluginStorage = {
|
||||
await deleteItem(STORE_THEMES, themeId);
|
||||
},
|
||||
|
||||
// Theme skin CSS - separate store so it can be present/absent independently
|
||||
// of the colour-token CSS (e.g. some v2 themes ship colours only).
|
||||
async saveThemeSkin(themeId: string, skin: string): Promise<void> {
|
||||
await putItem(STORE_THEME_SKINS, themeId, skin);
|
||||
},
|
||||
async getThemeSkin(themeId: string): Promise<string | null> {
|
||||
return getItem<string>(STORE_THEME_SKINS, themeId);
|
||||
},
|
||||
async deleteThemeSkin(themeId: string): Promise<void> {
|
||||
await deleteItem(STORE_THEME_SKINS, themeId);
|
||||
},
|
||||
|
||||
// Preview images (stored as data URIs)
|
||||
async savePreview(id: string, dataUri: string): Promise<void> {
|
||||
await putItem(STORE_PREVIEWS, id, dataUri);
|
||||
|
||||
+76
-2
@@ -11,6 +11,41 @@ export type ThemeVariant = 'light' | 'dark';
|
||||
|
||||
// ─── Manifests ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Advanced theme fields ("Theme API v2"). All optional and additive - a
|
||||
* legacy theme that ships only `:root`/`.dark` CSS continues to work.
|
||||
*
|
||||
* When `apiVersion >= 2` (or any of `tokens`/`extends`/`derive`/`density`/
|
||||
* `radii`/`typography` is present), the theme compiler runs at install time
|
||||
* and produces a single CSS string from the structured fields, optionally
|
||||
* concatenated with a hand-written `theme.css` for fine-grained overrides.
|
||||
*/
|
||||
export interface ThemeTokenSet {
|
||||
/** Tokens applied regardless of variant (emitted into `:root`). */
|
||||
common?: Record<string, string>;
|
||||
/** Tokens applied in light mode (emitted into `:root`). */
|
||||
light?: Record<string, string>;
|
||||
/** Tokens applied in dark mode (emitted into `.dark`). */
|
||||
dark?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type ThemeDensity = 'compact' | 'normal' | 'touch';
|
||||
|
||||
export interface ThemeRadii {
|
||||
sm?: string;
|
||||
md?: string;
|
||||
lg?: string;
|
||||
xl?: string;
|
||||
full?: string;
|
||||
}
|
||||
|
||||
export interface ThemeTypography {
|
||||
fontSans?: string;
|
||||
fontMono?: string;
|
||||
fontDisplay?: string;
|
||||
baseFontSize?: string;
|
||||
}
|
||||
|
||||
export interface ThemeManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -21,6 +56,22 @@ export interface ThemeManifest {
|
||||
preview?: string;
|
||||
variants: ThemeVariant[];
|
||||
minAppVersion?: string;
|
||||
|
||||
// ─── Advanced (Theme API v2) ─────────────────────────────────
|
||||
/** Theme API version. Defaults to 1 (raw-CSS only). */
|
||||
apiVersion?: 1 | 2;
|
||||
/** Inherit tokens/CSS from another installed (or built-in) theme by id. */
|
||||
extends?: string;
|
||||
/** Structured colour tokens - compiled into CSS at install time. */
|
||||
tokens?: ThemeTokenSet;
|
||||
/** When true, missing standard tokens are derived (e.g. *-foreground from contrast). */
|
||||
derive?: boolean;
|
||||
/** Default UI density preset (compact / normal / touch). */
|
||||
density?: ThemeDensity;
|
||||
/** Border-radius scale, emitted as `--radius-*` vars. */
|
||||
radii?: ThemeRadii;
|
||||
/** Font stacks + base size, emitted as `--font-*` vars. */
|
||||
typography?: ThemeTypography;
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
@@ -70,12 +121,29 @@ export interface InstalledTheme {
|
||||
author: string;
|
||||
description: string;
|
||||
preview?: string; // data: URI or blob URL
|
||||
css: string; // raw CSS text
|
||||
css: string; // compiled CSS text - what gets injected
|
||||
/**
|
||||
* Optional "skin" CSS shipped by Theme API v2 themes that need to restyle
|
||||
* actual UI components (toolbars, lists, buttons, etc.) - not just colour
|
||||
* tokens. Injected into a separate `<style>` tag so it can be stripped
|
||||
* cleanly when the theme is deactivated. Stored in IndexedDB with the same
|
||||
* lifecycle as `css` to keep localStorage small.
|
||||
*/
|
||||
skin?: string;
|
||||
variants: ThemeVariant[];
|
||||
enabled: boolean;
|
||||
builtIn: boolean;
|
||||
managed?: boolean;
|
||||
forceEnabled?: boolean;
|
||||
|
||||
// ─── Advanced (Theme API v2) ─ carried over from the manifest ─
|
||||
apiVersion?: 1 | 2;
|
||||
extends?: string;
|
||||
tokens?: ThemeTokenSet;
|
||||
derive?: boolean;
|
||||
density?: ThemeDensity;
|
||||
radii?: ThemeRadii;
|
||||
typography?: ThemeTypography;
|
||||
}
|
||||
|
||||
export interface InstalledPlugin {
|
||||
@@ -527,7 +595,13 @@ export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle'
|
||||
// ─── Validation ──────────────────────────────────────────────
|
||||
|
||||
export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
export const MAX_THEME_SIZE = 1 * 1024 * 1024; // 1 MB
|
||||
export const MAX_THEME_SIZE = 2 * 1024 * 1024; // 2 MB (was 1 MB; v2 themes may ship a skin.css)
|
||||
/**
|
||||
* Maximum size of an individual `skin.css` payload after extraction.
|
||||
* Skins are component-level CSS, not images - anything bigger than this is
|
||||
* almost certainly bundling assets the validator will refuse anyway.
|
||||
*/
|
||||
export const MAX_THEME_SKIN_BYTES = 256 * 1024; // 256 KB
|
||||
|
||||
export const ALLOWED_PLUGIN_FILES = new Set([
|
||||
'.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp',
|
||||
|
||||
+91
-14
@@ -8,9 +8,11 @@ import {
|
||||
ALL_PERMISSIONS,
|
||||
MAX_PLUGIN_SIZE,
|
||||
MAX_THEME_SIZE,
|
||||
MAX_THEME_SKIN_BYTES,
|
||||
ALLOWED_PLUGIN_FILES,
|
||||
} from './plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from './theme-loader';
|
||||
import { sanitizeThemeCSS, sanitizeSkinCSS, validateThemeCSSSafety } from './theme-loader';
|
||||
import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
@@ -21,6 +23,11 @@ export interface ValidationResult {
|
||||
export interface ThemeExtractionResult extends ValidationResult {
|
||||
manifest: ThemeManifest | null;
|
||||
css: string;
|
||||
/**
|
||||
* Optional skin CSS - component-level overrides extracted from `skin.css`.
|
||||
* Only populated for Theme API v2 manifests; v1 themes ignore the file.
|
||||
*/
|
||||
skin: string | null;
|
||||
preview: string | null; // data URI
|
||||
}
|
||||
|
||||
@@ -63,6 +70,29 @@ function validateThemeManifest(manifest: Record<string, unknown>): { result: The
|
||||
if (!valid) errors.push('Variants must be "light" or "dark"');
|
||||
}
|
||||
|
||||
// ── Theme API v2 fields (all optional) ──
|
||||
if (manifest.apiVersion !== undefined && manifest.apiVersion !== 1 && manifest.apiVersion !== 2) {
|
||||
errors.push('"apiVersion" must be 1 or 2 if present');
|
||||
}
|
||||
if (manifest.extends !== undefined && typeof manifest.extends !== 'string') {
|
||||
errors.push('"extends" must be a string (the parent theme id)');
|
||||
}
|
||||
if (manifest.tokens !== undefined && (typeof manifest.tokens !== 'object' || manifest.tokens === null)) {
|
||||
errors.push('"tokens" must be an object with optional "common"/"light"/"dark" maps');
|
||||
}
|
||||
if (manifest.density !== undefined && !['compact', 'normal', 'touch'].includes(manifest.density as string)) {
|
||||
errors.push('"density" must be "compact", "normal", or "touch"');
|
||||
}
|
||||
if (manifest.derive !== undefined && typeof manifest.derive !== 'boolean') {
|
||||
errors.push('"derive" must be a boolean');
|
||||
}
|
||||
if (manifest.radii !== undefined && (typeof manifest.radii !== 'object' || manifest.radii === null)) {
|
||||
errors.push('"radii" must be an object');
|
||||
}
|
||||
if (manifest.typography !== undefined && (typeof manifest.typography !== 'object' || manifest.typography === null)) {
|
||||
errors.push('"typography" must be an object');
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { result: null, errors };
|
||||
|
||||
return {
|
||||
@@ -157,7 +187,12 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
||||
|
||||
// Size check
|
||||
if (file.size > MAX_THEME_SIZE) {
|
||||
return { valid: false, errors: ['Theme ZIP exceeds 1 MB size limit'], warnings: [], manifest: null, css: '', preview: null };
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`Theme ZIP exceeds ${Math.round(MAX_THEME_SIZE / (1024 * 1024))} MB size limit`],
|
||||
warnings: [],
|
||||
manifest: null, css: '', skin: null, preview: null,
|
||||
};
|
||||
}
|
||||
|
||||
let zip: JSZip;
|
||||
@@ -165,7 +200,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
zip = await JSZip.loadAsync(buffer);
|
||||
} catch {
|
||||
return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', preview: null };
|
||||
return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', skin: null, preview: null };
|
||||
}
|
||||
|
||||
const root = findZipRoot(zip);
|
||||
@@ -173,7 +208,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
||||
// Read manifest
|
||||
const manifestFile = zip.file(root + 'manifest.json');
|
||||
if (!manifestFile) {
|
||||
return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', preview: null };
|
||||
return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', skin: null, preview: null };
|
||||
}
|
||||
|
||||
let manifestData: Record<string, unknown>;
|
||||
@@ -181,28 +216,49 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
||||
const raw = await manifestFile.async('string');
|
||||
manifestData = JSON.parse(raw);
|
||||
} catch {
|
||||
return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', preview: null };
|
||||
return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', skin: null, preview: null };
|
||||
}
|
||||
|
||||
const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData);
|
||||
errors.push(...manifestErrors);
|
||||
if (!manifest) {
|
||||
return { valid: false, errors, warnings, manifest: null, css: '', preview: null };
|
||||
return { valid: false, errors, warnings, manifest: null, css: '', skin: null, preview: null };
|
||||
}
|
||||
|
||||
// Read theme.css
|
||||
// Read theme.css - required for v1 themes, optional when the manifest
|
||||
// declares Theme API v2 fields (tokens/extends/derive/density/radii/typography),
|
||||
// since the compiler can produce CSS purely from the manifest.
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
if (!cssFile) {
|
||||
const isAdvanced = isAdvancedManifest(manifest);
|
||||
|
||||
let userCSS = '';
|
||||
if (cssFile) {
|
||||
userCSS = await cssFile.async('string');
|
||||
const safety = validateThemeCSSSafety(userCSS);
|
||||
if (!safety.valid) {
|
||||
// Sanitize instead of rejecting
|
||||
const sanitized = sanitizeThemeCSS(userCSS);
|
||||
userCSS = sanitized.css;
|
||||
warnings.push(...sanitized.warnings);
|
||||
}
|
||||
} else if (!isAdvanced) {
|
||||
errors.push('Missing theme.css');
|
||||
return { valid: false, errors, warnings, manifest, css: '', preview: null };
|
||||
return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null };
|
||||
}
|
||||
|
||||
let rawCSS = await cssFile.async('string');
|
||||
// Compile advanced tokens into CSS (for v2 manifests). The compiled output
|
||||
// is concatenated with any user-supplied theme.css for fine-grained overrides.
|
||||
let rawCSS = userCSS;
|
||||
if (isAdvanced) {
|
||||
const compiled = compileAdvancedTheme(manifest, { userCSS });
|
||||
if (compiled.errors.length > 0) {
|
||||
errors.push(...compiled.errors);
|
||||
return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null };
|
||||
}
|
||||
warnings.push(...compiled.warnings);
|
||||
rawCSS = compiled.css;
|
||||
|
||||
// Validate CSS safety
|
||||
const safety = validateThemeCSSSafety(rawCSS);
|
||||
if (!safety.valid) {
|
||||
// Sanitize instead of rejecting
|
||||
// Run sanitizer over the final compiled output as a defence-in-depth check.
|
||||
const sanitized = sanitizeThemeCSS(rawCSS);
|
||||
rawCSS = sanitized.css;
|
||||
warnings.push(...sanitized.warnings);
|
||||
@@ -222,12 +278,33 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// Read skin.css if present (Theme API v2 only). Skins target real
|
||||
// component selectors and bypass the strict :root/.dark selector check -
|
||||
// they still go through the dangerous-pattern sanitizer.
|
||||
let skin: string | null = null;
|
||||
const skinFile = zip.file(root + 'skin.css');
|
||||
if (skinFile) {
|
||||
if (!isAdvanced) {
|
||||
warnings.push('skin.css ignored - only Theme API v2 manifests can ship a skin');
|
||||
} else {
|
||||
const rawSkin = await skinFile.async('string');
|
||||
if (rawSkin.length > MAX_THEME_SKIN_BYTES) {
|
||||
warnings.push(`skin.css exceeds ${Math.round(MAX_THEME_SKIN_BYTES / 1024)} KB and was dropped`);
|
||||
} else {
|
||||
const sanitized = sanitizeSkinCSS(rawSkin);
|
||||
skin = sanitized.css;
|
||||
warnings.push(...sanitized.warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
manifest,
|
||||
css: rawCSS,
|
||||
skin,
|
||||
preview,
|
||||
};
|
||||
}
|
||||
|
||||
+45
-14
@@ -1,12 +1,32 @@
|
||||
/**
|
||||
* Sub-addressing utilities for user+tag@domain.com format
|
||||
* Sub-addressing utilities for user{delimiter}tag@domain.com format
|
||||
* Works server-side automatically - no JMAP API calls needed
|
||||
*
|
||||
* The delimiter character is configurable per server (RFC 5233). Common
|
||||
* choices: "+" (Postfix, Stalwart default), "-" (qmail), ".", "=".
|
||||
*/
|
||||
|
||||
// Constants for tag validation
|
||||
const MAX_TAG_LENGTH = 30;
|
||||
const TAG_REGEX = /^[a-zA-Z0-9-]{1,30}$/;
|
||||
|
||||
export const DEFAULT_SUB_ADDRESS_DELIMITER = '+';
|
||||
export const SUPPORTED_SUB_ADDRESS_DELIMITERS = ['+', '-', '.', '='] as const;
|
||||
export type SubAddressDelimiterPreset = (typeof SUPPORTED_SUB_ADDRESS_DELIMITERS)[number];
|
||||
|
||||
export function isSupportedSubAddressDelimiter(value: string): value is SubAddressDelimiterPreset {
|
||||
return (SUPPORTED_SUB_ADDRESS_DELIMITERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
// RFC 5321 atext "special" characters, minus alphanumerics and "@". A custom
|
||||
// delimiter must be exactly one of these — they're safe to embed in a local
|
||||
// part and unambiguously separate the user from the tag.
|
||||
const VALID_DELIMITER_REGEX = /^[!#$%&'*+\-./=?^_`{|}~]$/;
|
||||
|
||||
export function isValidSubAddressDelimiter(value: unknown): value is string {
|
||||
return typeof value === 'string' && VALID_DELIMITER_REGEX.test(value);
|
||||
}
|
||||
|
||||
export type TagValidationErrorCode =
|
||||
| 'EMPTY'
|
||||
| 'TOO_LONG'
|
||||
@@ -22,10 +42,14 @@ export interface ParsedAddress {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an email address to extract sub-address tag
|
||||
* Example: "user+shopping@example.com" -> { baseUser: "user", tag: "shopping" }
|
||||
* Parse an email address to extract sub-address tag.
|
||||
* The first occurrence of the delimiter in the local part separates the
|
||||
* base user from the tag, matching the behavior of Postfix/qmail/Sieve.
|
||||
*/
|
||||
export function parseSubAddress(email: string): ParsedAddress {
|
||||
export function parseSubAddress(
|
||||
email: string,
|
||||
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
): ParsedAddress {
|
||||
const [localPart, domain] = email.split('@');
|
||||
|
||||
if (!localPart || !domain) {
|
||||
@@ -38,9 +62,9 @@ export function parseSubAddress(email: string): ParsedAddress {
|
||||
};
|
||||
}
|
||||
|
||||
const plusIndex = localPart.indexOf('+');
|
||||
const delimiterIndex = localPart.indexOf(delimiter);
|
||||
|
||||
if (plusIndex === -1) {
|
||||
if (delimiterIndex === -1) {
|
||||
return {
|
||||
localPart,
|
||||
baseUser: localPart,
|
||||
@@ -50,8 +74,8 @@ export function parseSubAddress(email: string): ParsedAddress {
|
||||
};
|
||||
}
|
||||
|
||||
const baseUser = localPart.substring(0, plusIndex);
|
||||
const tag = localPart.substring(plusIndex + 1);
|
||||
const baseUser = localPart.substring(0, delimiterIndex);
|
||||
const tag = localPart.substring(delimiterIndex + delimiter.length);
|
||||
|
||||
return {
|
||||
localPart,
|
||||
@@ -63,18 +87,25 @@ export function parseSubAddress(email: string): ParsedAddress {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sub-addressed email
|
||||
* Example: generateSubAddress("user@example.com", "shopping") -> "user+shopping@example.com"
|
||||
* Generate a sub-addressed email.
|
||||
* Example: generateSubAddress("user@example.com", "shopping", "+") -> "user+shopping@example.com"
|
||||
*/
|
||||
export function generateSubAddress(baseEmail: string, tag: string): string {
|
||||
export function generateSubAddress(
|
||||
baseEmail: string,
|
||||
tag: string,
|
||||
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
): string {
|
||||
const [localPart, domain] = baseEmail.split('@');
|
||||
|
||||
if (!localPart || !domain || !tag) {
|
||||
return baseEmail;
|
||||
}
|
||||
|
||||
// Remove existing tag if present
|
||||
const cleanLocal = localPart.split('+')[0];
|
||||
// Strip an existing tag if one is already present
|
||||
const existingDelimiterIndex = localPart.indexOf(delimiter);
|
||||
const cleanLocal = existingDelimiterIndex === -1
|
||||
? localPart
|
||||
: localPart.substring(0, existingDelimiterIndex);
|
||||
|
||||
// Sanitize tag (alphanumeric and dash only)
|
||||
const cleanTag = tag.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
|
||||
@@ -83,7 +114,7 @@ export function generateSubAddress(baseEmail: string, tag: string): string {
|
||||
return baseEmail;
|
||||
}
|
||||
|
||||
return `${cleanLocal}+${cleanTag}@${domain}`;
|
||||
return `${cleanLocal}${delimiter}${cleanTag}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
// Block telemetry endpoints from pointing at internal/loopback addresses.
|
||||
// Required because the admin UI lets an authenticated admin set an arbitrary
|
||||
// URL; without this an attacker with a session (or a hostile admin in a
|
||||
// multi-tenant deploy) could redirect heartbeats at internal hosts.
|
||||
//
|
||||
// Set BULWARK_TELEMETRY_ALLOW_PRIVATE=1 to bypass - useful only for local
|
||||
// dev where the collector is on the loopback.
|
||||
|
||||
const PRIVATE_V4: RegExp[] = [
|
||||
/^0\./, // 0.0.0.0/8
|
||||
/^10\./, // 10.0.0.0/8
|
||||
/^127\./, // loopback
|
||||
/^169\.254\./, // link-local + cloud metadata
|
||||
/^172\.(1[6-9]|2\d|3[0-1])\./, // 172.16.0.0/12
|
||||
/^192\.168\./, // 192.168.0.0/16
|
||||
/^192\.0\.0\./, // IETF reserved
|
||||
/^198\.(1[8-9])\./, // benchmarking 198.18.0.0/15
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 CGNAT
|
||||
/^22[4-9]\./, // 224.0.0.0/4 multicast
|
||||
/^23\d\./,
|
||||
/^2[4-5]\d\./, // 240.0.0.0/4 reserved
|
||||
];
|
||||
|
||||
function isPrivateV4(ip: string): boolean {
|
||||
return PRIVATE_V4.some((re) => re.test(ip));
|
||||
}
|
||||
|
||||
function isPrivateV6(ip: string): boolean {
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === '::1' || lower === '::') return true;
|
||||
if (/^fe[89ab][0-9a-f]:/.test(lower)) return true; // fe80::/10 link-local
|
||||
if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true; // fc00::/7 ULA
|
||||
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (mapped) return isPrivateV4(mapped[1]);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPrivateAddress(ip: string): boolean {
|
||||
const family = isIP(ip);
|
||||
if (family === 4) return isPrivateV4(ip);
|
||||
if (family === 6) return isPrivateV6(ip);
|
||||
return false;
|
||||
}
|
||||
|
||||
const BAD_HOSTS = new Set([
|
||||
'localhost',
|
||||
'localhost.localdomain',
|
||||
'ip6-localhost',
|
||||
'ip6-loopback',
|
||||
]);
|
||||
|
||||
function bypassEnabled(): boolean {
|
||||
return process.env.BULWARK_TELEMETRY_ALLOW_PRIVATE === '1';
|
||||
}
|
||||
|
||||
export type EndpointCheck = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
// Sync URL/host shape check. Catches the obvious cases without DNS.
|
||||
export function validateEndpointUrl(raw: string): EndpointCheck {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return { ok: false, reason: 'invalid URL' };
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return { ok: false, reason: 'must be http(s)://' };
|
||||
}
|
||||
if (bypassEnabled()) return { ok: true };
|
||||
|
||||
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (!host) return { ok: false, reason: 'host required' };
|
||||
if (BAD_HOSTS.has(host)) {
|
||||
return { ok: false, reason: 'localhost endpoints are not allowed' };
|
||||
}
|
||||
if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.localhost')) {
|
||||
return { ok: false, reason: 'private TLDs are not allowed' };
|
||||
}
|
||||
if (isIP(host) && isPrivateAddress(host)) {
|
||||
return { ok: false, reason: 'private/loopback IP is not allowed' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Async check that additionally resolves DNS hostnames. Use this on
|
||||
// set-endpoint AND immediately before fetch to defeat DNS-rebinding tricks
|
||||
// where a hostname resolves to a public IP at validation time and a private
|
||||
// one at fetch time.
|
||||
export async function resolveEndpointAllowed(raw: string): Promise<EndpointCheck> {
|
||||
const initial = validateEndpointUrl(raw);
|
||||
if (!initial.ok) return initial;
|
||||
if (bypassEnabled()) return { ok: true };
|
||||
|
||||
const host = new URL(raw).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (isIP(host)) return { ok: true };
|
||||
|
||||
try {
|
||||
const addrs = await lookup(host, { all: true });
|
||||
for (const a of addrs) {
|
||||
if (isPrivateAddress(a.address)) {
|
||||
return { ok: false, reason: `host ${host} resolves to private address ${a.address}` };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
// Don't block on transient DNS failures - fetch will fail loudly anyway,
|
||||
// and we don't want to lock admins out of their config when the resolver
|
||||
// is flaky. The literal-IP check above already covers the direct-attack
|
||||
// case.
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { startScheduler, stopScheduler, reschedule, sendOnce } from './sender';
|
||||
export { buildPayload, markProcessStart } from './payload';
|
||||
export {
|
||||
loadState, saveState, getInstanceId, effectiveConsent,
|
||||
} from './state';
|
||||
export { recordLogin, getLoginCounts } from './login-tracker';
|
||||
export {
|
||||
validateEndpointUrl, resolveEndpointAllowed, isPrivateAddress,
|
||||
} from './endpoint-guard';
|
||||
export type { EndpointCheck } from './endpoint-guard';
|
||||
export type {
|
||||
TelemetryPayload, TelemetryStateFile, ConsentState,
|
||||
Platform, OsFamily, CountBucket, TelemetryFeatures,
|
||||
} from './types';
|
||||
export { DEFAULT_ENDPOINT } from './types';
|
||||
@@ -0,0 +1,125 @@
|
||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getInstanceId } from './state';
|
||||
|
||||
// We never store usernames or server URLs in the clear. Each login is
|
||||
// recorded as HMAC-SHA256(username + '@' + serverUrl, instance_id), so the
|
||||
// file on disk cannot be cross-correlated with any other instance and is
|
||||
// not PII even if leaked.
|
||||
|
||||
interface LoginRecord {
|
||||
id: string;
|
||||
lastLoginAt: string;
|
||||
}
|
||||
|
||||
interface LoginsFile {
|
||||
records: LoginRecord[];
|
||||
}
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let cache: LoginsFile | null = null;
|
||||
|
||||
function getDir(): string {
|
||||
return process.env.TELEMETRY_DATA_DIR || path.join(process.cwd(), 'data', 'telemetry');
|
||||
}
|
||||
|
||||
function loginsPath(): string {
|
||||
return path.join(getDir(), 'logins.json');
|
||||
}
|
||||
|
||||
async function ensureDir(): Promise<void> {
|
||||
const dir = getDir();
|
||||
if (!existsSync(dir)) await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function loadFile(): Promise<LoginsFile> {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = await readFile(loginsPath(), 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<LoginsFile>;
|
||||
cache = Array.isArray(parsed?.records) ? { records: parsed.records as LoginRecord[] } : { records: [] };
|
||||
} catch {
|
||||
cache = { records: [] };
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
async function saveFile(file: LoginsFile): Promise<void> {
|
||||
await ensureDir();
|
||||
cache = file;
|
||||
const tmp = loginsPath() + '.tmp';
|
||||
await writeFile(tmp, JSON.stringify(file), 'utf8');
|
||||
await rename(tmp, loginsPath());
|
||||
}
|
||||
|
||||
function normalizeServer(serverUrl: string): string {
|
||||
return serverUrl.trim().replace(/\/+$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
async function hashIdentity(username: string, serverUrl: string): Promise<string> {
|
||||
const instanceId = await getInstanceId();
|
||||
const subject = `${username.trim().toLowerCase()}@${normalizeServer(serverUrl)}`;
|
||||
return createHmac('sha256', instanceId).update(subject).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful login. Best-effort; never throws. Updates the
|
||||
* existing record's timestamp if the same identity has logged in before,
|
||||
* otherwise appends a new record. Records older than the retention window
|
||||
* are pruned on every write.
|
||||
*/
|
||||
export async function recordLogin(username: string, serverUrl: string): Promise<void> {
|
||||
if (!username || !serverUrl) return;
|
||||
try {
|
||||
const id = await hashIdentity(username, serverUrl);
|
||||
const file = await loadFile();
|
||||
const now = new Date().toISOString();
|
||||
const cutoff = Date.now() - RETENTION_MS;
|
||||
const next: LoginRecord[] = [];
|
||||
let updated = false;
|
||||
for (const rec of file.records) {
|
||||
const ts = new Date(rec.lastLoginAt).getTime();
|
||||
if (Number.isNaN(ts) || ts < cutoff) continue;
|
||||
if (rec.id === id) {
|
||||
next.push({ id, lastLoginAt: now });
|
||||
updated = true;
|
||||
} else {
|
||||
next.push(rec);
|
||||
}
|
||||
}
|
||||
if (!updated) next.push({ id, lastLoginAt: now });
|
||||
await saveFile({ records: next });
|
||||
} catch (err) {
|
||||
logger.debug?.('telemetry: recordLogin failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Total distinct accounts seen in the 90-day retention window, plus those
|
||||
* with a login in the last 7 days.
|
||||
*/
|
||||
export async function getLoginCounts(): Promise<{ total: number; active7d: number }> {
|
||||
try {
|
||||
const file = await loadFile();
|
||||
const cutoff = Date.now() - RETENTION_MS;
|
||||
const sevenAgo = Date.now() - SEVEN_DAYS_MS;
|
||||
let total = 0;
|
||||
let active7d = 0;
|
||||
for (const rec of file.records) {
|
||||
const ts = new Date(rec.lastLoginAt).getTime();
|
||||
if (Number.isNaN(ts) || ts < cutoff) continue;
|
||||
total++;
|
||||
if (ts >= sevenAgo) active7d++;
|
||||
}
|
||||
return { total, active7d };
|
||||
} catch {
|
||||
return { total: 0, active7d: 0 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { resolveEndpointAllowed } from './endpoint-guard';
|
||||
import { getInstanceId } from './state';
|
||||
import { getLoginCounts } from './login-tracker';
|
||||
import type {
|
||||
TelemetryPayload,
|
||||
TelemetryFeatures,
|
||||
Platform,
|
||||
OsFamily,
|
||||
CountBucket,
|
||||
} from './types';
|
||||
|
||||
let processStartedAt = Date.now();
|
||||
export function markProcessStart(): void {
|
||||
processStartedAt = Date.now();
|
||||
}
|
||||
|
||||
function readPackage(): { version: string; build: string | null } {
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
|
||||
) as { version?: string };
|
||||
return { version: pkg.version ?? '0.0.0', build: process.env.BULWARK_BUILD ?? 'release' };
|
||||
} catch {
|
||||
return { version: '0.0.0', build: null };
|
||||
}
|
||||
}
|
||||
|
||||
function detectPlatform(): Platform {
|
||||
if (process.env.KUBERNETES_SERVICE_HOST) return 'k8s';
|
||||
// /.dockerenv is the standard Docker container marker.
|
||||
try {
|
||||
readFileSync('/.dockerenv');
|
||||
return 'docker';
|
||||
} catch { /* not in docker */ }
|
||||
return 'bare';
|
||||
}
|
||||
|
||||
function detectOs(): OsFamily {
|
||||
switch (process.platform) {
|
||||
case 'linux': return 'linux';
|
||||
case 'darwin': return 'darwin';
|
||||
case 'win32': return 'windows';
|
||||
default: return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export function bucketCount(n: number): CountBucket {
|
||||
if (n <= 0) return '0';
|
||||
if (n === 1) return '1';
|
||||
if (n <= 5) return '2-5';
|
||||
if (n <= 10) return '6-10';
|
||||
if (n <= 50) return '11-50';
|
||||
if (n <= 200) return '51-200';
|
||||
return '201+';
|
||||
}
|
||||
|
||||
async function readFeatures(): Promise<TelemetryFeatures> {
|
||||
await configManager.ensureLoaded();
|
||||
const gates = configManager.getPolicy().features;
|
||||
const cfg = configManager.getAll();
|
||||
return {
|
||||
// Booleans only. We read whether a feature is enabled - never any
|
||||
// config value beyond a presence check.
|
||||
calendar: gates.calendarTasksEnabled === true,
|
||||
contacts: gates.contactsEnabled === true,
|
||||
files: gates.filesEnabled === true,
|
||||
extensions: gates.pluginsEnabled === true,
|
||||
oauth_enabled: cfg['oauthEnabled'] === true,
|
||||
smime_enabled: gates.smimeEnabled === true,
|
||||
};
|
||||
}
|
||||
|
||||
const STALWART_VERSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
let stalwartVersionCache: { version: string | null; fetchedAt: number } | null = null;
|
||||
|
||||
// Stalwart returns the version in the Server response header
|
||||
// (e.g. "Stalwart Mail Server v0.16.0"). The /.well-known/jmap endpoint
|
||||
// requires auth, but the header is on the 401 response too, so an
|
||||
// unauthenticated GET is enough. Cached for a day to avoid hammering
|
||||
// the JMAP server on every payload preview.
|
||||
async function detectStalwartVersion(): Promise<string | null> {
|
||||
if (process.env.STALWART_VERSION) return process.env.STALWART_VERSION;
|
||||
if (stalwartVersionCache &&
|
||||
Date.now() - stalwartVersionCache.fetchedAt < STALWART_VERSION_TTL_MS) {
|
||||
return stalwartVersionCache.version;
|
||||
}
|
||||
await configManager.ensureLoaded();
|
||||
const serverUrl = configManager.get<string>('jmapServerUrl', '').trim();
|
||||
if (!serverUrl) {
|
||||
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
|
||||
return null;
|
||||
}
|
||||
const wellKnown = `${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`;
|
||||
// Reuse the SSRF guard so a misconfigured JMAP_SERVER_URL pointing at an
|
||||
// internal host doesn't get probed from telemetry context either.
|
||||
const guard = await resolveEndpointAllowed(wellKnown);
|
||||
if (!guard.ok) {
|
||||
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(wellKnown, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
const server = res.headers.get('server') ?? '';
|
||||
const m = server.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
|
||||
const version = m?.[1] ?? null;
|
||||
stalwartVersionCache = { version, fetchedAt: Date.now() };
|
||||
return version;
|
||||
} catch (err) {
|
||||
logger.debug?.('telemetry: stalwart version probe failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
stalwartVersionCache = { version: null, fetchedAt: Date.now() };
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Account counts come from the local login tracker, which records a per-
|
||||
// instance HMAC of every successful login plus the timestamp. Total = unique
|
||||
// identities seen in the last 90 days; active7d = identities with a login in
|
||||
// the last 7 days.
|
||||
|
||||
async function countExtensions(): Promise<{ extensions: number; themes: number }> {
|
||||
try {
|
||||
const { getPluginRegistry, getThemeRegistry } = await import('@/lib/admin/plugin-registry');
|
||||
const [plugins, themes] = await Promise.all([getPluginRegistry(), getThemeRegistry()]);
|
||||
return {
|
||||
extensions: plugins.plugins.length,
|
||||
themes: themes.themes.length,
|
||||
};
|
||||
} catch {
|
||||
return { extensions: 0, themes: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildPayload(): Promise<TelemetryPayload> {
|
||||
const instance_id = await getInstanceId();
|
||||
const { version, build } = readPackage();
|
||||
const features = await readFeatures();
|
||||
const accounts = await getLoginCounts();
|
||||
const exts = await countExtensions();
|
||||
const stalwart_version = await detectStalwartVersion();
|
||||
const uptime_days = Math.min(
|
||||
365,
|
||||
Math.floor((Date.now() - processStartedAt) / 86_400_000),
|
||||
);
|
||||
|
||||
return {
|
||||
schema: '1',
|
||||
instance_id,
|
||||
ts: new Date().toISOString(),
|
||||
version,
|
||||
build,
|
||||
platform: detectPlatform(),
|
||||
node_version: process.versions.node,
|
||||
os_family: detectOs(),
|
||||
stalwart_version,
|
||||
features,
|
||||
counts: {
|
||||
accounts: bucketCount(accounts.total),
|
||||
accounts_active_7d: bucketCount(accounts.active7d),
|
||||
extensions_installed: exts.extensions,
|
||||
themes_installed: exts.themes,
|
||||
},
|
||||
uptime_days,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import { effectiveConsent, endpointEnabled, loadState, saveState } from './state';
|
||||
import { buildPayload } from './payload';
|
||||
import { resolveEndpointAllowed } from './endpoint-guard';
|
||||
import { DEFAULT_ENDPOINT } from './types';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const JITTER_MS = 2 * 60 * 60 * 1000; // ± 2 hours
|
||||
const FIRST_DELAY_MS = 60 * 60 * 1000; // 1 hour after consent
|
||||
|
||||
let currentTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function jitteredDelay(base: number): number {
|
||||
const j = (Math.random() * 2 - 1) * JITTER_MS;
|
||||
return Math.max(60_000, base + j);
|
||||
}
|
||||
|
||||
export async function sendOnce(opts?: { reason?: string }): Promise<{
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const { consent, source, state } = await effectiveConsent();
|
||||
if (consent !== 'on') return { ok: false, error: `consent ${consent} (source ${source})` };
|
||||
const endpoint = state.endpoint || DEFAULT_ENDPOINT;
|
||||
if (!endpointEnabled(endpoint)) return { ok: false, error: 'endpoint blank' };
|
||||
|
||||
// Re-check at fetch time: defeats DNS rebinding, and catches the case
|
||||
// where state.json was edited out-of-band to bypass the admin API.
|
||||
const guard = await resolveEndpointAllowed(endpoint);
|
||||
if (!guard.ok) {
|
||||
logger.warn('telemetry: endpoint blocked', { reason: guard.reason });
|
||||
return { ok: false, error: `endpoint blocked: ${guard.reason}` };
|
||||
}
|
||||
|
||||
const payload = await buildPayload();
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const ok = res.ok;
|
||||
if (ok) {
|
||||
const next = await loadState();
|
||||
next.lastSentAt = new Date().toISOString();
|
||||
await saveState(next);
|
||||
}
|
||||
logger.info('telemetry: heartbeat', {
|
||||
ok, status: res.status, reason: opts?.reason ?? 'scheduled',
|
||||
});
|
||||
return { ok, status: res.status };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.warn('telemetry: heartbeat failed', { error: msg });
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleNext(delayMs: number): Promise<void> {
|
||||
if (currentTimer) clearTimeout(currentTimer);
|
||||
const at = new Date(Date.now() + delayMs).toISOString();
|
||||
const state = await loadState();
|
||||
state.nextScheduledAt = at;
|
||||
await saveState(state);
|
||||
currentTimer = setTimeout(() => { void tick(); }, delayMs);
|
||||
// Don't keep the process alive just for this.
|
||||
currentTimer.unref?.();
|
||||
}
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
await sendOnce({ reason: 'scheduled' });
|
||||
await scheduleNext(jitteredDelay(DAY_MS));
|
||||
}
|
||||
|
||||
// Called from instrumentation. Idempotent.
|
||||
export async function startScheduler(): Promise<void> {
|
||||
const { consent } = await effectiveConsent();
|
||||
if (consent !== 'on') {
|
||||
logger.info('telemetry: scheduler not started', { consent });
|
||||
return;
|
||||
}
|
||||
const state = await loadState();
|
||||
// If we have a next-scheduled time in the future use it; otherwise schedule
|
||||
// FIRST_DELAY_MS out. This means after a restart we don't fire immediately.
|
||||
let delay = FIRST_DELAY_MS;
|
||||
if (state.nextScheduledAt) {
|
||||
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
|
||||
if (remaining > 0) delay = Math.min(remaining, DAY_MS + JITTER_MS);
|
||||
}
|
||||
await scheduleNext(delay);
|
||||
logger.info('telemetry: scheduler started', {
|
||||
nextInMs: delay,
|
||||
endpoint: state.endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopScheduler(): Promise<void> {
|
||||
if (currentTimer) clearTimeout(currentTimer);
|
||||
currentTimer = null;
|
||||
}
|
||||
|
||||
// Called when consent flips on/off via the UI.
|
||||
export async function reschedule(): Promise<void> {
|
||||
await stopScheduler();
|
||||
await startScheduler();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { TelemetryStateFile, ConsentState } from './types';
|
||||
import { DEFAULT_ENDPOINT } from './types';
|
||||
|
||||
function getDir(): string {
|
||||
return process.env.TELEMETRY_DATA_DIR ||
|
||||
path.join(process.cwd(), 'data', 'telemetry');
|
||||
}
|
||||
|
||||
function statePath(): string { return path.join(getDir(), 'state.json'); }
|
||||
function idPath(): string { return path.join(getDir(), '.telemetry-id'); }
|
||||
|
||||
function envOverride(): ConsentState | null {
|
||||
const v = (process.env.BULWARK_TELEMETRY ?? '').toLowerCase();
|
||||
if (v === 'off' || v === 'false' || v === '0' || v === 'no') return 'off';
|
||||
if (process.env.BULWARK_TELEMETRY_DISABLED) {
|
||||
const d = process.env.BULWARK_TELEMETRY_DISABLED.toLowerCase();
|
||||
if (d === '1' || d === 'true' || d === 'yes') return 'off';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function ensureDir(): Promise<void> {
|
||||
if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true });
|
||||
}
|
||||
|
||||
export async function getInstanceId(): Promise<string> {
|
||||
await ensureDir();
|
||||
try {
|
||||
const id = (await readFile(idPath(), 'utf8')).trim();
|
||||
if (/^[0-9a-f-]{36}$/i.test(id)) return id;
|
||||
} catch { /* generate fresh */ }
|
||||
const fresh = randomUUID();
|
||||
const tmp = idPath() + '.tmp';
|
||||
await writeFile(tmp, fresh, 'utf8');
|
||||
await rename(tmp, idPath());
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// Default consent is 'on' - telemetry is anonymous and enabled by default.
|
||||
// Admins can disable via the UI, the BULWARK_TELEMETRY env var, or by clearing
|
||||
// the endpoint. See https://bulwarkmail.org/docs/legal/privacy/telemetry.
|
||||
const DEFAULTS: TelemetryStateFile = {
|
||||
consent: 'on',
|
||||
endpoint: DEFAULT_ENDPOINT,
|
||||
consentedAt: null,
|
||||
lastSentAt: null,
|
||||
nextScheduledAt: null,
|
||||
};
|
||||
|
||||
export async function loadState(): Promise<TelemetryStateFile> {
|
||||
await ensureDir();
|
||||
try {
|
||||
const raw = await readFile(statePath(), 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<TelemetryStateFile>;
|
||||
return { ...DEFAULTS, ...parsed };
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn('telemetry: state read failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
// First-ever load on a fresh install: persist the default-on state with
|
||||
// an autoEnabledAt stamp so the admin UI can show "telemetry was
|
||||
// auto-enabled at <time>; disable here" without re-arming on restart.
|
||||
const fresh: TelemetryStateFile = {
|
||||
...DEFAULTS,
|
||||
consentedAt: new Date().toISOString(),
|
||||
};
|
||||
await saveState(fresh);
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveState(state: TelemetryStateFile): Promise<void> {
|
||||
await ensureDir();
|
||||
const tmp = statePath() + '.tmp';
|
||||
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
|
||||
await rename(tmp, statePath());
|
||||
}
|
||||
|
||||
// Effective consent: env var wins over file. UI changes are blocked
|
||||
// when env override is active so the user knows where it's coming from.
|
||||
export async function effectiveConsent(): Promise<{
|
||||
consent: ConsentState;
|
||||
source: 'env' | 'file';
|
||||
state: TelemetryStateFile;
|
||||
}> {
|
||||
const envState = envOverride();
|
||||
const state = await loadState();
|
||||
if (envState) return { consent: envState, source: 'env', state };
|
||||
return { consent: state.consent, source: 'file', state };
|
||||
}
|
||||
|
||||
export function endpointEnabled(endpoint: string | undefined): boolean {
|
||||
return !!endpoint && endpoint.trim().length > 0;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Schema v1 of the anonymous heartbeat. Documented at
|
||||
// https://bulwarkmail.org/docs/legal/privacy/telemetry
|
||||
|
||||
export type ConsentState = 'pending' | 'on' | 'off';
|
||||
|
||||
export type Platform = 'docker' | 'bare' | 'k8s' | 'unknown';
|
||||
export type OsFamily = 'linux' | 'darwin' | 'windows' | 'unknown';
|
||||
export type CountBucket = '0' | '1' | '2-5' | '6-10' | '11-50' | '51-200' | '201+';
|
||||
|
||||
export interface TelemetryFeatures {
|
||||
calendar: boolean;
|
||||
contacts: boolean;
|
||||
files: boolean;
|
||||
extensions: boolean;
|
||||
oauth_enabled: boolean;
|
||||
smime_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TelemetryPayload {
|
||||
schema: '1';
|
||||
instance_id: string;
|
||||
ts: string;
|
||||
version: string;
|
||||
build: string | null;
|
||||
platform: Platform;
|
||||
node_version: string;
|
||||
os_family: OsFamily;
|
||||
stalwart_version: string | null;
|
||||
features: TelemetryFeatures;
|
||||
counts: {
|
||||
accounts: CountBucket;
|
||||
accounts_active_7d: CountBucket;
|
||||
extensions_installed: number;
|
||||
themes_installed: number;
|
||||
};
|
||||
uptime_days: number;
|
||||
}
|
||||
|
||||
export interface TelemetryStateFile {
|
||||
consent: ConsentState;
|
||||
endpoint: string;
|
||||
consentedAt: string | null;
|
||||
lastSentAt: string | null;
|
||||
nextScheduledAt: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_ENDPOINT = 'https://telemetry.bulwarkmail.org/v1/heartbeat';
|
||||
@@ -0,0 +1,295 @@
|
||||
// Advanced Theme API v2 - compiles structured manifest fields (tokens,
|
||||
// radii, typography, density, extends) into a single CSS string that the
|
||||
// existing `injectThemeCSS` pipeline can apply unchanged.
|
||||
|
||||
import type {
|
||||
ThemeDensity,
|
||||
ThemeManifest,
|
||||
ThemeRadii,
|
||||
ThemeTokenSet,
|
||||
ThemeTypography,
|
||||
} from './plugin-types';
|
||||
import { getLuminance, parseColor } from './color-transform';
|
||||
|
||||
export interface CompiledTheme {
|
||||
css: string;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard tokens whose `*-foreground` counterpart can be auto-derived from
|
||||
* contrast when `derive: true` and only the base colour is supplied.
|
||||
*/
|
||||
const DERIVE_PAIRS: Array<[base: string, fg: string]> = [
|
||||
['primary', 'primary-foreground'],
|
||||
['secondary', 'secondary-foreground'],
|
||||
['muted', 'muted-foreground'],
|
||||
['accent', 'accent-foreground'],
|
||||
['destructive', 'destructive-foreground'],
|
||||
['popover', 'popover-foreground'],
|
||||
['card', 'card-foreground'],
|
||||
['sidebar', 'sidebar-foreground'],
|
||||
['success', 'success-foreground'],
|
||||
['warning', 'warning-foreground'],
|
||||
['info', 'info-foreground'],
|
||||
];
|
||||
|
||||
/** Pick a foreground colour (white or near-black) by background luminance. */
|
||||
function pickForeground(bg: string): string {
|
||||
const rgb = parseColor(bg);
|
||||
if (!rgb) return '#ffffff';
|
||||
return getLuminance(rgb.r, rgb.g, rgb.b) >= 0.55 ? '#0f172a' : '#ffffff';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a manifest token key to a fully-qualified CSS custom property:
|
||||
* "primary" → "--color-primary"
|
||||
* "color-primary" → "--color-primary"
|
||||
* "--color-primary" → "--color-primary"
|
||||
* "font-sans" → "--font-sans"
|
||||
*/
|
||||
const PREFIXED_NAMESPACES = ['color-', 'font-', 'radius-', 'density-'];
|
||||
function tokenName(key: string): string {
|
||||
if (key.startsWith('--')) return key;
|
||||
if (PREFIXED_NAMESPACES.some((ns) => key.startsWith(ns))) return `--${key}`;
|
||||
return `--color-${key}`;
|
||||
}
|
||||
|
||||
function emitTokens(
|
||||
tokens: Record<string, string>,
|
||||
derive: boolean,
|
||||
): { lines: string[]; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const expanded: Record<string, string> = { ...tokens };
|
||||
|
||||
if (derive) {
|
||||
for (const [base, fg] of DERIVE_PAIRS) {
|
||||
if (expanded[base] && !expanded[fg]) {
|
||||
expanded[fg] = pickForeground(expanded[base]);
|
||||
}
|
||||
}
|
||||
// Common alias: --color-foreground used as page text colour.
|
||||
if (expanded.background && !expanded.foreground) {
|
||||
expanded.foreground = pickForeground(expanded.background);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const [rawKey, value] of Object.entries(expanded)) {
|
||||
if (typeof value !== 'string' || !value.trim()) continue;
|
||||
if (!isSafeTokenKey(rawKey)) {
|
||||
warnings.push(`Token "${rawKey}" dropped - invalid key (only [a-z0-9-] allowed)`);
|
||||
continue;
|
||||
}
|
||||
if (!isSafeTokenValue(value)) {
|
||||
warnings.push(`Token "${rawKey}" dropped - value contains unsafe characters`);
|
||||
continue;
|
||||
}
|
||||
lines.push(` ${tokenName(rawKey)}: ${value.trim()};`);
|
||||
}
|
||||
return { lines, warnings };
|
||||
}
|
||||
|
||||
const SAFE_KEY_PATTERN = /^(--)?[a-z][a-z0-9-]*$/;
|
||||
function isSafeTokenKey(key: string): boolean {
|
||||
return SAFE_KEY_PATTERN.test(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token values are emitted verbatim into CSS, so they must not contain
|
||||
* anything that could break out of the declaration (`{`, `}`, `;`,
|
||||
* `<`/`>`) or pull in remote/scripted content.
|
||||
*/
|
||||
function isSafeTokenValue(value: string): boolean {
|
||||
if (/[{}<>]/.test(value)) return false;
|
||||
if (value.includes(';')) return false;
|
||||
if (/url\s*\(\s*['"]?(https?|data|javascript):/i.test(value)) return false;
|
||||
if (/expression\s*\(/i.test(value)) return false;
|
||||
if (/-moz-binding/i.test(value)) return false;
|
||||
if (/javascript\s*:/i.test(value)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function emitRadii(radii: ThemeRadii): string[] {
|
||||
const out: string[] = [];
|
||||
for (const [k, v] of Object.entries(radii)) {
|
||||
if (typeof v === 'string' && isSafeTokenValue(v)) {
|
||||
out.push(` --radius-${k}: ${v.trim()};`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function emitTypography(typography: ThemeTypography): string[] {
|
||||
const out: string[] = [];
|
||||
if (typography.fontSans && isSafeTokenValue(typography.fontSans)) {
|
||||
out.push(` --font-sans: ${typography.fontSans.trim()};`);
|
||||
}
|
||||
if (typography.fontMono && isSafeTokenValue(typography.fontMono)) {
|
||||
out.push(` --font-mono: ${typography.fontMono.trim()};`);
|
||||
}
|
||||
if (typography.fontDisplay && isSafeTokenValue(typography.fontDisplay)) {
|
||||
out.push(` --font-display: ${typography.fontDisplay.trim()};`);
|
||||
}
|
||||
if (typography.baseFontSize && isSafeTokenValue(typography.baseFontSize)) {
|
||||
out.push(` --font-size-base: ${typography.baseFontSize.trim()};`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const DENSITY_VARS: Record<ThemeDensity, Record<string, string>> = {
|
||||
compact: {
|
||||
'--density-row-height': '28px',
|
||||
'--density-control-height': '28px',
|
||||
'--density-spacing-1': '2px',
|
||||
'--density-spacing-2': '4px',
|
||||
'--density-spacing-3': '6px',
|
||||
},
|
||||
normal: {
|
||||
'--density-row-height': '36px',
|
||||
'--density-control-height': '32px',
|
||||
'--density-spacing-1': '4px',
|
||||
'--density-spacing-2': '8px',
|
||||
'--density-spacing-3': '12px',
|
||||
},
|
||||
touch: {
|
||||
'--density-row-height': '44px',
|
||||
'--density-control-height': '40px',
|
||||
'--density-spacing-1': '6px',
|
||||
'--density-spacing-2': '12px',
|
||||
'--density-spacing-3': '18px',
|
||||
},
|
||||
};
|
||||
|
||||
function emitDensity(density: ThemeDensity): string[] {
|
||||
return Object.entries(DENSITY_VARS[density]).map(([k, v]) => ` ${k}: ${v};`);
|
||||
}
|
||||
|
||||
export interface CompileOptions {
|
||||
/**
|
||||
* Resolves a `extends: <id>` chain to that base theme's compiled CSS.
|
||||
* Implementations should return null for unknown ids; circular refs are
|
||||
* the caller's problem (we don't recurse - just one level of inheritance).
|
||||
*/
|
||||
resolveExtends?: (id: string) => string | null;
|
||||
/**
|
||||
* Optional hand-written CSS appended after compiled tokens. Use this for
|
||||
* the rare overrides the structured API can't express (extra `@font-face`,
|
||||
* `@keyframes`, `@media (prefers-contrast)` blocks, etc.).
|
||||
*/
|
||||
userCSS?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile an advanced theme manifest into a single safe CSS string.
|
||||
*
|
||||
* Output layout:
|
||||
* 1. parent (extends) CSS, if any
|
||||
* 2. `:root { common + light + radii + typography + density }`
|
||||
* 3. `.dark { common + dark }` (only when the theme declares a dark variant)
|
||||
* 4. user-supplied `theme.css` content (sanitized upstream)
|
||||
*
|
||||
* The compiler never emits selectors other than `:root` and `.dark`, so the
|
||||
* existing CSS sanitizer/selector validator continues to apply.
|
||||
*/
|
||||
export function compileAdvancedTheme(
|
||||
manifest: ThemeManifest,
|
||||
opts: CompileOptions = {},
|
||||
): CompiledTheme {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isAdvancedManifest(manifest)) {
|
||||
return { css: '', warnings, errors: ['Manifest does not declare any advanced theme fields'] };
|
||||
}
|
||||
|
||||
const tokens: ThemeTokenSet = manifest.tokens ?? {};
|
||||
const derive = manifest.derive === true;
|
||||
const wantsDark = manifest.variants.includes('dark');
|
||||
const wantsLight = manifest.variants.includes('light');
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
// 1. extends - prepend parent CSS verbatim
|
||||
if (manifest.extends && opts.resolveExtends) {
|
||||
const parentCSS = opts.resolveExtends(manifest.extends);
|
||||
if (parentCSS == null) {
|
||||
warnings.push(`extends: parent theme "${manifest.extends}" not found - skipping`);
|
||||
} else {
|
||||
sections.push(`/* inherited from ${manifest.extends} */\n${parentCSS}`);
|
||||
}
|
||||
} else if (manifest.extends) {
|
||||
warnings.push(`extends: no resolver provided - "${manifest.extends}" ignored`);
|
||||
}
|
||||
|
||||
// 2. :root block (light + common + structural)
|
||||
const rootLines: string[] = [];
|
||||
|
||||
if (tokens.common) {
|
||||
const { lines, warnings: w } = emitTokens(tokens.common, derive);
|
||||
rootLines.push(...lines);
|
||||
warnings.push(...w);
|
||||
}
|
||||
if (wantsLight && tokens.light) {
|
||||
const { lines, warnings: w } = emitTokens(tokens.light, derive);
|
||||
rootLines.push(...lines);
|
||||
warnings.push(...w);
|
||||
}
|
||||
if (manifest.radii) rootLines.push(...emitRadii(manifest.radii));
|
||||
if (manifest.typography) rootLines.push(...emitTypography(manifest.typography));
|
||||
if (manifest.density) rootLines.push(...emitDensity(manifest.density));
|
||||
|
||||
if (rootLines.length > 0) {
|
||||
sections.push(`:root {\n${rootLines.join('\n')}\n}`);
|
||||
}
|
||||
|
||||
// 3. .dark block
|
||||
if (wantsDark) {
|
||||
const darkLines: string[] = [];
|
||||
if (tokens.common) {
|
||||
const { lines, warnings: w } = emitTokens(tokens.common, derive);
|
||||
darkLines.push(...lines);
|
||||
warnings.push(...w);
|
||||
}
|
||||
if (tokens.dark) {
|
||||
const { lines, warnings: w } = emitTokens(tokens.dark, derive);
|
||||
darkLines.push(...lines);
|
||||
warnings.push(...w);
|
||||
}
|
||||
if (darkLines.length > 0) {
|
||||
sections.push(`.dark {\n${darkLines.join('\n')}\n}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. hand-written overrides
|
||||
if (opts.userCSS && opts.userCSS.trim()) {
|
||||
sections.push(`/* user overrides */\n${opts.userCSS.trim()}`);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
errors.push('Compiled theme is empty - no tokens, radii, typography, or density supplied');
|
||||
}
|
||||
|
||||
return {
|
||||
css: sections.join('\n\n'),
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a manifest opts into Theme API v2 by setting `apiVersion: 2` or by
|
||||
* declaring any of the structured fields.
|
||||
*/
|
||||
export function isAdvancedManifest(manifest: ThemeManifest): boolean {
|
||||
return (
|
||||
manifest.apiVersion === 2 ||
|
||||
!!manifest.tokens ||
|
||||
!!manifest.extends ||
|
||||
!!manifest.derive ||
|
||||
!!manifest.density ||
|
||||
!!manifest.radii ||
|
||||
!!manifest.typography
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { DISALLOWED_CSS_PATTERNS } from './plugin-types';
|
||||
|
||||
const THEME_STYLE_ID = 'active-theme';
|
||||
const THEME_SKIN_STYLE_ID = 'active-theme-skin';
|
||||
const THEME_SKIN_BODY_ATTR = 'data-theme-skin';
|
||||
|
||||
/**
|
||||
* Sanitize theme CSS: strip dangerous patterns like @import, external url(),
|
||||
@@ -88,6 +90,67 @@ export function removeThemeCSS(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a theme's *skin* CSS - component-level overrides shipped by Theme
|
||||
* API v2 themes via `skin.css`. Lives in a separate `<style>` tag so it can
|
||||
* be removed cleanly without touching the colour-token block, and is placed
|
||||
* AFTER the colour block so component rules win specificity.
|
||||
*
|
||||
* Also sets `body[data-theme-skin="<themeId>"]` so authors can scope their
|
||||
* own `:not(...)` overrides if they want belt-and-braces specificity.
|
||||
*/
|
||||
export function injectThemeSkinCSS(css: string, themeId: string): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
let styleEl = document.getElementById(THEME_SKIN_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = THEME_SKIN_STYLE_ID;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = css;
|
||||
|
||||
if (document.body) {
|
||||
document.body.setAttribute(THEME_SKIN_BODY_ATTR, themeId);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeThemeSkinCSS(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const styleEl = document.getElementById(THEME_SKIN_STYLE_ID);
|
||||
if (styleEl) styleEl.remove();
|
||||
if (document.body) document.body.removeAttribute(THEME_SKIN_BODY_ATTR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a theme *skin* - looser than `sanitizeThemeCSS` because skins
|
||||
* intentionally target real component selectors (toolbars, lists, buttons),
|
||||
* not just `:root`/`.dark`. The same script-injection / external-resource
|
||||
* prohibitions still apply.
|
||||
*/
|
||||
export function sanitizeSkinCSS(css: string): { css: string; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
let cleaned = css;
|
||||
|
||||
for (const pattern of DISALLOWED_CSS_PATTERNS) {
|
||||
if (pattern.test(cleaned)) {
|
||||
warnings.push(`Skin: removed disallowed pattern: ${pattern.source}`);
|
||||
cleaned = cleaned.replace(new RegExp(pattern.source, 'gi'), '/* [removed] */');
|
||||
}
|
||||
}
|
||||
|
||||
// `@import` is already covered by DISALLOWED_CSS_PATTERNS, but skins also
|
||||
// get an explicit no-`@charset`/`@namespace` policy so they can't change
|
||||
// how the host stylesheet parses subsequent rules.
|
||||
cleaned = cleaned.replace(/@(charset|namespace)\b[^;]*;?/gi, () => {
|
||||
warnings.push('Skin: removed @charset/@namespace directive');
|
||||
return '/* [removed] */';
|
||||
});
|
||||
|
||||
return { css: cleaned, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a theme CSS string is valid and safe.
|
||||
*/
|
||||
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
// Browser-side Web Push setup. Mirrors the React Native flow in
|
||||
// repos/react-native/src/lib/push-notifications.ts so the relay sees the same
|
||||
// shape from both clients - the only differences are which native API
|
||||
// produces the push token (PushManager.subscribe here, FCM there) and which
|
||||
// register endpoint we hit on the relay.
|
||||
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
const DEVICE_CLIENT_ID_KEY = 'bulwark.push.deviceClientId.v1';
|
||||
const SUBSCRIPTION_ID_KEY = 'bulwark.push.subscriptionId.v1';
|
||||
|
||||
// Hosted relay so self-hosters don't need their own VAPID + Firebase setup.
|
||||
// Override at build time via NEXT_PUBLIC_PUSH_RELAY_URL or at runtime by
|
||||
// calling enableWebPush({ relayBaseUrl }) from the settings UI.
|
||||
export const DEFAULT_RELAY_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_PUSH_RELAY_URL || 'https://notifications.relay.bulwarkmail.org';
|
||||
|
||||
// Match the mobile app's lifetime hint. The JMAP server may clamp this down.
|
||||
const SUBSCRIPTION_EXPIRES_DAYS = 90;
|
||||
const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
|
||||
|
||||
// Only `EmailDelivery` state-changes when new mail is actually delivered.
|
||||
// `Email` fires for any mutation (sending, drafting, moving, marking read,
|
||||
// deleting) and `Mailbox` fires for mailbox edits — both produced spurious
|
||||
// system notifications, so we keep them out of the push subscription.
|
||||
// In-app sync uses a separate StateChange channel and is unaffected.
|
||||
const PUSH_TYPES = ['EmailDelivery'] as const;
|
||||
|
||||
function sameTypes(a: readonly string[] | null | undefined, b: readonly string[]): boolean {
|
||||
if (!a || a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((t, i) => t === sortedB[i]);
|
||||
}
|
||||
|
||||
export interface EnableWebPushParams {
|
||||
client: IJMAPClient;
|
||||
// Optional - falls back to DEFAULT_RELAY_BASE_URL.
|
||||
relayBaseUrl?: string;
|
||||
// Free-form label the relay shows in /metrics; never returned in pushes.
|
||||
accountLabel?: string;
|
||||
}
|
||||
|
||||
export interface EnableWebPushResult {
|
||||
subscriptionId: string;
|
||||
}
|
||||
|
||||
export class WebPushUnsupportedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'WebPushUnsupportedError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isWebPushSupported(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return (
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
);
|
||||
}
|
||||
|
||||
function buildRelayUrl(base: string, suffix: string): string {
|
||||
return base.replace(/\/+$/, '') + suffix;
|
||||
}
|
||||
|
||||
function expiresFromNow(days: number): string {
|
||||
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function randomDeviceClientId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function getOrCreateDeviceClientId(): string {
|
||||
const existing = localStorage.getItem(DEVICE_CLIENT_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const next = randomDeviceClientId();
|
||||
localStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
// PushManager.subscribe wants the VAPID public key as a BufferSource.
|
||||
// Returning a Uint8Array<ArrayBuffer> (not the wider ArrayBufferLike that
|
||||
// includes SharedArrayBuffer) keeps strict TS happy on lib.dom 2024+.
|
||||
function urlBase64ToUint8Array(base64Url: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
|
||||
const base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
const buffer = new ArrayBuffer(raw.length);
|
||||
const out = new Uint8Array(buffer);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function readPushKey(
|
||||
sub: PushSubscription,
|
||||
name: 'p256dh' | 'auth',
|
||||
): string {
|
||||
const raw = sub.getKey(name);
|
||||
if (!raw) throw new Error(`PushSubscription is missing the ${name} key`);
|
||||
// Browsers want application/json over the wire so encode as base64url.
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(raw);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async function fetchVapidPublicKey(relayBaseUrl: string): Promise<string> {
|
||||
const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key'));
|
||||
if (!res.ok) {
|
||||
if (res.status === 503) {
|
||||
throw new Error('The push relay does not have Web Push configured');
|
||||
}
|
||||
throw new Error(`Failed to fetch VAPID key: ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as { publicKey?: string };
|
||||
if (!body.publicKey) throw new Error('Relay returned an empty VAPID key');
|
||||
return body.publicKey;
|
||||
}
|
||||
|
||||
async function ensurePermission(): Promise<void> {
|
||||
if (Notification.permission === 'granted') return;
|
||||
if (Notification.permission === 'denied') {
|
||||
throw new Error('Notifications are blocked - allow them in browser settings to continue');
|
||||
}
|
||||
const result = await Notification.requestPermission();
|
||||
if (result !== 'granted') {
|
||||
throw new Error('Notification permission was not granted');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureServiceWorker(): Promise<ServiceWorkerRegistration> {
|
||||
// The webmail's PWA already registers /sw.js for installability. If it
|
||||
// hasn't been picked up yet (e.g. first load), kick it ourselves so the
|
||||
// push handler is in place.
|
||||
let registration = await navigator.serviceWorker.getRegistration('/');
|
||||
if (!registration) {
|
||||
registration = await navigator.serviceWorker.register('/sw.js');
|
||||
}
|
||||
await navigator.serviceWorker.ready;
|
||||
return registration;
|
||||
}
|
||||
|
||||
async function registerWithRelay(params: {
|
||||
relayBaseUrl: string;
|
||||
subscriptionId: string;
|
||||
// Subset of PushSubscriptionJSON we actually serialise. Inlined so eslint's
|
||||
// no-undef rule (which doesn't know about DOM type-only globals) is happy.
|
||||
subscription: {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
};
|
||||
accountLabel?: string;
|
||||
}): Promise<void> {
|
||||
const { endpoint, keys } = params.subscription;
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
throw new Error('Browser returned an incomplete PushSubscription');
|
||||
}
|
||||
const res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/web'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subscriptionId: params.subscriptionId,
|
||||
subscription: { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } },
|
||||
accountLabel: params.accountLabel,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Relay register failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollVerificationCode(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
): Promise<string> {
|
||||
// Stalwart per-account rate-limits PushVerification posts (default 60s).
|
||||
// If there are leftover unverified subscriptions on the account, our new
|
||||
// one queues up behind them - so we wait long enough to clear one verify
|
||||
// window even in the unlucky case.
|
||||
const timeoutAt = Date.now() + 75_000;
|
||||
let delay = 400;
|
||||
while (Date.now() < timeoutAt) {
|
||||
const res = await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
|
||||
);
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { verificationCode?: string | null };
|
||||
if (body.verificationCode) return body.verificationCode;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
delay = Math.min(delay * 1.5, 2000);
|
||||
}
|
||||
throw new Error('Timed out waiting for PushVerification from the JMAP server');
|
||||
}
|
||||
|
||||
async function refreshSubscriptionExpires(
|
||||
client: IJMAPClient,
|
||||
sub: { id: string; expires: string | null; types: string[] | null },
|
||||
): Promise<boolean> {
|
||||
const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES);
|
||||
if (!typesNeedUpdate && sub.expires) {
|
||||
const remainingMs = new Date(sub.expires).getTime() - Date.now();
|
||||
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) return true;
|
||||
}
|
||||
try {
|
||||
const patch: { expires?: string; types?: string[] } = {
|
||||
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
||||
};
|
||||
if (typesNeedUpdate) patch.types = [...PUSH_TYPES];
|
||||
return await client.updatePushSubscription(sub.id, patch);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableWebPush(
|
||||
params: EnableWebPushParams,
|
||||
): Promise<EnableWebPushResult> {
|
||||
if (!isWebPushSupported()) {
|
||||
throw new WebPushUnsupportedError(
|
||||
'This browser does not support Web Push. On iOS the site needs to be installed to the home screen.',
|
||||
);
|
||||
}
|
||||
|
||||
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
if (!relayBaseUrl) throw new Error('relayBaseUrl is required');
|
||||
|
||||
await ensurePermission();
|
||||
const registration = await ensureServiceWorker();
|
||||
|
||||
const vapidPublicKey = await fetchVapidPublicKey(relayBaseUrl);
|
||||
|
||||
// Reuse an existing browser PushSubscription when possible - resubscribing
|
||||
// with the same VAPID key produces the same endpoint, but the call still
|
||||
// costs a network round-trip the user can feel.
|
||||
let pushSubscription = await registration.pushManager.getSubscription();
|
||||
if (pushSubscription) {
|
||||
const keyMatches = pushSubscription.options?.applicationServerKey;
|
||||
if (!keyMatches) {
|
||||
await pushSubscription.unsubscribe();
|
||||
pushSubscription = null;
|
||||
}
|
||||
}
|
||||
if (!pushSubscription) {
|
||||
pushSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
}
|
||||
|
||||
const deviceClientId = getOrCreateDeviceClientId();
|
||||
|
||||
await registerWithRelay({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
subscription: {
|
||||
endpoint: pushSubscription.endpoint,
|
||||
keys: {
|
||||
p256dh: readPushKey(pushSubscription, 'p256dh'),
|
||||
auth: readPushKey(pushSubscription, 'auth'),
|
||||
},
|
||||
},
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
|
||||
// Reuse the JMAP-side PushSubscription if the server still has it, just
|
||||
// refreshing the expiry so it doesn't time out between sessions.
|
||||
const existingSubs = await params.client.listPushSubscriptions().catch(() => []);
|
||||
const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
if (storedServerId) {
|
||||
const match = existingSubs.find((s) => s.id === storedServerId);
|
||||
if (match) {
|
||||
const refreshed = await refreshSubscriptionExpires(params.client, match);
|
||||
if (refreshed) return { subscriptionId: storedServerId };
|
||||
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
|
||||
}
|
||||
localStorage.removeItem(SUBSCRIPTION_ID_KEY);
|
||||
}
|
||||
|
||||
// Reap any leftover subscriptions still bound to this device. These pile
|
||||
// up when a previous enable attempt failed mid-flow (verification timed
|
||||
// out, browser tab closed, etc). Stalwart per-account rate-limits
|
||||
// verification posts, so leaving stragglers around blocks the new one.
|
||||
const stragglers = existingSubs.filter(
|
||||
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
|
||||
);
|
||||
for (const s of stragglers) {
|
||||
await params.client.destroyPushSubscription(s.id).catch(() => undefined);
|
||||
}
|
||||
|
||||
const serverAssignedId = await params.client.createPushSubscription({
|
||||
deviceClientId,
|
||||
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
|
||||
types: [...PUSH_TYPES],
|
||||
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
||||
});
|
||||
|
||||
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
|
||||
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);
|
||||
localStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
|
||||
|
||||
return { subscriptionId: serverAssignedId };
|
||||
}
|
||||
|
||||
export interface DisableWebPushParams {
|
||||
client: IJMAPClient;
|
||||
relayBaseUrl?: string;
|
||||
}
|
||||
|
||||
// Best-effort teardown: clear the JMAP subscription, the relay mapping, and
|
||||
// the browser PushSubscription. Any single failure is swallowed so the user
|
||||
// always ends up in a "disabled" state locally.
|
||||
export async function disableWebPush(params: DisableWebPushParams): Promise<void> {
|
||||
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
|
||||
const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
if (storedServerId) {
|
||||
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
|
||||
localStorage.removeItem(SUBSCRIPTION_ID_KEY);
|
||||
}
|
||||
|
||||
const deviceClientId = localStorage.getItem(DEVICE_CLIENT_ID_KEY);
|
||||
if (deviceClientId && relayBaseUrl) {
|
||||
await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
||||
const registration = await navigator.serviceWorker.getRegistration('/');
|
||||
const sub = await registration?.pushManager.getSubscription();
|
||||
if (sub) await sub.unsubscribe().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isWebPushEnabled(): Promise<boolean> {
|
||||
if (!isWebPushSupported()) return false;
|
||||
if (Notification.permission !== 'granted') return false;
|
||||
const registration = await navigator.serviceWorker.getRegistration('/');
|
||||
if (!registration) return false;
|
||||
const sub = await registration.pushManager.getSubscription();
|
||||
return sub !== null && localStorage.getItem(SUBSCRIPTION_ID_KEY) !== null;
|
||||
}
|
||||
+53
-9
@@ -127,6 +127,7 @@
|
||||
"demo_tour": "Průvodce",
|
||||
"tags": "Štítky",
|
||||
"folders": "Složky",
|
||||
"shared": "Sdílené",
|
||||
"mail": "Pošta",
|
||||
"nav_label": "Navigace",
|
||||
"add_app": "Aplikace"
|
||||
@@ -426,8 +427,8 @@
|
||||
"event_updated": "Aktualizace #{sequence}",
|
||||
"event_status_tentative": "Nezávazně",
|
||||
"event_status_cancelled": "Zrušeno",
|
||||
"expand": "Zobrazit detaily",
|
||||
"collapse": "Skrýt detaily"
|
||||
"expand": "Zobrazit podrobnosti",
|
||||
"collapse": "Skrýt podrobnosti"
|
||||
},
|
||||
"send": "Odeslat",
|
||||
"more": "více"
|
||||
@@ -509,7 +510,10 @@
|
||||
"message": "Vaše zpráva obsahuje slovo \"{keyword}\", ale není k ní připojen žádný soubor. Přesto odeslat?",
|
||||
"send_anyway": "Přesto odeslat",
|
||||
"back": "Zpět k úpravám"
|
||||
}
|
||||
},
|
||||
"add_link": "Přidat odkaz",
|
||||
"link_url_prompt": "Zadejte URL",
|
||||
"sending": "Odesílání..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Potvrdit",
|
||||
@@ -602,7 +606,6 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Jazyk",
|
||||
"czech": "Česky",
|
||||
"english": "English",
|
||||
"french": "Français",
|
||||
"japanese": "日本語",
|
||||
@@ -614,7 +617,6 @@
|
||||
"portuguese": "Português",
|
||||
"russian": "Русский",
|
||||
"select_language": "Vybrat jazyk",
|
||||
"switch_to_czech": "Přepnout na češtinu",
|
||||
"switch_to_english": "Přepnout na angličtinu",
|
||||
"switch_to_french": "Přepnout na francouzštinu",
|
||||
"switch_to_japanese": "Přepnout na japonštinu",
|
||||
@@ -804,6 +806,23 @@
|
||||
"sound_desc": "Přehrát zvukové upozornění pro připomenutí kalendáře",
|
||||
"invitation_parsing": "Rozpoznávat e-mailové pozvánky",
|
||||
"invitation_parsing_desc": "Rozpoznávat pozvánky kalendáře v přílohách e-mailů a zobrazovat akce kalendáře"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Toto zařízení přestane přijímat upozornění, když je web zavřený.",
|
||||
"confirm_disable_title": "Zakázat oznámení na pozadí?",
|
||||
"description": "Přijímat systémová oznámení o nové poště, když je tento web zavřený. Doručováno přes push relay Bulwark; relay nikdy nevidí obsah pošty.",
|
||||
"disable": "Zakázat",
|
||||
"enable": "Povolit",
|
||||
"ios_hint": "Na iOS nejprve nainstalujte web na domovskou obrazovku – Safari doručuje Web Push pouze nainstalovaným PWA.",
|
||||
"reenable": "Znovu zaregistrovat",
|
||||
"relay_desc": "Výchozí je hostovaný relay Bulwark. Změňte pouze pokud používáte vlastní hosting.",
|
||||
"relay_label": "Push relay",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Aktivní na tomto zařízení",
|
||||
"status_busy": "Pracuji…",
|
||||
"status_inactive": "Není povoleno na tomto zařízení",
|
||||
"status_unsupported": "Tento prohlížeč nepodporuje Web Push",
|
||||
"title": "Oznámení na pozadí"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -892,6 +911,13 @@
|
||||
"label": "Automaticky vybírat adresu pro odpověď",
|
||||
"description": "Při odpovídání automaticky přepnout adresu odesílatele na identitu, která původně obdržela zprávu"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Oddělovač sub-adresy",
|
||||
"description": "Znak oddělující uživatelské jméno od sub-adresy. Zvolte oddělovač používaný vaším poštovním serverem (např. uzivatel{delimiter}stitek@domena.cz).",
|
||||
"option": "{delimiter} (uzivatel{delimiter}stitek@domena.cz)",
|
||||
"custom": "Vlastní…",
|
||||
"custom_input_label": "Vlastní znak oddělovače"
|
||||
},
|
||||
"attachment_click_action": {
|
||||
"label": "Akce po kliknutí na přílohu",
|
||||
"description": "Vyberte, zda má kliknutí na přílohu zobrazit náhled, nebo ji ihned stáhnout",
|
||||
@@ -1327,8 +1353,8 @@
|
||||
"contacts": {
|
||||
"title": "Kontakty",
|
||||
"description": "Import a export kontaktů",
|
||||
"group_by_letter_label": "Seskupit podle prvního písmene",
|
||||
"group_by_letter_description": "Zobrazovat abecední nadpisy v seznamu kontaktů",
|
||||
"group_by_letter_label": "Seskupit podle prvního písmena",
|
||||
"group_by_letter_description": "Zobrazit záhlaví sekcí podle abecedy v seznamu kontaktů",
|
||||
"import_label": "Importovat kontakty",
|
||||
"import_description": "Importovat kontakty ze souboru vCard (.vcf)",
|
||||
"export_label": "Exportovat kontakty",
|
||||
@@ -1737,7 +1763,7 @@
|
||||
"use_address": "Použít tuto adresu",
|
||||
"invalid_tag": "Štítek může obsahovat pouze písmena, číslice a pomlčky",
|
||||
"tag_too_long": "Štítek může mít maximálně 30 znaků",
|
||||
"help_text": "Zprávy odeslané na adresu uzivatel+stitek@domena.cz budou doručeny do vaší doručené pošty",
|
||||
"help_text": "Zprávy odeslané na adresu uzivatel{delimiter}stitek@domena.cz budou doručeny do vaší doručené pošty",
|
||||
"validation": {
|
||||
"empty": "Štítek nesmí být prázdný",
|
||||
"too_long": "Štítek může mít maximálně {max} znaků",
|
||||
@@ -2134,7 +2160,11 @@
|
||||
"tomorrow_header": "Zítra",
|
||||
"export_ics": "Exportovat jako .ics",
|
||||
"copy_title": "Kopírovat název",
|
||||
"copy_link": "Kopírovat odkaz na schůzku"
|
||||
"copy_link": "Kopírovat odkaz na schůzku",
|
||||
"go_to_today": "Přejít na dnešek",
|
||||
"new_all_day_event": "Nová celodenní událost",
|
||||
"new_event": "Nová událost",
|
||||
"new_task": "Nový úkol"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Přidat poznámku...",
|
||||
@@ -2433,6 +2463,20 @@
|
||||
"due_today": "Dnes",
|
||||
"due_tomorrow": "Zítra",
|
||||
"overdue": "Po termínu"
|
||||
},
|
||||
"months": {
|
||||
"jan": "led",
|
||||
"feb": "úno",
|
||||
"mar": "bře",
|
||||
"apr": "dub",
|
||||
"may": "kvě",
|
||||
"jun": "čvn",
|
||||
"jul": "čvc",
|
||||
"aug": "srp",
|
||||
"sep": "zář",
|
||||
"oct": "říj",
|
||||
"nov": "lis",
|
||||
"dec": "pro"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
+54
-5
@@ -424,7 +424,9 @@
|
||||
"cancel_info": "Der Organisator hat diesen Termin abgesagt.",
|
||||
"event_updated": "Aktualisierung #{sequence}",
|
||||
"event_status_tentative": "Vorläufig",
|
||||
"event_status_cancelled": "Abgesagt"
|
||||
"event_status_cancelled": "Abgesagt",
|
||||
"collapse": "Details ausblenden",
|
||||
"expand": "Details anzeigen"
|
||||
},
|
||||
"previous": "Zurück",
|
||||
"next": "Weiter",
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "Ihre Nachricht enthält \"{keyword}\", aber es ist keine Datei angehängt. Trotzdem senden?",
|
||||
"send_anyway": "Trotzdem senden",
|
||||
"back": "Zurück zur Bearbeitung"
|
||||
}
|
||||
},
|
||||
"add_link": "Link hinzufügen",
|
||||
"link_url_prompt": "URL eingeben",
|
||||
"sending": "Wird gesendet..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Bestätigen",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "Einen Ton für Kalendererinnerungen abspielen",
|
||||
"invitation_parsing": "E-Mail-Einladungen erkennen",
|
||||
"invitation_parsing_desc": "Kalendereinladungen in E-Mail-Anhängen erkennen und Kalenderaktionen anzeigen"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Dieses Gerät erhält keine Benachrichtigungen mehr, wenn die Seite geschlossen ist.",
|
||||
"confirm_disable_title": "Hintergrundbenachrichtigungen deaktivieren?",
|
||||
"description": "Systembenachrichtigungen für neue E-Mails empfangen, wenn diese Seite geschlossen ist. Zustellung über das Bulwark Push-Relay; das Relay sieht niemals E-Mail-Inhalte.",
|
||||
"disable": "Deaktivieren",
|
||||
"enable": "Aktivieren",
|
||||
"ios_hint": "Installieren Sie die Seite unter iOS zuerst auf dem Startbildschirm – Safari liefert Web Push nur an installierte PWAs.",
|
||||
"reenable": "Neu registrieren",
|
||||
"relay_desc": "Standardmäßig wird das gehostete Bulwark-Relay verwendet. Nur ändern, wenn Sie selbst hosten.",
|
||||
"relay_label": "Push-Relay",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Auf diesem Gerät aktiv",
|
||||
"status_busy": "Wird verarbeitet…",
|
||||
"status_inactive": "Auf diesem Gerät nicht aktiviert",
|
||||
"status_unsupported": "Dieser Browser unterstützt Web Push nicht",
|
||||
"title": "Hintergrundbenachrichtigungen"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -889,6 +911,13 @@
|
||||
"label": "Antwortadresse automatisch wählen",
|
||||
"description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Sub-Adress-Trennzeichen",
|
||||
"description": "Zeichen, das Ihren Benutzernamen vom Sub-Adress-Tag trennt. Verwenden Sie das von Ihrem Mailserver verwendete Trennzeichen (z. B. benutzer{delimiter}tag@domain.de).",
|
||||
"option": "{delimiter} (benutzer{delimiter}tag@domain.de)",
|
||||
"custom": "Benutzerdefiniert…",
|
||||
"custom_input_label": "Benutzerdefiniertes Trennzeichen"
|
||||
},
|
||||
"attachment_click_action": {
|
||||
"label": "Aktion beim Klick auf Anhänge",
|
||||
"description": "Festlegen, ob ein Dateianhang beim Anklicken in der Vorschau geöffnet oder sofort heruntergeladen wird",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "Keine Adressbücher gefunden",
|
||||
"categories_title": "Kategorien",
|
||||
"categories_description": "Kontaktkategorien umbenennen",
|
||||
"no_categories": "Keine Kategorien gefunden"
|
||||
"no_categories": "Keine Kategorien gefunden",
|
||||
"group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen",
|
||||
"group_by_letter_label": "Nach Anfangsbuchstaben gruppieren"
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-Mail-Filter",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "Diese Adresse verwenden",
|
||||
"invalid_tag": "Tag darf nur alphanumerisch und Bindestriche enthalten",
|
||||
"tag_too_long": "Tag darf maximal 30 Zeichen lang sein",
|
||||
"help_text": "E-Mails an benutzer+tag@domain.de werden in Ihrem Posteingang ankommen",
|
||||
"help_text": "E-Mails an benutzer{delimiter}tag@domain.de werden in Ihrem Posteingang ankommen",
|
||||
"validation": {
|
||||
"empty": "Tag darf nicht leer sein",
|
||||
"too_long": "Tag darf maximal {max} Zeichen lang sein",
|
||||
@@ -2129,7 +2160,11 @@
|
||||
"tomorrow_header": "Morgen",
|
||||
"export_ics": "Als .ics exportieren",
|
||||
"copy_title": "Titel kopieren",
|
||||
"copy_link": "Meeting-Link kopieren"
|
||||
"copy_link": "Meeting-Link kopieren",
|
||||
"go_to_today": "Zu heute",
|
||||
"new_all_day_event": "Neuer ganztägiger Termin",
|
||||
"new_event": "Neuer Termin",
|
||||
"new_task": "Neue Aufgabe"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Notiz hinzufügen...",
|
||||
@@ -2428,6 +2463,20 @@
|
||||
"due_today": "Heute fällig",
|
||||
"due_tomorrow": "Morgen fällig",
|
||||
"overdue": "Überfällig"
|
||||
},
|
||||
"months": {
|
||||
"jan": "Jan",
|
||||
"feb": "Feb",
|
||||
"mar": "Mär",
|
||||
"apr": "Apr",
|
||||
"may": "Mai",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Aug",
|
||||
"sep": "Sep",
|
||||
"oct": "Okt",
|
||||
"nov": "Nov",
|
||||
"dec": "Dez"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
+44
-2
@@ -783,6 +783,23 @@
|
||||
"swift": "Swift Gesture",
|
||||
"relax": "Relax"
|
||||
},
|
||||
"push": {
|
||||
"title": "Background Notifications",
|
||||
"description": "Receive system notifications for new mail when this site is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
|
||||
"relay_label": "Push relay",
|
||||
"relay_desc": "Defaults to the hosted Bulwark relay. Change only if you self-host.",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Active on this device",
|
||||
"status_inactive": "Not enabled on this device",
|
||||
"status_unsupported": "This browser does not support Web Push",
|
||||
"status_busy": "Working…",
|
||||
"enable": "Enable",
|
||||
"reenable": "Re-register",
|
||||
"disable": "Disable",
|
||||
"confirm_disable_title": "Disable background notifications?",
|
||||
"confirm_disable_message": "This device will stop receiving alerts when the site is closed.",
|
||||
"ios_hint": "On iOS, install the site to your home screen first - Safari only delivers Web Push to installed PWAs."
|
||||
},
|
||||
"sound_selection": {
|
||||
"title": "Notification Sound",
|
||||
"description": "Choose which sound to play for notifications",
|
||||
@@ -894,6 +911,13 @@
|
||||
"label": "Auto-select Reply Address",
|
||||
"description": "When replying, automatically switch the From address to the identity that originally received the message"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Sub-Address Delimiter",
|
||||
"description": "Character separating your username from a sub-address tag. Match the delimiter your mail server uses (e.g. user{delimiter}tag@domain.com).",
|
||||
"option": "{delimiter} (user{delimiter}tag@domain.com)",
|
||||
"custom": "Custom…",
|
||||
"custom_input_label": "Custom delimiter character"
|
||||
},
|
||||
"attachment_click_action": {
|
||||
"label": "Attachment Click Action",
|
||||
"description": "Choose whether clicking a file attachment previews it or downloads it immediately",
|
||||
@@ -1739,7 +1763,7 @@
|
||||
"use_address": "Use This Address",
|
||||
"invalid_tag": "Tag must be alphanumeric and dashes only",
|
||||
"tag_too_long": "Tag must be 30 characters or less",
|
||||
"help_text": "Emails sent to user+tag@domain.com will arrive in your inbox",
|
||||
"help_text": "Emails sent to user{delimiter}tag@domain.com will arrive in your inbox",
|
||||
"validation": {
|
||||
"empty": "Tag cannot be empty",
|
||||
"too_long": "Tag must be {max} characters or less",
|
||||
@@ -2136,7 +2160,11 @@
|
||||
"tomorrow_header": "Tomorrow",
|
||||
"export_ics": "Export as .ics",
|
||||
"copy_title": "Copy title",
|
||||
"copy_link": "Copy meeting link"
|
||||
"copy_link": "Copy meeting link",
|
||||
"new_event": "New event",
|
||||
"new_all_day_event": "New all-day event",
|
||||
"new_task": "New task",
|
||||
"go_to_today": "Go to today"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Add a note...",
|
||||
@@ -2268,6 +2296,20 @@
|
||||
"sat": "Sat",
|
||||
"sun": "Sun"
|
||||
},
|
||||
"months": {
|
||||
"jan": "Jan",
|
||||
"feb": "Feb",
|
||||
"mar": "Mar",
|
||||
"apr": "Apr",
|
||||
"may": "May",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Aug",
|
||||
"sep": "Sep",
|
||||
"oct": "Oct",
|
||||
"nov": "Nov",
|
||||
"dec": "Dec"
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "Event created",
|
||||
"event_updated": "Event updated",
|
||||
|
||||
+54
-5
@@ -424,7 +424,9 @@
|
||||
"cancel_info": "El organizador ha cancelado este evento.",
|
||||
"event_updated": "Actualización #{sequence}",
|
||||
"event_status_tentative": "Provisional",
|
||||
"event_status_cancelled": "Cancelado"
|
||||
"event_status_cancelled": "Cancelado",
|
||||
"collapse": "Ocultar detalles",
|
||||
"expand": "Mostrar detalles"
|
||||
},
|
||||
"previous": "Anterior",
|
||||
"next": "Siguiente",
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "Tu mensaje menciona \"{keyword}\" pero no hay ningún archivo adjunto. ¿Enviar de todos modos?",
|
||||
"send_anyway": "Enviar de todos modos",
|
||||
"back": "Volver a editar"
|
||||
}
|
||||
},
|
||||
"add_link": "Añadir enlace",
|
||||
"link_url_prompt": "Introduce la URL",
|
||||
"sending": "Enviando..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmar",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "Reproducir un sonido para recordatorios del calendario",
|
||||
"invitation_parsing": "Analizar invitaciones por correo",
|
||||
"invitation_parsing_desc": "Detectar invitaciones de calendario en archivos adjuntos y mostrar acciones de calendario"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Este dispositivo dejará de recibir alertas cuando el sitio esté cerrado.",
|
||||
"confirm_disable_title": "¿Desactivar las notificaciones en segundo plano?",
|
||||
"description": "Recibe notificaciones del sistema para correo nuevo cuando este sitio está cerrado. Se entrega a través del relay push de Bulwark; el relay nunca ve el contenido del correo.",
|
||||
"disable": "Desactivar",
|
||||
"enable": "Activar",
|
||||
"ios_hint": "En iOS, instala primero el sitio en la pantalla de inicio: Safari solo entrega Web Push a PWAs instaladas.",
|
||||
"reenable": "Volver a registrar",
|
||||
"relay_desc": "Usa el relay alojado de Bulwark de forma predeterminada. Cámbialo solo si te alojas tú mismo.",
|
||||
"relay_label": "Relay push",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Activo en este dispositivo",
|
||||
"status_busy": "Trabajando…",
|
||||
"status_inactive": "No habilitado en este dispositivo",
|
||||
"status_unsupported": "Este navegador no admite Web Push",
|
||||
"title": "Notificaciones en segundo plano"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -884,6 +906,13 @@
|
||||
"label": "Seleccionar dirección de respuesta automáticamente",
|
||||
"description": "Al responder, cambia automáticamente la dirección del remitente a la identidad que recibió el mensaje original"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Delimitador de sub-dirección",
|
||||
"description": "Carácter que separa tu nombre de usuario de la etiqueta de sub-dirección. Usa el delimitador que utilice tu servidor de correo (por ejemplo, usuario{delimiter}etiqueta@dominio.com).",
|
||||
"option": "{delimiter} (usuario{delimiter}etiqueta@dominio.com)",
|
||||
"custom": "Personalizado…",
|
||||
"custom_input_label": "Carácter delimitador personalizado"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Mostrar Vista Previa",
|
||||
"description": "Mostrar vista previa del correo en la lista",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "No se encontraron libretas de direcciones",
|
||||
"categories_title": "Categorías",
|
||||
"categories_description": "Renombrar categorías de contactos",
|
||||
"no_categories": "No se encontraron categorías"
|
||||
"no_categories": "No se encontraron categorías",
|
||||
"group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos",
|
||||
"group_by_letter_label": "Agrupar por primera letra"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtros de correo",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "Usar Esta Dirección",
|
||||
"invalid_tag": "La etiqueta debe ser solo alfanumérica y guiones",
|
||||
"tag_too_long": "La etiqueta debe tener 30 caracteres o menos",
|
||||
"help_text": "Los correos enviados a usuario+etiqueta@dominio.com llegarán a su bandeja de entrada",
|
||||
"help_text": "Los correos enviados a usuario{delimiter}etiqueta@dominio.com llegarán a su bandeja de entrada",
|
||||
"validation": {
|
||||
"empty": "La etiqueta no puede estar vacía",
|
||||
"too_long": "La etiqueta debe tener {max} caracteres o menos",
|
||||
@@ -2129,7 +2160,11 @@
|
||||
"tomorrow_header": "Mañana",
|
||||
"export_ics": "Exportar como .ics",
|
||||
"copy_title": "Copiar título",
|
||||
"copy_link": "Copiar enlace de reunión"
|
||||
"copy_link": "Copiar enlace de reunión",
|
||||
"go_to_today": "Ir a hoy",
|
||||
"new_all_day_event": "Nuevo evento de todo el día",
|
||||
"new_event": "Nuevo evento",
|
||||
"new_task": "Nueva tarea"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Añadir una nota...",
|
||||
@@ -2428,6 +2463,20 @@
|
||||
"due_today": "Vence hoy",
|
||||
"due_tomorrow": "Vence mañana",
|
||||
"overdue": "Vencida"
|
||||
},
|
||||
"months": {
|
||||
"jan": "Ene",
|
||||
"feb": "Feb",
|
||||
"mar": "Mar",
|
||||
"apr": "Abr",
|
||||
"may": "May",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Ago",
|
||||
"sep": "Sep",
|
||||
"oct": "Oct",
|
||||
"nov": "Nov",
|
||||
"dec": "Dic"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
+54
-5
@@ -424,7 +424,9 @@
|
||||
"cancel_info": "L'organisateur a annulé cet événement.",
|
||||
"event_updated": "Mise à jour #{sequence}",
|
||||
"event_status_tentative": "Provisoire",
|
||||
"event_status_cancelled": "Annulé"
|
||||
"event_status_cancelled": "Annulé",
|
||||
"collapse": "Masquer les détails",
|
||||
"expand": "Afficher les détails"
|
||||
},
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "Votre message mentionne \"{keyword}\" mais aucun fichier n'est joint. Envoyer quand même ?",
|
||||
"send_anyway": "Envoyer quand même",
|
||||
"back": "Retour à l'édition"
|
||||
}
|
||||
},
|
||||
"add_link": "Ajouter un lien",
|
||||
"link_url_prompt": "Saisissez l'URL",
|
||||
"sending": "Envoi..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmer",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "Jouer un son pour les rappels de calendrier",
|
||||
"invitation_parsing": "Analyser les invitations par e-mail",
|
||||
"invitation_parsing_desc": "Détecter les invitations de calendrier dans les pièces jointes et afficher les actions de calendrier"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Cet appareil cessera de recevoir des alertes lorsque le site est fermé.",
|
||||
"confirm_disable_title": "Désactiver les notifications en arrière-plan ?",
|
||||
"description": "Recevez des notifications système pour les nouveaux courriers quand ce site est fermé. Livré via le relais push Bulwark ; le relais ne voit jamais le contenu des courriers.",
|
||||
"disable": "Désactiver",
|
||||
"enable": "Activer",
|
||||
"ios_hint": "Sur iOS, installez d'abord le site sur l'écran d'accueil – Safari ne livre Web Push qu'aux PWA installées.",
|
||||
"reenable": "Réenregistrer",
|
||||
"relay_desc": "Utilise par défaut le relais Bulwark hébergé. Ne le changez que si vous l'hébergez vous-même.",
|
||||
"relay_label": "Relais push",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Actif sur cet appareil",
|
||||
"status_busy": "Traitement en cours…",
|
||||
"status_inactive": "Non activé sur cet appareil",
|
||||
"status_unsupported": "Ce navigateur ne prend pas en charge Web Push",
|
||||
"title": "Notifications en arrière-plan"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -884,6 +906,13 @@
|
||||
"label": "Sélection automatique de l'adresse de réponse",
|
||||
"description": "Lors d'une réponse, bascule automatiquement l'adresse d'expédition vers l'identité qui a reçu le message d'origine"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Délimiteur de sous-adresse",
|
||||
"description": "Caractère séparant votre nom d'utilisateur de l'étiquette de sous-adresse. Utilisez le délimiteur configuré sur votre serveur de messagerie (par ex. utilisateur{delimiter}tag@domaine.com).",
|
||||
"option": "{delimiter} (utilisateur{delimiter}tag@domaine.com)",
|
||||
"custom": "Personnalisé…",
|
||||
"custom_input_label": "Caractère de délimiteur personnalisé"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Afficher l'aperçu",
|
||||
"description": "Afficher l'aperçu de l'email dans la liste",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "Aucun carnet d'adresses trouvé",
|
||||
"categories_title": "Catégories",
|
||||
"categories_description": "Renommer les catégories de contacts",
|
||||
"no_categories": "Aucune catégorie trouvée"
|
||||
"no_categories": "Aucune catégorie trouvée",
|
||||
"group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts",
|
||||
"group_by_letter_label": "Grouper par première lettre"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres de courrier",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "Utiliser cette adresse",
|
||||
"invalid_tag": "Le tag doit contenir uniquement des lettres, chiffres et tirets",
|
||||
"tag_too_long": "Le tag doit faire 30 caractères ou moins",
|
||||
"help_text": "Les emails envoyés à utilisateur+tag@domaine.com arriveront dans votre boîte de réception",
|
||||
"help_text": "Les emails envoyés à utilisateur{delimiter}tag@domaine.com arriveront dans votre boîte de réception",
|
||||
"validation": {
|
||||
"empty": "Le tag ne peut pas être vide",
|
||||
"too_long": "Le tag doit faire {max} caractères ou moins",
|
||||
@@ -2129,7 +2160,11 @@
|
||||
"tomorrow_header": "Demain",
|
||||
"export_ics": "Exporter en .ics",
|
||||
"copy_title": "Copier le titre",
|
||||
"copy_link": "Copier le lien de réunion"
|
||||
"copy_link": "Copier le lien de réunion",
|
||||
"go_to_today": "Aller à aujourd'hui",
|
||||
"new_all_day_event": "Nouvel événement sur la journée",
|
||||
"new_event": "Nouvel événement",
|
||||
"new_task": "Nouvelle tâche"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Ajouter une note...",
|
||||
@@ -2261,6 +2296,20 @@
|
||||
"sat": "Sam",
|
||||
"sun": "Dim"
|
||||
},
|
||||
"months": {
|
||||
"jan": "janv.",
|
||||
"feb": "févr.",
|
||||
"mar": "mars",
|
||||
"apr": "avr.",
|
||||
"may": "mai",
|
||||
"jun": "juin",
|
||||
"jul": "juil.",
|
||||
"aug": "août",
|
||||
"sep": "sept.",
|
||||
"oct": "oct.",
|
||||
"nov": "nov.",
|
||||
"dec": "déc."
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "Événement créé",
|
||||
"event_updated": "Événement mis à jour",
|
||||
|
||||
+54
-5
@@ -424,7 +424,9 @@
|
||||
"cancel_info": "L'organizzatore ha annullato questo evento.",
|
||||
"event_updated": "Aggiornamento #{sequence}",
|
||||
"event_status_tentative": "Provvisorio",
|
||||
"event_status_cancelled": "Annullato"
|
||||
"event_status_cancelled": "Annullato",
|
||||
"collapse": "Nascondi dettagli",
|
||||
"expand": "Mostra dettagli"
|
||||
},
|
||||
"previous": "Precedente",
|
||||
"next": "Successivo",
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "Il tuo messaggio menziona \"{keyword}\" ma nessun file è allegato. Inviare comunque?",
|
||||
"send_anyway": "Invia comunque",
|
||||
"back": "Torna alla modifica"
|
||||
}
|
||||
},
|
||||
"add_link": "Aggiungi link",
|
||||
"link_url_prompt": "Inserisci l'URL",
|
||||
"sending": "Invio in corso..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Conferma",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "Riproduci un suono per i promemoria del calendario",
|
||||
"invitation_parsing": "Analizza inviti via e-mail",
|
||||
"invitation_parsing_desc": "Rileva inviti calendario negli allegati e mostra azioni calendario"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Questo dispositivo non riceverà più avvisi quando il sito è chiuso.",
|
||||
"confirm_disable_title": "Disabilitare le notifiche in background?",
|
||||
"description": "Ricevi notifiche di sistema per la nuova posta quando questo sito è chiuso. Consegnato tramite il relay push Bulwark; il relay non vede mai il contenuto della posta.",
|
||||
"disable": "Disabilita",
|
||||
"enable": "Abilita",
|
||||
"ios_hint": "Su iOS, installa prima il sito sulla schermata Home: Safari consegna Web Push solo alle PWA installate.",
|
||||
"reenable": "Registra di nuovo",
|
||||
"relay_desc": "Per impostazione predefinita usa il relay Bulwark ospitato. Cambialo solo se ospiti in autonomia.",
|
||||
"relay_label": "Relay push",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Attivo su questo dispositivo",
|
||||
"status_busy": "In elaborazione…",
|
||||
"status_inactive": "Non abilitato su questo dispositivo",
|
||||
"status_unsupported": "Questo browser non supporta Web Push",
|
||||
"title": "Notifiche in background"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -884,6 +906,13 @@
|
||||
"label": "Seleziona automaticamente l'indirizzo di risposta",
|
||||
"description": "Quando rispondi, passa automaticamente l'indirizzo mittente all'identità che ha ricevuto il messaggio originale"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Delimitatore sub-indirizzo",
|
||||
"description": "Carattere che separa il tuo nome utente dall'etichetta del sub-indirizzo. Usa il delimitatore configurato sul tuo server di posta (es. utente{delimiter}tag@dominio.com).",
|
||||
"option": "{delimiter} (utente{delimiter}tag@dominio.com)",
|
||||
"custom": "Personalizzato…",
|
||||
"custom_input_label": "Carattere delimitatore personalizzato"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Mostra anteprima testo",
|
||||
"description": "Visualizza l'anteprima del messaggio nell'elenco",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "Nessuna rubrica trovata",
|
||||
"categories_title": "Categorie",
|
||||
"categories_description": "Rinomina le categorie dei contatti",
|
||||
"no_categories": "Nessuna categoria trovata"
|
||||
"no_categories": "Nessuna categoria trovata",
|
||||
"group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti",
|
||||
"group_by_letter_label": "Raggruppa per prima lettera"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtri email",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "Usa questo indirizzo",
|
||||
"invalid_tag": "Il tag deve contenere solo caratteri alfanumerici e trattini",
|
||||
"tag_too_long": "Il tag deve essere di massimo 30 caratteri",
|
||||
"help_text": "I messaggi inviati a utente+tag@dominio.com arriveranno nella tua casella di posta",
|
||||
"help_text": "I messaggi inviati a utente{delimiter}tag@dominio.com arriveranno nella tua casella di posta",
|
||||
"validation": {
|
||||
"empty": "Il tag non può essere vuoto",
|
||||
"too_long": "Il tag deve essere di massimo {max} caratteri",
|
||||
@@ -2129,7 +2160,11 @@
|
||||
"tomorrow_header": "Domani",
|
||||
"export_ics": "Esporta come .ics",
|
||||
"copy_title": "Copia titolo",
|
||||
"copy_link": "Copia link riunione"
|
||||
"copy_link": "Copia link riunione",
|
||||
"go_to_today": "Vai a oggi",
|
||||
"new_all_day_event": "Nuovo evento giornata intera",
|
||||
"new_event": "Nuovo evento",
|
||||
"new_task": "Nuova attività"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Aggiungi una nota...",
|
||||
@@ -2428,6 +2463,20 @@
|
||||
"due_today": "Scade oggi",
|
||||
"due_tomorrow": "Scade domani",
|
||||
"overdue": "Scaduta"
|
||||
},
|
||||
"months": {
|
||||
"jan": "gen",
|
||||
"feb": "feb",
|
||||
"mar": "mar",
|
||||
"apr": "apr",
|
||||
"may": "mag",
|
||||
"jun": "giu",
|
||||
"jul": "lug",
|
||||
"aug": "ago",
|
||||
"sep": "set",
|
||||
"oct": "ott",
|
||||
"nov": "nov",
|
||||
"dec": "dic"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
+54
-5
@@ -424,7 +424,9 @@
|
||||
"cancel_info": "主催者がこのイベントをキャンセルしました。",
|
||||
"event_updated": "更新 #{sequence}",
|
||||
"event_status_tentative": "仮",
|
||||
"event_status_cancelled": "キャンセル済み"
|
||||
"event_status_cancelled": "キャンセル済み",
|
||||
"collapse": "詳細を非表示",
|
||||
"expand": "詳細を表示"
|
||||
},
|
||||
"previous": "前へ",
|
||||
"next": "次へ",
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "メッセージに「{keyword}」が含まれていますが、ファイルが添付されていません。このまま送信しますか?",
|
||||
"send_anyway": "そのまま送信",
|
||||
"back": "編集に戻る"
|
||||
}
|
||||
},
|
||||
"add_link": "リンクを追加",
|
||||
"link_url_prompt": "URLを入力してください",
|
||||
"sending": "送信中..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "確認",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "カレンダーリマインダーの音を鳴らす",
|
||||
"invitation_parsing": "メール招待を解析",
|
||||
"invitation_parsing_desc": "メール添付ファイルのカレンダー招待を検出し、カレンダーアクションを表示"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "このデバイスは、サイトが閉じているときに通知を受信しなくなります。",
|
||||
"confirm_disable_title": "バックグラウンド通知を無効にしますか?",
|
||||
"description": "このサイトが閉じているときに新着メールのシステム通知を受信します。Bulwark プッシュリレー経由で配信され、リレーがメール内容を見ることはありません。",
|
||||
"disable": "無効化",
|
||||
"enable": "有効化",
|
||||
"ios_hint": "iOS では、最初にサイトをホーム画面にインストールしてください。Safari はインストールされた PWA にのみ Web Push を配信します。",
|
||||
"reenable": "再登録",
|
||||
"relay_desc": "デフォルトはホストされた Bulwark リレーです。セルフホストする場合のみ変更してください。",
|
||||
"relay_label": "プッシュリレー",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "このデバイスで有効",
|
||||
"status_busy": "処理中…",
|
||||
"status_inactive": "このデバイスでは有効になっていません",
|
||||
"status_unsupported": "このブラウザは Web Push をサポートしていません",
|
||||
"title": "バックグラウンド通知"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -884,6 +906,13 @@
|
||||
"label": "返信元アドレスを自動選択",
|
||||
"description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "サブアドレス区切り文字",
|
||||
"description": "ユーザー名とサブアドレスタグを区切る文字です。お使いのメールサーバーが使用する区切り文字に合わせてください(例: user{delimiter}tag@domain.com)。",
|
||||
"option": "{delimiter} (user{delimiter}tag@domain.com)",
|
||||
"custom": "カスタム…",
|
||||
"custom_input_label": "カスタム区切り文字"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "プレビューテキストを表示",
|
||||
"description": "リストにメールのプレビューを表示",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "アドレス帳が見つかりません",
|
||||
"categories_title": "カテゴリ",
|
||||
"categories_description": "連絡先カテゴリの名前を変更",
|
||||
"no_categories": "カテゴリが見つかりません"
|
||||
"no_categories": "カテゴリが見つかりません",
|
||||
"group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示",
|
||||
"group_by_letter_label": "頭文字でグループ化"
|
||||
},
|
||||
"filters": {
|
||||
"title": "メールフィルター",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "このアドレスを使用",
|
||||
"invalid_tag": "タグは英数字とハイフンのみ使用できます",
|
||||
"tag_too_long": "タグは30文字以内にしてください",
|
||||
"help_text": "user+tag@domain.comに送信されたメールは受信トレイに届きます",
|
||||
"help_text": "user{delimiter}tag@domain.comに送信されたメールは受信トレイに届きます",
|
||||
"validation": {
|
||||
"empty": "タグは空にできません",
|
||||
"too_long": "タグは{max}文字以内にしてください",
|
||||
@@ -2129,7 +2160,11 @@
|
||||
"tomorrow_header": "明日",
|
||||
"export_ics": ".icsとしてエクスポート",
|
||||
"copy_title": "タイトルをコピー",
|
||||
"copy_link": "会議リンクをコピー"
|
||||
"copy_link": "会議リンクをコピー",
|
||||
"go_to_today": "今日に移動",
|
||||
"new_all_day_event": "新しい終日イベント",
|
||||
"new_event": "新しいイベント",
|
||||
"new_task": "新しいタスク"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "メモを追加...",
|
||||
@@ -2428,6 +2463,20 @@
|
||||
"due_today": "今日が期限",
|
||||
"due_tomorrow": "明日が期限",
|
||||
"overdue": "期限切れ"
|
||||
},
|
||||
"months": {
|
||||
"jan": "1月",
|
||||
"feb": "2月",
|
||||
"mar": "3月",
|
||||
"apr": "4月",
|
||||
"may": "5月",
|
||||
"jun": "6月",
|
||||
"jul": "7月",
|
||||
"aug": "8月",
|
||||
"sep": "9月",
|
||||
"oct": "10月",
|
||||
"nov": "11月",
|
||||
"dec": "12月"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
+54
-5
@@ -426,7 +426,9 @@
|
||||
"cancel_info": "주최자가 이 일정을 취소했어요.",
|
||||
"event_updated": "업데이트 #{sequence}",
|
||||
"event_status_tentative": "미정",
|
||||
"event_status_cancelled": "취소됨"
|
||||
"event_status_cancelled": "취소됨",
|
||||
"collapse": "세부정보 숨기기",
|
||||
"expand": "세부정보 표시"
|
||||
},
|
||||
"send": "보내기",
|
||||
"more": "더보기"
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "메시지에 \"{keyword}\"이(가) 언급되어 있지만 파일이 첨부되지 않았습니다. 그래도 보내시겠습니까?",
|
||||
"send_anyway": "그래도 보내기",
|
||||
"back": "편집으로 돌아가기"
|
||||
}
|
||||
},
|
||||
"add_link": "링크 추가",
|
||||
"link_url_prompt": "URL을 입력하세요",
|
||||
"sending": "전송 중..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "확인",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "일정 알림이 올 때 소리로 알려줘요",
|
||||
"invitation_parsing": "이메일 초대장 분석",
|
||||
"invitation_parsing_desc": "이메일 첨부파일에서 캘린더 초대장을 감지하고 캘린더에 표시해요"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "이 사이트가 닫혀 있을 때 이 기기는 알림을 더 이상 받지 않습니다.",
|
||||
"confirm_disable_title": "백그라운드 알림을 비활성화하시겠습니까?",
|
||||
"description": "이 사이트가 닫혀 있을 때 새 메일에 대한 시스템 알림을 받습니다. Bulwark 푸시 릴레이를 통해 전달되며, 릴레이는 메일 내용을 절대 보지 않습니다.",
|
||||
"disable": "비활성화",
|
||||
"enable": "활성화",
|
||||
"ios_hint": "iOS에서는 먼저 사이트를 홈 화면에 설치하세요. Safari는 설치된 PWA에만 Web Push를 전달합니다.",
|
||||
"reenable": "다시 등록",
|
||||
"relay_desc": "기본값은 호스팅된 Bulwark 릴레이입니다. 셀프 호스팅 시에만 변경하세요.",
|
||||
"relay_label": "푸시 릴레이",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "이 기기에서 활성",
|
||||
"status_busy": "처리 중…",
|
||||
"status_inactive": "이 기기에서 활성화되지 않음",
|
||||
"status_unsupported": "이 브라우저는 Web Push를 지원하지 않습니다",
|
||||
"title": "백그라운드 알림"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -889,6 +911,13 @@
|
||||
"label": "답장 시 보내는 사람 자동 선택",
|
||||
"description": "답장할 때 메일을 받았던 주소로 보내는 사람을 자동으로 변경해요"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "서브 주소 구분자",
|
||||
"description": "사용자 이름과 서브 주소 태그를 나누는 문자예요. 메일 서버가 사용하는 구분자에 맞춰 주세요 (예: user{delimiter}tag@domain.com).",
|
||||
"option": "{delimiter} (user{delimiter}tag@domain.com)",
|
||||
"custom": "사용자 지정…",
|
||||
"custom_input_label": "사용자 지정 구분 문자"
|
||||
},
|
||||
"attachment_click_action": {
|
||||
"label": "첨부파일 클릭 동작",
|
||||
"description": "파일을 클릭했을 때 미리보기를 할지, 바로 다운로드할지 선택해 주세요",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "주소록을 찾을 수 없음",
|
||||
"categories_title": "카테고리",
|
||||
"categories_description": "연락처 카테고리 이름 변경",
|
||||
"no_categories": "카테고리를 찾을 수 없음"
|
||||
"no_categories": "카테고리를 찾을 수 없음",
|
||||
"group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시",
|
||||
"group_by_letter_label": "첫 글자로 그룹화"
|
||||
},
|
||||
"filters": {
|
||||
"title": "이메일 필터",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "이 주소 사용하기",
|
||||
"invalid_tag": "태그는 알파벳, 숫자, 대시(-)만 쓸 수 있어요",
|
||||
"tag_too_long": "태그는 30자 이하여야 해요",
|
||||
"help_text": "user+tag@domain.com 으로 보낸 메일은 내 받은편지함으로 들어와요",
|
||||
"help_text": "user{delimiter}tag@domain.com 으로 보낸 메일은 내 받은편지함으로 들어와요",
|
||||
"validation": {
|
||||
"empty": "태그를 비워둘 수 없어요",
|
||||
"too_long": "태그는 {max}자 이하여야 해요",
|
||||
@@ -2129,7 +2160,11 @@
|
||||
"tomorrow_header": "내일",
|
||||
"export_ics": ".ics로 내보내기",
|
||||
"copy_title": "제목 복사",
|
||||
"copy_link": "회의 링크 복사"
|
||||
"copy_link": "회의 링크 복사",
|
||||
"go_to_today": "오늘로 이동",
|
||||
"new_all_day_event": "새 종일 이벤트",
|
||||
"new_event": "새 이벤트",
|
||||
"new_task": "새 작업"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "메모 추가...",
|
||||
@@ -2428,6 +2463,20 @@
|
||||
"due_today": "오늘 마감",
|
||||
"due_tomorrow": "내일 마감",
|
||||
"overdue": "기한 지남"
|
||||
},
|
||||
"months": {
|
||||
"jan": "1월",
|
||||
"feb": "2월",
|
||||
"mar": "3월",
|
||||
"apr": "4월",
|
||||
"may": "5월",
|
||||
"jun": "6월",
|
||||
"jul": "7월",
|
||||
"aug": "8월",
|
||||
"sep": "9월",
|
||||
"oct": "10월",
|
||||
"nov": "11월",
|
||||
"dec": "12월"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
+55
-6
@@ -426,7 +426,9 @@
|
||||
"cancel_info": "Organizators atcēla šo pasākumu.",
|
||||
"event_updated": "Atjauninājums Nr. {sequence}",
|
||||
"event_status_tentative": "Pagaidām",
|
||||
"event_status_cancelled": "Atcelts"
|
||||
"event_status_cancelled": "Atcelts",
|
||||
"collapse": "Paslēpt detaļas",
|
||||
"expand": "Rādīt detaļas"
|
||||
},
|
||||
"send": "Sūtīt",
|
||||
"more": "vairāk"
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "Jūsu ziņojumā minēts \"{keyword}\", bet nav pievienots neviens fails. Vai tomēr sūtīt?",
|
||||
"send_anyway": "Sūtīt tik un tā",
|
||||
"back": "Atpakaļ pie rediģēšanas"
|
||||
}
|
||||
},
|
||||
"add_link": "Pievienot saiti",
|
||||
"link_url_prompt": "Ievadiet URL",
|
||||
"sending": "Sūta..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Apstiprināt",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "Atskaņot skaņas signālu kalendāra atgādinājumiem",
|
||||
"invitation_parsing": "Atpazīt uzaicinājumus e-pastā",
|
||||
"invitation_parsing_desc": "Noteikt kalendāra uzaicinājumus pielikumos un rādīt kalendāra darbības"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Šī ierīce vairs nesaņems brīdinājumus, kad vietne būs aizvērta.",
|
||||
"confirm_disable_title": "Atspējot fona paziņojumus?",
|
||||
"description": "Saņem sistēmas paziņojumus par jaunu pastu, kad šī vietne ir aizvērta. Piegādāts caur Bulwark push releju; relejs nekad neredz pasta saturu.",
|
||||
"disable": "Atspējot",
|
||||
"enable": "Iespējot",
|
||||
"ios_hint": "Operētājsistēmā iOS vispirms instalējiet vietni sākuma ekrānā – Safari piegādā Web Push tikai instalētajām PWA.",
|
||||
"reenable": "Reģistrēt vēlreiz",
|
||||
"relay_desc": "Pēc noklusējuma izmanto izmitināto Bulwark releju. Mainiet tikai tad, ja izmitināt pats.",
|
||||
"relay_label": "Push relejs",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Aktīvs šajā ierīcē",
|
||||
"status_busy": "Notiek darbs…",
|
||||
"status_inactive": "Nav iespējots šajā ierīcē",
|
||||
"status_unsupported": "Šī pārlūkprogramma neatbalsta Web Push",
|
||||
"title": "Fona paziņojumi"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -884,6 +906,13 @@
|
||||
"label": "Automātiski izvēlēties atbildes adresi",
|
||||
"description": "Atbildot automātiski izmantot to kontu, uz kuru vēstule tika saņemta"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Apakšadreses atdalītājs",
|
||||
"description": "Zīme, kas atdala lietotājvārdu no apakšadreses tagu. Izvēlieties atdalītāju, ko lieto jūsu pasta serveris (piem. lietotajs{delimiter}tags@domens.lv).",
|
||||
"option": "{delimiter} (lietotajs{delimiter}tags@domens.lv)",
|
||||
"custom": "Pielāgots…",
|
||||
"custom_input_label": "Pielāgota atdalītāja zīme"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Rādīt priekšskatījuma tekstu",
|
||||
"description": "Rādīt vēstules fragmentu sarakstā",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "Adrešu grāmatas nav atrastas",
|
||||
"categories_title": "Kategorijas",
|
||||
"categories_description": "Pārdēvēt kontaktu kategorijas",
|
||||
"no_categories": "Kategorijas nav atrastas"
|
||||
"no_categories": "Kategorijas nav atrastas",
|
||||
"group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā",
|
||||
"group_by_letter_label": "Grupēt pēc pirmā burta"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Pasta filtri",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "Izmantot šo adresi",
|
||||
"invalid_tag": "Tags var saturēt tikai burtus, ciparus un domuzīmes",
|
||||
"tag_too_long": "Tags nedrīkst pārsniegt 30 rakstzīmes",
|
||||
"help_text": "Vēstules uz lietotajs+tags@domens.lv nonāks jūsu pastkastē",
|
||||
"help_text": "Vēstules uz lietotajs{delimiter}tags@domens.lv nonāks jūsu pastkastē",
|
||||
"validation": {
|
||||
"empty": "Tags nevar būt tukšs",
|
||||
"too_long": "Tags nedrīkst pārsniegt {max} rakstzīmes",
|
||||
@@ -2128,7 +2159,11 @@
|
||||
"tomorrow_header": "Rīt",
|
||||
"export_ics": "Eksportēt kā .ics",
|
||||
"copy_title": "Kopēt nosaukumu",
|
||||
"copy_link": "Kopēt sapulces saiti"
|
||||
"copy_link": "Kopēt sapulces saiti",
|
||||
"go_to_today": "Pāriet uz šodienu",
|
||||
"new_all_day_event": "Jauns visas dienas notikums",
|
||||
"new_event": "Jauns notikums",
|
||||
"new_task": "Jauns uzdevums"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Pievienot piezīmi...",
|
||||
@@ -2428,7 +2463,21 @@
|
||||
"due_tomorrow": "Rīt",
|
||||
"overdue": "Kavēts"
|
||||
},
|
||||
"birthday_calendar": "Dzimšanas dienas"
|
||||
"birthday_calendar": "Dzimšanas dienas",
|
||||
"months": {
|
||||
"jan": "janv.",
|
||||
"feb": "febr.",
|
||||
"mar": "marts",
|
||||
"apr": "apr.",
|
||||
"may": "maijs",
|
||||
"jun": "jūn.",
|
||||
"jul": "jūl.",
|
||||
"aug": "aug.",
|
||||
"sep": "sept.",
|
||||
"oct": "okt.",
|
||||
"nov": "nov.",
|
||||
"dec": "dec."
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Izvērstā meklēšana",
|
||||
|
||||
+54
-5
@@ -424,7 +424,9 @@
|
||||
"cancel_info": "De organisator heeft dit evenement geannuleerd.",
|
||||
"event_updated": "Update #{sequence}",
|
||||
"event_status_tentative": "Voorlopig",
|
||||
"event_status_cancelled": "Geannuleerd"
|
||||
"event_status_cancelled": "Geannuleerd",
|
||||
"collapse": "Details verbergen",
|
||||
"expand": "Details tonen"
|
||||
},
|
||||
"previous": "Vorige",
|
||||
"next": "Volgende",
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "Uw bericht vermeldt \"{keyword}\" maar er is geen bestand bijgevoegd. Toch verzenden?",
|
||||
"send_anyway": "Toch verzenden",
|
||||
"back": "Terug naar bewerken"
|
||||
}
|
||||
},
|
||||
"add_link": "Link toevoegen",
|
||||
"link_url_prompt": "Voer de URL in",
|
||||
"sending": "Bezig met verzenden..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Bevestigen",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "Een geluid afspelen voor agendaherinneringen",
|
||||
"invitation_parsing": "E-mailuitnodigingen herkennen",
|
||||
"invitation_parsing_desc": "Agenda-uitnodigingen in e-mailbijlagen detecteren en agendaacties tonen"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Dit apparaat ontvangt geen meldingen meer als de site is gesloten.",
|
||||
"confirm_disable_title": "Achtergrondmeldingen uitschakelen?",
|
||||
"description": "Ontvang systeemmeldingen voor nieuwe e-mail wanneer deze site gesloten is. Geleverd via de Bulwark-pushrelay; de relay ziet nooit e-mailinhoud.",
|
||||
"disable": "Uitschakelen",
|
||||
"enable": "Inschakelen",
|
||||
"ios_hint": "Installeer de site op iOS eerst op het beginscherm – Safari levert Web Push alleen aan geïnstalleerde PWA's.",
|
||||
"reenable": "Opnieuw registreren",
|
||||
"relay_desc": "Gebruikt standaard de gehoste Bulwark-relay. Wijzig alleen als je zelf hostt.",
|
||||
"relay_label": "Push-relay",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Actief op dit apparaat",
|
||||
"status_busy": "Bezig…",
|
||||
"status_inactive": "Niet ingeschakeld op dit apparaat",
|
||||
"status_unsupported": "Deze browser ondersteunt geen Web Push",
|
||||
"title": "Achtergrondmeldingen"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -884,6 +906,13 @@
|
||||
"label": "Antwoordadres automatisch selecteren",
|
||||
"description": "Schakel bij het beantwoorden automatisch het Van-adres om naar de identiteit die het oorspronkelijke bericht ontving"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Sub-adres scheidingsteken",
|
||||
"description": "Teken dat je gebruikersnaam scheidt van het sub-adres-label. Gebruik het scheidingsteken dat je mailserver gebruikt (bv. gebruiker{delimiter}tag@domein.nl).",
|
||||
"option": "{delimiter} (gebruiker{delimiter}tag@domein.nl)",
|
||||
"custom": "Aangepast…",
|
||||
"custom_input_label": "Aangepast scheidingsteken"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Voorbeeldtekst tonen",
|
||||
"description": "E-mailvoorbeeld weergeven in de lijst",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "Geen adresboeken gevonden",
|
||||
"categories_title": "Categorieën",
|
||||
"categories_description": "Contactcategorieën hernoemen",
|
||||
"no_categories": "Geen categorieën gevonden"
|
||||
"no_categories": "Geen categorieën gevonden",
|
||||
"group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst",
|
||||
"group_by_letter_label": "Groeperen op eerste letter"
|
||||
},
|
||||
"filters": {
|
||||
"title": "E-mailfilters",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "Dit adres gebruiken",
|
||||
"invalid_tag": "Tag mag alleen letters, cijfers en streepjes bevatten",
|
||||
"tag_too_long": "Tag mag maximaal 30 tekens bevatten",
|
||||
"help_text": "E-mails verzonden naar gebruiker+tag@domein.nl komen in je postvak IN aan",
|
||||
"help_text": "E-mails verzonden naar gebruiker{delimiter}tag@domein.nl komen in je postvak IN aan",
|
||||
"validation": {
|
||||
"empty": "Tag mag niet leeg zijn",
|
||||
"too_long": "Tag mag maximaal {max} tekens bevatten",
|
||||
@@ -2129,7 +2160,11 @@
|
||||
"tomorrow_header": "Morgen",
|
||||
"export_ics": "Exporteren als .ics",
|
||||
"copy_title": "Titel kopiëren",
|
||||
"copy_link": "Vergaderlink kopiëren"
|
||||
"copy_link": "Vergaderlink kopiëren",
|
||||
"go_to_today": "Ga naar vandaag",
|
||||
"new_all_day_event": "Nieuwe dagvullende afspraak",
|
||||
"new_event": "Nieuwe afspraak",
|
||||
"new_task": "Nieuwe taak"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Notitie toevoegen...",
|
||||
@@ -2428,6 +2463,20 @@
|
||||
"due_today": "Vervalt vandaag",
|
||||
"due_tomorrow": "Vervalt morgen",
|
||||
"overdue": "Achterstallig"
|
||||
},
|
||||
"months": {
|
||||
"jan": "jan",
|
||||
"feb": "feb",
|
||||
"mar": "mrt",
|
||||
"apr": "apr",
|
||||
"may": "mei",
|
||||
"jun": "jun",
|
||||
"jul": "jul",
|
||||
"aug": "aug",
|
||||
"sep": "sep",
|
||||
"oct": "okt",
|
||||
"nov": "nov",
|
||||
"dec": "dec"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
+54
-5
@@ -426,7 +426,9 @@
|
||||
"cancel_info": "Organizator anulował to wydarzenie.",
|
||||
"event_updated": "Aktualizacja #{sequence}",
|
||||
"event_status_tentative": "Wstępne",
|
||||
"event_status_cancelled": "Anulowane"
|
||||
"event_status_cancelled": "Anulowane",
|
||||
"collapse": "Ukryj szczegóły",
|
||||
"expand": "Pokaż szczegóły"
|
||||
},
|
||||
"send": "Wyślij",
|
||||
"more": "więcej"
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "Twoja wiadomość wspomina o \"{keyword}\", ale nie załączono żadnego pliku. Wysłać mimo to?",
|
||||
"send_anyway": "Wyślij mimo to",
|
||||
"back": "Wróć do edycji"
|
||||
}
|
||||
},
|
||||
"add_link": "Dodaj link",
|
||||
"link_url_prompt": "Wprowadź adres URL",
|
||||
"sending": "Wysyłanie..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Potwierdź",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "Odtwarzaj sygnał dźwiękowy dla przypomnień kalendarza",
|
||||
"invitation_parsing": "Rozpoznawaj zaproszenia e-mail",
|
||||
"invitation_parsing_desc": "Wykrywaj zaproszenia kalendarzowe w załącznikach wiadomości e-mail i pokazuj akcje kalendarza"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "To urządzenie przestanie otrzymywać powiadomienia, gdy strona jest zamknięta.",
|
||||
"confirm_disable_title": "Wyłączyć powiadomienia w tle?",
|
||||
"description": "Otrzymuj powiadomienia systemowe o nowych wiadomościach, gdy ta strona jest zamknięta. Dostarczane przez przekaźnik push Bulwark; przekaźnik nigdy nie widzi treści wiadomości.",
|
||||
"disable": "Wyłącz",
|
||||
"enable": "Włącz",
|
||||
"ios_hint": "W systemie iOS najpierw zainstaluj stronę na ekranie głównym – Safari dostarcza Web Push tylko zainstalowanym PWA.",
|
||||
"reenable": "Zarejestruj ponownie",
|
||||
"relay_desc": "Domyślnie używa hostowanego przekaźnika Bulwark. Zmień tylko jeśli hostujesz samodzielnie.",
|
||||
"relay_label": "Przekaźnik push",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Aktywne na tym urządzeniu",
|
||||
"status_busy": "Przetwarzanie…",
|
||||
"status_inactive": "Niewłączone na tym urządzeniu",
|
||||
"status_unsupported": "Ta przeglądarka nie obsługuje Web Push",
|
||||
"title": "Powiadomienia w tle"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -889,6 +911,13 @@
|
||||
"label": "Automatycznie wybieraj adres odpowiedzi",
|
||||
"description": "Podczas odpowiadania automatycznie przełączaj adres nadawcy na tożsamość, która pierwotnie otrzymała wiadomość"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Separator sub-adresu",
|
||||
"description": "Znak oddzielający Twoją nazwę użytkownika od tagu sub-adresu. Użyj separatora zgodnego z Twoim serwerem pocztowym (np. user{delimiter}tag@domain.com).",
|
||||
"option": "{delimiter} (user{delimiter}tag@domain.com)",
|
||||
"custom": "Niestandardowy…",
|
||||
"custom_input_label": "Niestandardowy znak separatora"
|
||||
},
|
||||
"attachment_click_action": {
|
||||
"label": "Akcja po kliknięciu załącznika",
|
||||
"description": "Wybierz, czy kliknięcie załącznika pliku ma pokazać podgląd, czy od razu go pobrać",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "Nie znaleziono książek adresowych",
|
||||
"categories_title": "Kategorie",
|
||||
"categories_description": "Zmień nazwy kategorii kontaktów",
|
||||
"no_categories": "Nie znaleziono kategorii"
|
||||
"no_categories": "Nie znaleziono kategorii",
|
||||
"group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów",
|
||||
"group_by_letter_label": "Grupuj według pierwszej litery"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtry wiadomości e-mail",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "Użyj tego adresu",
|
||||
"invalid_tag": "Tag może zawierać tylko litery, cyfry i myślniki",
|
||||
"tag_too_long": "Tag może mieć maksymalnie 30 znaków",
|
||||
"help_text": "Wiadomości wysłane na adres user+tag@domain.com trafią do Twojej skrzynki odbiorczej",
|
||||
"help_text": "Wiadomości wysłane na adres user{delimiter}tag@domain.com trafią do Twojej skrzynki odbiorczej",
|
||||
"validation": {
|
||||
"empty": "Tag nie może być pusty",
|
||||
"too_long": "Tag może mieć maksymalnie {max} znaków",
|
||||
@@ -2129,7 +2160,11 @@
|
||||
"tomorrow_header": "Jutro",
|
||||
"export_ics": "Eksportuj jako .ics",
|
||||
"copy_title": "Kopiuj tytuł",
|
||||
"copy_link": "Kopiuj link do spotkania"
|
||||
"copy_link": "Kopiuj link do spotkania",
|
||||
"go_to_today": "Przejdź do dzisiaj",
|
||||
"new_all_day_event": "Nowe wydarzenie całodniowe",
|
||||
"new_event": "Nowe wydarzenie",
|
||||
"new_task": "Nowe zadanie"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Dodaj notatkę...",
|
||||
@@ -2428,6 +2463,20 @@
|
||||
"due_today": "Dzisiaj",
|
||||
"due_tomorrow": "Jutro",
|
||||
"overdue": "Zaległe"
|
||||
},
|
||||
"months": {
|
||||
"jan": "sty",
|
||||
"feb": "lut",
|
||||
"mar": "mar",
|
||||
"apr": "kwi",
|
||||
"may": "maj",
|
||||
"jun": "cze",
|
||||
"jul": "lip",
|
||||
"aug": "sie",
|
||||
"sep": "wrz",
|
||||
"oct": "paź",
|
||||
"nov": "lis",
|
||||
"dec": "gru"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
+55
-6
@@ -424,7 +424,9 @@
|
||||
"cancel_info": "O organizador cancelou este evento.",
|
||||
"event_updated": "Atualização #{sequence}",
|
||||
"event_status_tentative": "Provisório",
|
||||
"event_status_cancelled": "Cancelado"
|
||||
"event_status_cancelled": "Cancelado",
|
||||
"collapse": "Ocultar detalhes",
|
||||
"expand": "Mostrar detalhes"
|
||||
},
|
||||
"previous": "Anterior",
|
||||
"next": "Próximo",
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "A sua mensagem menciona \"{keyword}\" mas nenhum arquivo está anexado. Enviar mesmo assim?",
|
||||
"send_anyway": "Enviar mesmo assim",
|
||||
"back": "Voltar à edição"
|
||||
}
|
||||
},
|
||||
"add_link": "Adicionar link",
|
||||
"link_url_prompt": "Insira a URL",
|
||||
"sending": "Enviando..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Confirmar",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "Reproduzir um som para lembretes do calendário",
|
||||
"invitation_parsing": "Analisar convites por e-mail",
|
||||
"invitation_parsing_desc": "Detectar convites de calendário em anexos de e-mail e mostrar ações de calendário"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Este dispositivo deixará de receber alertas quando o site estiver fechado.",
|
||||
"confirm_disable_title": "Desativar notificações em segundo plano?",
|
||||
"description": "Receba notificações do sistema para novas mensagens quando este site estiver fechado. Entregue através do relay push do Bulwark; o relay nunca vê o conteúdo da mensagem.",
|
||||
"disable": "Desativar",
|
||||
"enable": "Ativar",
|
||||
"ios_hint": "No iOS, instale o site primeiro na tela inicial – o Safari só entrega Web Push para PWAs instalados.",
|
||||
"reenable": "Registrar novamente",
|
||||
"relay_desc": "Usa o relay Bulwark hospedado por padrão. Altere apenas se você hospedar.",
|
||||
"relay_label": "Relay push",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Ativo neste dispositivo",
|
||||
"status_busy": "Processando…",
|
||||
"status_inactive": "Não ativado neste dispositivo",
|
||||
"status_unsupported": "Este navegador não oferece suporte a Web Push",
|
||||
"title": "Notificações em segundo plano"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -884,6 +906,13 @@
|
||||
"label": "Selecionar automaticamente o endereço de resposta",
|
||||
"description": "Ao responder, muda automaticamente o endereço do remetente para a identidade que recebeu a mensagem original"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Delimitador de sub-endereço",
|
||||
"description": "Caractere que separa seu nome de usuário da tag de sub-endereço. Use o delimitador configurado no seu servidor de e-mail (ex.: usuario{delimiter}tag@dominio.com).",
|
||||
"option": "{delimiter} (usuario{delimiter}tag@dominio.com)",
|
||||
"custom": "Personalizado…",
|
||||
"custom_input_label": "Caractere delimitador personalizado"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Mostrar Texto de Visualização",
|
||||
"description": "Exibir visualização do e-mail na lista",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "Nenhum catálogo de endereços encontrado",
|
||||
"categories_title": "Categorias",
|
||||
"categories_description": "Renomear categorias de contatos",
|
||||
"no_categories": "Nenhuma categoria encontrada"
|
||||
"no_categories": "Nenhuma categoria encontrada",
|
||||
"group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos",
|
||||
"group_by_letter_label": "Agrupar pela primeira letra"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtros de e-mail",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "Usar Este Endereço",
|
||||
"invalid_tag": "A tag deve conter apenas caracteres alfanuméricos e hífens",
|
||||
"tag_too_long": "A tag deve ter no máximo 30 caracteres",
|
||||
"help_text": "E-mails enviados para usuario+tag@dominio.com chegarão na sua caixa de entrada",
|
||||
"help_text": "E-mails enviados para usuario{delimiter}tag@dominio.com chegarão na sua caixa de entrada",
|
||||
"validation": {
|
||||
"empty": "A tag não pode estar vazia",
|
||||
"too_long": "A tag deve ter no máximo {max} caracteres",
|
||||
@@ -2129,7 +2160,11 @@
|
||||
"tomorrow_header": "Amanhã",
|
||||
"export_ics": "Exportar como .ics",
|
||||
"copy_title": "Copiar título",
|
||||
"copy_link": "Copiar link da reunião"
|
||||
"copy_link": "Copiar link da reunião",
|
||||
"go_to_today": "Ir para hoje",
|
||||
"new_all_day_event": "Novo evento de dia inteiro",
|
||||
"new_event": "Novo evento",
|
||||
"new_task": "Nova tarefa"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Adicionar uma nota...",
|
||||
@@ -2261,6 +2296,20 @@
|
||||
"sat": "Sáb",
|
||||
"sun": "Dom"
|
||||
},
|
||||
"months": {
|
||||
"jan": "jan",
|
||||
"feb": "fev",
|
||||
"mar": "mar",
|
||||
"apr": "abr",
|
||||
"may": "mai",
|
||||
"jun": "jun",
|
||||
"jul": "jul",
|
||||
"aug": "ago",
|
||||
"sep": "set",
|
||||
"oct": "out",
|
||||
"nov": "nov",
|
||||
"dec": "dez"
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "Evento criado",
|
||||
"event_updated": "Evento atualizado",
|
||||
@@ -2738,4 +2787,4 @@
|
||||
"custom": "Personalizado"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-5
@@ -426,7 +426,9 @@
|
||||
"cancel_info": "Организатор отменил это событие.",
|
||||
"event_updated": "Обновление №{sequence}",
|
||||
"event_status_tentative": "Под вопросом",
|
||||
"event_status_cancelled": "Отменено"
|
||||
"event_status_cancelled": "Отменено",
|
||||
"collapse": "Скрыть детали",
|
||||
"expand": "Показать детали"
|
||||
},
|
||||
"send": "Отправить",
|
||||
"more": "ещё"
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "В вашем сообщении упоминается \"{keyword}\", но файл не прикреплён. Отправить всё равно?",
|
||||
"send_anyway": "Отправить всё равно",
|
||||
"back": "Вернуться к редактированию"
|
||||
}
|
||||
},
|
||||
"add_link": "Добавить ссылку",
|
||||
"link_url_prompt": "Введите URL",
|
||||
"sending": "Отправка..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Подтвердить",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "Воспроизводить звуковой сигнал для напоминаний календаря",
|
||||
"invitation_parsing": "Распознавать приглашения по почте",
|
||||
"invitation_parsing_desc": "Обнаруживать приглашения календаря во вложениях писем и показывать действия календаря"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Это устройство перестанет получать оповещения, когда сайт закрыт.",
|
||||
"confirm_disable_title": "Отключить фоновые уведомления?",
|
||||
"description": "Получайте системные уведомления о новых письмах, когда этот сайт закрыт. Доставляется через push-релей Bulwark; релей никогда не видит содержимое писем.",
|
||||
"disable": "Отключить",
|
||||
"enable": "Включить",
|
||||
"ios_hint": "В iOS сначала установите сайт на главный экран – Safari доставляет Web Push только установленным PWA.",
|
||||
"reenable": "Перерегистрировать",
|
||||
"relay_desc": "По умолчанию используется размещённый релей Bulwark. Меняйте только если хостите самостоятельно.",
|
||||
"relay_label": "Push-релей",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Активно на этом устройстве",
|
||||
"status_busy": "Выполняется…",
|
||||
"status_inactive": "Не включено на этом устройстве",
|
||||
"status_unsupported": "Этот браузер не поддерживает Web Push",
|
||||
"title": "Фоновые уведомления"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -884,6 +906,13 @@
|
||||
"label": "Автоматически выбирать адрес для ответа",
|
||||
"description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Разделитель суб-адресов",
|
||||
"description": "Символ, отделяющий имя пользователя от тега суб-адреса. Используйте разделитель, настроенный на вашем почтовом сервере (например, user{delimiter}tag@domain.com).",
|
||||
"option": "{delimiter} (user{delimiter}tag@domain.com)",
|
||||
"custom": "Свой…",
|
||||
"custom_input_label": "Свой символ-разделитель"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Показывать текст предпросмотра",
|
||||
"description": "Отображать предпросмотр письма в списке",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "Адресные книги не найдены",
|
||||
"categories_title": "Категории",
|
||||
"categories_description": "Переименование категорий контактов",
|
||||
"no_categories": "Категории не найдены"
|
||||
"no_categories": "Категории не найдены",
|
||||
"group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов",
|
||||
"group_by_letter_label": "Группировать по первой букве"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Фильтры почты",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "Использовать этот адрес",
|
||||
"invalid_tag": "Тег должен содержать только буквы, цифры и дефисы",
|
||||
"tag_too_long": "Тег не должен превышать 30 символов",
|
||||
"help_text": "Письма на адрес user+tag@domain.com будут приходить в ваш ящик",
|
||||
"help_text": "Письма на адрес user{delimiter}tag@domain.com будут приходить в ваш ящик",
|
||||
"validation": {
|
||||
"empty": "Тег не может быть пустым",
|
||||
"too_long": "Тег не должен превышать {max} символов",
|
||||
@@ -2129,7 +2160,11 @@
|
||||
"tomorrow_header": "Завтра",
|
||||
"export_ics": "Экспорт в .ics",
|
||||
"copy_title": "Скопировать название",
|
||||
"copy_link": "Скопировать ссылку встречи"
|
||||
"copy_link": "Скопировать ссылку встречи",
|
||||
"go_to_today": "Перейти к сегодня",
|
||||
"new_all_day_event": "Новое событие на весь день",
|
||||
"new_event": "Новое событие",
|
||||
"new_task": "Новая задача"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Добавить заметку...",
|
||||
@@ -2428,6 +2463,20 @@
|
||||
"due_today": "Сегодня",
|
||||
"due_tomorrow": "Завтра",
|
||||
"overdue": "Просрочено"
|
||||
},
|
||||
"months": {
|
||||
"jan": "янв.",
|
||||
"feb": "февр.",
|
||||
"mar": "март",
|
||||
"apr": "апр.",
|
||||
"may": "май",
|
||||
"jun": "июнь",
|
||||
"jul": "июль",
|
||||
"aug": "авг.",
|
||||
"sep": "сент.",
|
||||
"oct": "окт.",
|
||||
"nov": "нояб.",
|
||||
"dec": "дек."
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+54
-5
@@ -426,7 +426,9 @@
|
||||
"cancel_info": "Організатор скасував цю подію.",
|
||||
"event_updated": "Оновлення №{sequence}",
|
||||
"event_status_tentative": "Орієнтовний",
|
||||
"event_status_cancelled": "Скасовано"
|
||||
"event_status_cancelled": "Скасовано",
|
||||
"collapse": "Сховати деталі",
|
||||
"expand": "Показати деталі"
|
||||
},
|
||||
"send": "Надіслати",
|
||||
"more": "більше"
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "У вашому повідомленні згадується \"{keyword}\", але файл не вкладено. Все одно надіслати?",
|
||||
"send_anyway": "Все одно надішліть",
|
||||
"back": "Назад до редагування"
|
||||
}
|
||||
},
|
||||
"add_link": "Додати посилання",
|
||||
"link_url_prompt": "Введіть URL",
|
||||
"sending": "Надсилання..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "Підтвердити",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "Відтворення звукового сповіщення для нагадувань календаря",
|
||||
"invitation_parsing": "Проаналізуйте запрошення електронною поштою",
|
||||
"invitation_parsing_desc": "Виявляти запрошення календаря у вкладеннях електронної пошти та показувати дії календаря"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "Цей пристрій перестане отримувати сповіщення, коли сайт закритий.",
|
||||
"confirm_disable_title": "Вимкнути фонові сповіщення?",
|
||||
"description": "Отримуйте системні сповіщення про нові листи, коли цей сайт закритий. Доставляється через push-реле Bulwark; реле ніколи не бачить вміст листів.",
|
||||
"disable": "Вимкнути",
|
||||
"enable": "Увімкнути",
|
||||
"ios_hint": "На iOS спочатку встановіть сайт на головний екран – Safari доставляє Web Push лише встановленим PWA.",
|
||||
"reenable": "Перереєструвати",
|
||||
"relay_desc": "Типово використовується розміщене реле Bulwark. Змінюйте лише, якщо хостите самостійно.",
|
||||
"relay_label": "Push-реле",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "Активно на цьому пристрої",
|
||||
"status_busy": "Виконується…",
|
||||
"status_inactive": "Не ввімкнено на цьому пристрої",
|
||||
"status_unsupported": "Цей браузер не підтримує Web Push",
|
||||
"title": "Фонові сповіщення"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -889,6 +911,13 @@
|
||||
"label": "Автоматичний вибір адреси для відповіді",
|
||||
"description": "Під час відповіді автоматично змінюйте адресу відправника на особу, яка спочатку отримала повідомлення"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Розділювач під-адреси",
|
||||
"description": "Символ, який відокремлює ім'я користувача від мітки під-адреси. Використовуйте розділювач, налаштований на вашому поштовому сервері (напр. user{delimiter}tag@domain.com).",
|
||||
"option": "{delimiter} (user{delimiter}tag@domain.com)",
|
||||
"custom": "Власний…",
|
||||
"custom_input_label": "Власний символ-розділювач"
|
||||
},
|
||||
"attachment_click_action": {
|
||||
"label": "Вкладення Натисніть Дія",
|
||||
"description": "Виберіть, чи клацання вкладеного файлу попередньо переглядає його чи негайно завантажує",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "Адресних книг не знайдено",
|
||||
"categories_title": "Категорії",
|
||||
"categories_description": "Перейменувати категорії контактів",
|
||||
"no_categories": "Категорії не знайдено"
|
||||
"no_categories": "Категорії не знайдено",
|
||||
"group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів",
|
||||
"group_by_letter_label": "Групувати за першою літерою"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Фільтри електронної пошти",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "Використовуйте цю адресу",
|
||||
"invalid_tag": "Тег має бути лише буквено-цифровим і тире",
|
||||
"tag_too_long": "Тег має містити 30 символів або менше",
|
||||
"help_text": "Електронні листи, надіслані на user+tag@domain.com, надходитимуть до вашої скриньки",
|
||||
"help_text": "Електронні листи, надіслані на user{delimiter}tag@domain.com, надходитимуть до вашої скриньки",
|
||||
"validation": {
|
||||
"empty": "Тег не може бути порожнім",
|
||||
"too_long": "Тег має містити не більше {max} символів",
|
||||
@@ -2129,7 +2160,11 @@
|
||||
"tomorrow_header": "завтра",
|
||||
"export_ics": "Експортувати як .ics",
|
||||
"copy_title": "Скопіювати назву",
|
||||
"copy_link": "Скопіювати посилання зустрічі"
|
||||
"copy_link": "Скопіювати посилання зустрічі",
|
||||
"go_to_today": "Перейти до сьогодні",
|
||||
"new_all_day_event": "Нова подія на весь день",
|
||||
"new_event": "Нова подія",
|
||||
"new_task": "Нове завдання"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "Додати примітку...",
|
||||
@@ -2428,6 +2463,20 @@
|
||||
"due_today": "Сьогодні",
|
||||
"due_tomorrow": "завтра",
|
||||
"overdue": "Прострочена"
|
||||
},
|
||||
"months": {
|
||||
"jan": "січ.",
|
||||
"feb": "лют.",
|
||||
"mar": "бер.",
|
||||
"apr": "квіт.",
|
||||
"may": "трав.",
|
||||
"jun": "черв.",
|
||||
"jul": "лип.",
|
||||
"aug": "серп.",
|
||||
"sep": "верес.",
|
||||
"oct": "жовт.",
|
||||
"nov": "лист.",
|
||||
"dec": "груд."
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
+54
-5
@@ -426,7 +426,9 @@
|
||||
"cancel_info": "组织者已取消此活动。",
|
||||
"event_updated": "更新 #{sequence}",
|
||||
"event_status_tentative": "暂定",
|
||||
"event_status_cancelled": "已取消"
|
||||
"event_status_cancelled": "已取消",
|
||||
"collapse": "隐藏详情",
|
||||
"expand": "显示详情"
|
||||
},
|
||||
"send": "发送",
|
||||
"more": "更多"
|
||||
@@ -508,7 +510,10 @@
|
||||
"message": "您的消息中提到了“{keyword}”,但未附加任何文件。仍然发送吗?",
|
||||
"send_anyway": "仍然发送",
|
||||
"back": "返回编辑"
|
||||
}
|
||||
},
|
||||
"add_link": "添加链接",
|
||||
"link_url_prompt": "输入 URL",
|
||||
"sending": "发送中..."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
"confirm": "确认",
|
||||
@@ -801,6 +806,23 @@
|
||||
"sound_desc": "播放日历提醒的音频提醒",
|
||||
"invitation_parsing": "解析邮件邀请",
|
||||
"invitation_parsing_desc": "检测邮件附件中的日历邀请并显示日历操作"
|
||||
},
|
||||
"push": {
|
||||
"confirm_disable_message": "当网站关闭时,此设备将停止接收提醒。",
|
||||
"confirm_disable_title": "禁用后台通知?",
|
||||
"description": "在此网站关闭时接收新邮件的系统通知。通过 Bulwark 推送中继传送;中继永远不会看到邮件内容。",
|
||||
"disable": "禁用",
|
||||
"enable": "启用",
|
||||
"ios_hint": "在 iOS 上,请先将网站安装到主屏幕 – Safari 仅向已安装的 PWA 发送 Web Push。",
|
||||
"reenable": "重新注册",
|
||||
"relay_desc": "默认使用托管的 Bulwark 中继。仅当您自托管时才更改。",
|
||||
"relay_label": "推送中继",
|
||||
"relay_placeholder": "https://notifications.relay.example.com",
|
||||
"status_active": "在此设备上活动",
|
||||
"status_busy": "工作中…",
|
||||
"status_inactive": "在此设备上未启用",
|
||||
"status_unsupported": "此浏览器不支持 Web Push",
|
||||
"title": "后台通知"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
@@ -889,6 +911,13 @@
|
||||
"label": "自动选择回复地址",
|
||||
"description": "回复时自动将发件人地址切换为最初收到该邮件的身份"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "子地址分隔符",
|
||||
"description": "用于分隔用户名和子地址标签的字符。请选择与您的邮件服务器一致的分隔符(例如 user{delimiter}tag@domain.com)。",
|
||||
"option": "{delimiter} (user{delimiter}tag@domain.com)",
|
||||
"custom": "自定义…",
|
||||
"custom_input_label": "自定义分隔字符"
|
||||
},
|
||||
"attachment_click_action": {
|
||||
"label": "附件单击操作",
|
||||
"description": "选择点击附件时是预览还是直接下载",
|
||||
@@ -1333,7 +1362,9 @@
|
||||
"no_address_books": "未找到地址簿",
|
||||
"categories_title": "类别",
|
||||
"categories_description": "重命名联系人类别",
|
||||
"no_categories": "未找到类别"
|
||||
"no_categories": "未找到类别",
|
||||
"group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题",
|
||||
"group_by_letter_label": "按首字母分组"
|
||||
},
|
||||
"filters": {
|
||||
"title": "邮件过滤器",
|
||||
@@ -1732,7 +1763,7 @@
|
||||
"use_address": "使用此地址",
|
||||
"invalid_tag": "标签只能是字母数字和破折号",
|
||||
"tag_too_long": "标签不得超过 30 个字符",
|
||||
"help_text": "发送到 user+tag@domain.com 的邮件将送达您的收件箱",
|
||||
"help_text": "发送到 user{delimiter}tag@domain.com 的邮件将送达您的收件箱",
|
||||
"validation": {
|
||||
"empty": "标签不能为空",
|
||||
"too_long": "标签不得超过 {max} 个字符",
|
||||
@@ -2129,7 +2160,11 @@
|
||||
"tomorrow_header": "明天",
|
||||
"export_ics": "导出为 .ics",
|
||||
"copy_title": "复制标题",
|
||||
"copy_link": "复制会议链接"
|
||||
"copy_link": "复制会议链接",
|
||||
"go_to_today": "前往今天",
|
||||
"new_all_day_event": "新建全天事件",
|
||||
"new_event": "新建事件",
|
||||
"new_task": "新建任务"
|
||||
},
|
||||
"detail": {
|
||||
"add_note": "添加注释...",
|
||||
@@ -2428,6 +2463,20 @@
|
||||
"due_today": "今天",
|
||||
"due_tomorrow": "明天",
|
||||
"overdue": "逾期"
|
||||
},
|
||||
"months": {
|
||||
"jan": "1月",
|
||||
"feb": "2月",
|
||||
"mar": "3月",
|
||||
"apr": "4月",
|
||||
"may": "5月",
|
||||
"jun": "6月",
|
||||
"jul": "7月",
|
||||
"aug": "8月",
|
||||
"sep": "9月",
|
||||
"oct": "10月",
|
||||
"nov": "11月",
|
||||
"dec": "12月"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
Generated
+59
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.5.2",
|
||||
"version": "1.5.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.5.2",
|
||||
"version": "1.5.4",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
@@ -14,6 +14,10 @@
|
||||
"@tiptap/extension-image": "^3.22.4",
|
||||
"@tiptap/extension-link": "^3.22.4",
|
||||
"@tiptap/extension-placeholder": "^3.22.4",
|
||||
"@tiptap/extension-table": "^3.22.4",
|
||||
"@tiptap/extension-table-cell": "^3.22.4",
|
||||
"@tiptap/extension-table-header": "^3.22.4",
|
||||
"@tiptap/extension-table-row": "^3.22.4",
|
||||
"@tiptap/extension-text-align": "^3.22.4",
|
||||
"@tiptap/extension-text-style": "^3.22.4",
|
||||
"@tiptap/extension-underline": "^3.22.4",
|
||||
@@ -3639,6 +3643,59 @@
|
||||
"@tiptap/core": "3.22.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "3.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.4.tgz",
|
||||
"integrity": "sha512-kjvLv3Z4JI+1tLDqZKa+bKU8VcxY+ZOyMCKWQA7wYmy8nKWkLJ60W+xy8AcXXpHB2goCIgSFLhsTyswx0GXH4w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.22.4",
|
||||
"@tiptap/pm": "3.22.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-cell": {
|
||||
"version": "3.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.22.4.tgz",
|
||||
"integrity": "sha512-uvFegCc1UQYK2nfIV2sIHg+hzLIMroJJm00XomzBgC1w/eSO7Ui8APiDh/baBcTPpCSU3SLiQLTgx7AU7oE3pg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-table": "3.22.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-header": {
|
||||
"version": "3.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.22.4.tgz",
|
||||
"integrity": "sha512-V4kLLWeRdc/I+IXiXZZhLAjsaHHiJWuLXTuOtZRDrCxQUiFLi4AgNg1DPQ09JAANkEWDhXq3x6BoUXaFwumbEw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-table": "3.22.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-row": {
|
||||
"version": "3.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.22.4.tgz",
|
||||
"integrity": "sha512-9tdS6jgS6DqUu5TpEmNrRoo/DL5Xam0PyrQaUEXUC+ssci+bMRCJ8PAWMcunNsI9NKf/Tb3wYrv6hGFChaT9uA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-table": "3.22.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.4.tgz",
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.5.2",
|
||||
"version": "1.5.4",
|
||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -37,6 +37,10 @@
|
||||
"@tiptap/extension-image": "^3.22.4",
|
||||
"@tiptap/extension-link": "^3.22.4",
|
||||
"@tiptap/extension-placeholder": "^3.22.4",
|
||||
"@tiptap/extension-table": "^3.22.4",
|
||||
"@tiptap/extension-table-cell": "^3.22.4",
|
||||
"@tiptap/extension-table-header": "^3.22.4",
|
||||
"@tiptap/extension-table-row": "^3.22.4",
|
||||
"@tiptap/extension-text-align": "^3.22.4",
|
||||
"@tiptap/extension-text-style": "^3.22.4",
|
||||
"@tiptap/extension-underline": "^3.22.4",
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function proxy(request: NextRequest) {
|
||||
? `'self' 'nonce-${nonce}' 'unsafe-eval' blob:`
|
||||
: `'self' 'nonce-${nonce}' blob:`;
|
||||
|
||||
const connectSrc = isDev ? `'self' https: ws: wss:` : `'self' https:`;
|
||||
const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https:`;
|
||||
|
||||
const frameAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
|
||||
|
||||
|
||||
+136
-4
@@ -1,8 +1,14 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
// Minimal service worker – satisfies the PWA installability requirement
|
||||
// without caching any assets. All requests fall through to the network,
|
||||
// so there is no risk of serving stale chunks after a deployment.
|
||||
// Bulwark service worker.
|
||||
//
|
||||
// This SW does two jobs:
|
||||
// 1. Satisfy the PWA installability requirement (network-only fetch handler,
|
||||
// no caching - so we never serve stale chunks after a deployment).
|
||||
// 2. Receive Web Push wake-up pings from the relay and turn them into
|
||||
// enriched system notifications. Mirrors the React Native FCM headless
|
||||
// task: relay sends only a state-change ping, the client fetches the
|
||||
// newest unread email itself so the relay never sees mail content.
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
@@ -12,5 +18,131 @@ self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// Network-only fetch handler – no caching.
|
||||
self.addEventListener("fetch", () => {});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
event.waitUntil(handlePush(event));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
event.waitUntil(handleNotificationClick(event));
|
||||
});
|
||||
|
||||
async function handlePush(event) {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = event.data ? event.data.json() : null;
|
||||
} catch (_) {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
const accountLabel = (payload && typeof payload.accountLabel === "string")
|
||||
? payload.accountLabel
|
||||
: "";
|
||||
|
||||
// Best effort: ask the webmail to look up the latest unread email so we can
|
||||
// build a useful notification. If the request fails (offline, session
|
||||
// expired, server down) we fall back to a generic "New mail" so the user
|
||||
// still sees something.
|
||||
let preview = null;
|
||||
let previewOk = false;
|
||||
try {
|
||||
const res = await fetch("/api/push/preview", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.ok) {
|
||||
preview = await res.json();
|
||||
previewOk = true;
|
||||
}
|
||||
} catch (_) {
|
||||
preview = null;
|
||||
}
|
||||
|
||||
const email = preview && preview.email ? preview.email : null;
|
||||
const unreadTotal = preview && typeof preview.unreadTotal === "number"
|
||||
? preview.unreadTotal
|
||||
: 0;
|
||||
|
||||
// Push subscription is scoped to EmailDelivery, but stragglers from the
|
||||
// older broader-types subscription, marking-as-read races and verification
|
||||
// pings can still wake us with no actual unread mail. When the preview API
|
||||
// succeeded and reports zero unread, stay silent. When the preview API
|
||||
// failed (network/auth/server down) we cannot tell, so fall through to the
|
||||
// generic "New mail" toast rather than miss a real delivery.
|
||||
if (previewOk && !email && unreadTotal === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let title;
|
||||
let body;
|
||||
let tag = "bulwark-mail";
|
||||
let data = { kind: "mail-list" };
|
||||
|
||||
if (email) {
|
||||
const sender = email.from && email.from[0];
|
||||
const senderName = (sender && sender.name) || (sender && sender.email) || "New mail";
|
||||
title = senderName + (accountLabel ? ` (${accountLabel})` : "");
|
||||
body = email.subject || email.preview || "(no subject)";
|
||||
tag = "bulwark-mail:" + email.id;
|
||||
data = {
|
||||
kind: "email",
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
};
|
||||
} else {
|
||||
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
|
||||
body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail";
|
||||
}
|
||||
|
||||
await self.registration.showNotification(title, {
|
||||
body,
|
||||
tag,
|
||||
icon: "/icon-192x192.png",
|
||||
badge: "/icon-192x192.png",
|
||||
data,
|
||||
renotify: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleNotificationClick(event) {
|
||||
const data = event.notification.data || {};
|
||||
const targetUrl = buildClickUrl(data);
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
|
||||
for (const client of allClients) {
|
||||
// Reuse an existing tab whenever possible - users on desktop browsers
|
||||
// get annoyed when each notification opens a fresh window.
|
||||
if ("focus" in client) {
|
||||
try {
|
||||
if ("navigate" in client && targetUrl) {
|
||||
await client.navigate(targetUrl);
|
||||
}
|
||||
return client.focus();
|
||||
} catch (_) {
|
||||
// navigate() can reject for cross-origin or detached clients - fall
|
||||
// through and open a new window below.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(targetUrl || "/");
|
||||
}
|
||||
}
|
||||
|
||||
function buildClickUrl(data) {
|
||||
if (!data) return "/";
|
||||
if (data.kind === "email" && data.emailId) {
|
||||
return `/?email=${encodeURIComponent(data.emailId)}`;
|
||||
}
|
||||
// Generic "New mail" toast (preview API failed or returned no email): land
|
||||
// the user on the latest unread message in their Inbox rather than just the
|
||||
// app shell, so the click still feels purposeful.
|
||||
return "/?openLatestUnread=1";
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user