Compare commits

..
39 Commits
Author SHA1 Message Date
Linus Rath 6a8ad525f1 chore: update version to 1.5.4 2026-05-01 02:17:33 +02:00
Linus Rath 31d17098d6 feat: open mail from push notification clicks 2026-05-01 02:13:25 +02:00
Linus Rath 3f97e6ed8d fix: scope email notifications to genuine inbox deliveries 2026-05-01 02:08:13 +02:00
Linus Rath 2dea33e698 feat: improve new email push notification logic for inbox 2026-05-01 01:57:57 +02:00
Linus Rath 123764f8b8 feat: improve new email notification logic for inbox 2026-05-01 01:55:24 +02:00
Linus Rath ec0f355c13 feat: allow custom sub-addressing delimiter character #239 2026-05-01 01:48:39 +02:00
Linus Rath c555973b6b feat: configurable sub-addressing delimiter #239 2026-05-01 01:42:06 +02:00
Linus Rath a8db02e881 i18n: add missing keys across 15 locales 2026-05-01 01:26:22 +02:00
AbdullahandLinus Rath 7dc5984359 feat(i18n): add Turkish localization 2026-05-01 01:16:30 +02:00
Linus Rath 1c3003421e fix: extend timeout for PushVerification and clean up leftover subscriptions 2026-05-01 00:54:40 +02:00
Linus Rath f3d9115ecd feat: web push notifications for PWA #233 2026-05-01 00:26:48 +02:00
Linus Rath 4400a7abba fix: evict unrecoverable basic-auth accounts on reload 2026-04-30 15:39:11 +02:00
Linus Rath 45a4db1c22 fix: pin JMAP auth verification to configured server URL #237 2026-04-30 15:34:14 +02:00
Linus Rath 65eef4b2b8 fix: persist htmlBody in drafts to preserve rich formatting #236 2026-04-30 15:24:09 +02:00
Linus Rath 25de7d996c feat: add tables to composer #236 2026-04-30 15:16:51 +02:00
Luis Felipe MarzagaoandLinus Rath c406fbb73e fixup! fix: implement useTranslations for start date on event detail popover 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath 31024396e3 fixup! fix: implement useTranslations for start date on event modal 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath f0967f90eb fix: implement useTranslations for start date on event modal 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath a4bb8e0c28 fix: implement useTranslations for start date on event detail popover 2026-04-30 11:08:16 +02:00
Linus Rath 7188abc9bc fix: set In-Reply-To and References on replies #234 2026-04-30 01:25:04 +02:00
Linus Rath 4a91cd0c44 style: drop iframe border-radius 2026-04-29 20:26:40 +02:00
Linus Rath 6abf8a5dd8 fix: detect <style> tag for padding 2026-04-29 20:25:24 +02:00
Linus Rath 3667c842c6 fix: light-mode override to body content only 2026-04-29 20:17:59 +02:00
Linus Rath b64721b43c fix: pad bare HTML emails like plain-text mails 2026-04-29 20:09:00 +02:00
Linus Rath 6b5ca2cb89 fix: smooth out body load, prevent flicker 2026-04-29 20:03:58 +02:00
Linus Rath 0f6e4f995f fix: prevent iframe flash on Load images or Trust sender 2026-04-29 18:29:48 +02:00
Linus Rath 0b6fdcabfb fix: update connect-src to include http protocol in development mode 2026-04-29 13:55:38 +02:00
Linus Rath fc49fe0687 chore: update version to 1.5.3 2026-04-28 18:43:09 +02:00
Linus Rath 419382d25d feat: add contacts feature gate and update telemetry payload 2026-04-28 17:54:11 +02:00
Linus Rath 8935b81f12 chore: update version to 1.5.3 2026-04-28 17:34:06 +02:00
Linus Rath ec581ce53e feat: update Docker configuration 2026-04-28 17:27:47 +02:00
Linus Rath 81d8465a79 fix: block telemetry endpoint from pointing at internal hosts 2026-04-28 17:16:30 +02:00
Linus Rath 0f3b506604 feat: add extension preview page and API for detailed extension information 2026-04-28 15:55:23 +02:00
Linus Rath 1b84547211 feat: add Theme API v2 with token compiler, skin slot 2026-04-28 15:39:13 +02:00
Linus Rath dafc8ace3c feat: track unique logins 2026-04-28 08:19:46 +02:00
Linus Rath 2c419cc4fe feat: add right-click context menu on empty calendar space 2026-04-28 01:56:15 +02:00
Linus Rath 90acf181f3 fix: harden plugin config, TOTP token exchange, and branding file serving 2026-04-28 01:44:37 +02:00
Linus Rath 54af07f2af feat: add anonymous instance telemetry
Adds a once-per-day heartbeat that lets the project see how many
instances run Bulwark, on what platforms, with what features enabled,
and roughly how many accounts they have. No email addresses, hostnames,
IPs, or any end-user data are ever sent.

- lib/telemetry: state file, payload builder, jittered scheduler,
  instance_id persistence at <data-dir>/.telemetry-id (delete to reset)
- app/api/admin/telemetry: admin API for status / set-consent /
  set-endpoint / send-now (all audit-logged)
- app/admin/telemetry: settings page with status, JSON payload preview,
  endpoint editor, send-now button, link to the privacy page
- instrumentation.node.ts: starts the scheduler on boot

Default state is enabled. The first heartbeat fires 1 hour after boot
so an admin who installs and immediately disables produces zero pings.
Disable via the settings UI, BULWARK_TELEMETRY=off (or
BULWARK_TELEMETRY_DISABLED=1), or by clearing the endpoint.

Account counts are bucketed (1, 2-5, 6-10, 11-50, 51-200, 201+) so a
small instance can't be re-identified by exact size. The /.telemetry-id
file can be deleted to mint a fresh instance_id.

Receiving collector is open source at bulwarkmail/dashboard. Self-host
your own and point at it via BULWARK_TELEMETRY_URL. Full schema,
retention (90d raw → aggregates), and lawful basis are documented at
bulwarkmail.org/docs/legal/privacy/telemetry.
2026-04-28 01:28:41 +02:00
Linus Rath 68f1fabc4b fix: batch shortcuts act on multi-selection when present #228 2026-04-28 00:04:29 +02:00
104 changed files with 9226 additions and 424 deletions
+19 -1
View File
@@ -84,6 +84,23 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# volume there (see docker-compose.yml). # volume there (see docker-compose.yml).
# ADMIN_DATA_DIR=./data/admin # ADMIN_DATA_DIR=./data/admin
# =============================================================================
# Anonymous Telemetry
# =============================================================================
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
# version, platform, bucketed account counts, and feature toggles only. See
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
#
# Disable telemetry entirely (overrides the admin UI):
# BULWARK_TELEMETRY=off
# Directory for telemetry state: instance id, consent, login HMACs
# (default: ./data/telemetry). For Docker, the default resolves to
# /app/data/telemetry - mount a persistent volume there (see docker-compose.yml)
# so the instance id and consent choice survive upgrades.
# TELEMETRY_DATA_DIR=./data/telemetry
# ============================================================================= # =============================================================================
# Server Listen Address # Server Listen Address
# ============================================================================= # =============================================================================
@@ -191,7 +208,8 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# ============================================================================= # =============================================================================
# URL of the BulwarkMail extension directory for the admin marketplace. # URL of the BulwarkMail extension directory for the admin marketplace.
# Set this to enable browsing and installing plugins/themes from the directory. # Defaults to https://extensions.bulwarkmail.org. Override only if you run
# your own directory (e.g. http://localhost:3001 for local development).
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org # EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
# ============================================================================= # =============================================================================
+48 -2
View File
@@ -1,11 +1,57 @@
# Changelog # Changelog
## 1.5.4 (2026-05-01)
### Features
- **PWA**: Web push notifications for new inbox mail (#233), with click-through to open the message
- **Composer**: Insert and edit tables in rich-text emails (#236)
- **Mail**: Configurable sub-addressing delimiter character (#239)
- **i18n**: Turkish localization
- **i18n**: Missing keys filled in across 15 locales
### Fixes
- **Mail**: Set In-Reply-To and References headers on replies (#234)
- **Mail**: Persist htmlBody in drafts to preserve rich formatting (#236)
- **Auth**: Pin JMAP auth verification to the configured server URL (#237)
- **Auth**: Evict unrecoverable basic-auth accounts on reload
- **Notifications**: Scope new-mail notifications to genuine inbox deliveries
- **Notifications**: Extend PushVerification timeout and clean up leftover subscriptions
- **Viewer**: Smooth out body load to prevent flicker on first render
- **Viewer**: Prevent iframe flash when loading images or trusting the sender
- **Viewer**: Pad bare HTML emails like plain-text mails for consistent layout
- **Viewer**: Light-mode override now only affects body content
- **Viewer**: Detect `<style>` tag when applying padding
- **Viewer**: Drop iframe border-radius
- **Calendar**: Localize event start date in detail popover and event modal
- **Dev**: Include http protocol in connect-src for development mode CSP
## 1.5.3 (2026-04-28)
> **New:** Help shape Bulwark Webmail. Each instance now sends a lightweight daily heartbeat (version, platform, bucketed account counts, feature toggles - never message data or PII) so we can see which platforms and features actually get used and prioritize fixes where they matter most. You're in control: opt out any time from **Admin → Telemetry** or by setting `BULWARK_TELEMETRY=off`. Full schema in the [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
### Features
- **Telemetry**: Anonymous instance telemetry, on by default. Reports schema version, platform, bucketed account counts, and feature toggles only - disable from the admin UI, with `BULWARK_TELEMETRY=off`, or by clearing the endpoint
- **Telemetry**: Track unique logins (HMAC'd per instance, 90-day retention) so the heartbeat can report bucketed account totals without storing usernames
- **Plugins**: Theme API v2 with token compiler and skin slot
- **Plugins**: Extension preview page and detailed extension info API
- **Calendar**: Right-click context menu on empty calendar space
- **Docker**: Persistent named volume for telemetry data so the instance id and admin's consent choice survive container upgrades
### Fixes
- **Security**: Block telemetry endpoint from pointing at internal/loopback hosts (validation + DNS-rebind re-check at fetch time)
- **Security**: Harden plugin config, TOTP token exchange, and branding file serving
- **Mail**: Batch shortcuts now act on the multi-selection when one is present (#228)
## 1.5.2 (2026-04-27) ## 1.5.2 (2026-04-27)
### Features ### 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**: 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**: 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 - **Calendar/Contacts**: JMAP sharing for calendars and address books
- **i18n**: Czech language support - **i18n**: Czech language support
+1 -1
View File
@@ -26,7 +26,7 @@ RUN apk upgrade --no-cache && \
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/data/settings /app/data/admin && chown -R nextjs:nodejs /app/data RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
+1 -1
View File
@@ -94,7 +94,7 @@
## Internationalization ## Internationalization
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська 15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
+3 -1
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.5.2-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.5.4-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div> </div>
@@ -53,6 +53,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
</tr> </tr>
</table> </table>
> **Anonymous telemetry is on by default** since 1.5.3. Each instance sends a daily heartbeat (version, platform, bucketed account counts, feature toggles - no message data, no PII). Disable from **Admin → Telemetry**, by setting `BULWARK_TELEMETRY=off`, or by clearing the endpoint. Full schema: [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
## Overview ## Overview
Bulwark is a full webmail suite not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login: Bulwark is a full webmail suite not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
+1 -1
View File
@@ -1 +1 @@
1.5.2 1.5.4
+58 -3
View File
@@ -31,6 +31,7 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal"; import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { EventContextMenu } from "@/components/calendar/event-context-menu"; import { EventContextMenu } from "@/components/calendar/event-context-menu";
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { downloadEventICS } from "@/lib/calendar-ics-export"; import { downloadEventICS } from "@/lib/calendar-ics-export";
@@ -104,6 +105,7 @@ export default function CalendarPage() {
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null); const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>(); const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>(); const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
const [defaultModalAllDay, setDefaultModalAllDay] = useState(false);
const [miniMonth, setMiniMonth] = useState(new Date()); const [miniMonth, setMiniMonth] = useState(new Date());
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null); const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null); const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
@@ -326,11 +328,12 @@ export default function CalendarPage() {
setSelectedDate(date); setSelectedDate(date);
}, [setSelectedDate]); }, [setSelectedDate]);
const openCreateModal = useCallback((date?: Date, endDate?: Date) => { const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => {
setEditEvent(null); setEditEvent(null);
const d = date || selectedDate; const d = date || selectedDate;
setDefaultModalDate(d); setDefaultModalDate(d);
setDefaultModalEndDate(endDate); setDefaultModalEndDate(endDate);
setDefaultModalAllDay(allDay ?? false);
setSelectedDate(d); setSelectedDate(d);
setShowEventModal(true); setShowEventModal(true);
}, [selectedDate, setSelectedDate]); }, [selectedDate, setSelectedDate]);
@@ -395,6 +398,21 @@ export default function CalendarPage() {
openEventContextMenu(e, event); openEventContextMenu(e, event);
}, [closeDetail, openEventContextMenu]); }, [closeDetail, openEventContextMenu]);
const {
contextMenu: emptyContextMenu,
openContextMenu: openEmptyContextMenu,
closeContextMenu: closeEmptyContextMenu,
menuRef: emptyContextMenuRef,
} = useContextMenu<{ date: Date; hour?: number; allDayArea?: boolean }>();
const handleContextMenuEmpty = useCallback(
(e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => {
closeDetail();
openEmptyContextMenu(e, { date, hour, allDayArea });
},
[closeDetail, openEmptyContextMenu],
);
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return; if (isMobile) return;
if (calendarHoverPreview === 'off') return; if (calendarHoverPreview === 'off') return;
@@ -961,6 +979,7 @@ export default function CalendarPage() {
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent} onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile} isMobile={isMobile}
@@ -978,6 +997,7 @@ export default function CalendarPage() {
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent} onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat} timeFormat={timeFormat}
@@ -997,6 +1017,7 @@ export default function CalendarPage() {
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent} onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
timeFormat={timeFormat} timeFormat={timeFormat}
isMobile={isMobile} isMobile={isMobile}
@@ -1211,12 +1232,13 @@ export default function CalendarPage() {
calendars={calendars} calendars={calendars}
defaultDate={defaultModalDate} defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate} defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
defaultCalendarId={defaultCalendarIdForCreate} defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent} onSave={handleSaveEvent}
onDelete={handleDeleteEvent} onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent} onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp} onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }} onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
onPreviewChange={setPendingPreview} onPreviewChange={setPendingPreview}
currentUserEmails={currentUserEmails} currentUserEmails={currentUserEmails}
isMobile={false} 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 && ( {detailEvent && detailAnchorRect && (
<EventDetailPopover <EventDetailPopover
event={detailEvent} event={detailEvent}
@@ -1308,12 +1362,13 @@ export default function CalendarPage() {
calendars={calendars} calendars={calendars}
defaultDate={defaultModalDate} defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate} defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
defaultCalendarId={defaultCalendarIdForCreate} defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent} onSave={handleSaveEvent}
onDelete={handleDeleteEvent} onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent} onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp} onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }} onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
currentUserEmails={currentUserEmails} currentUserEmails={currentUserEmails}
isMobile={true} isMobile={true}
/> />
+134 -18
View File
@@ -51,6 +51,7 @@ import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
import { appendPlainTextSignature } from "@/lib/signature-utils"; import { appendPlainTextSignature } from "@/lib/signature-utils";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -171,6 +172,11 @@ export default function Home() {
createMailbox, createMailbox,
renameMailbox, renameMailbox,
deleteMailbox, deleteMailbox,
batchDelete,
batchArchive,
batchMarkAsRead,
batchMarkAsSpam,
batchUndoSpam,
} = useEmailStore(); } = useEmailStore();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
@@ -353,27 +359,77 @@ export default function Home() {
onToggleStar: () => { onToggleStar: () => {
if (selectedEmail) handleToggleStar(); if (selectedEmail) handleToggleStar();
}, },
onArchive: () => { onArchive: async () => {
if (selectedEmail) handleArchive(); if (selectedEmailIds.size > 0 && client) {
try {
await batchArchive(client);
} catch (error) {
console.error("Failed to batch archive:", error);
}
} else if (selectedEmail) {
handleArchive();
}
}, },
onDelete: () => { onDelete: async () => {
if (selectedEmail) handleDelete(); if (selectedEmailIds.size > 0 && client) {
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
const isInJunk = currentMailbox?.role === 'junk';
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
const permanent = isInTrash || (isInJunk && permanentlyDeleteJunk);
const confirmed = await confirmDialog({
title: permanent
? t('email_list.permanent_delete_confirm_title')
: t('email_list.batch_actions.delete_confirm_title'),
message: permanent
? t('email_list.permanent_delete_confirm_batch_message', { count: selectedEmailIds.size })
: t('email_list.batch_actions.delete_confirm_message', { count: selectedEmailIds.size }),
confirmText: permanent
? t('email_list.permanent_delete')
: t('email_list.batch_actions.delete'),
variant: "destructive",
});
if (!confirmed) return;
try {
await batchDelete(client, permanent);
} catch (error) {
console.error("Failed to batch delete:", error);
}
} else if (selectedEmail) {
handleDelete();
}
}, },
onMarkAsUnread: async () => { onMarkAsUnread: async () => {
if (selectedEmail && client) { if (!client) return;
if (selectedEmailIds.size > 0) {
await batchMarkAsRead(client, false);
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, false); await markAsRead(client, selectedEmail.id, false);
} }
}, },
onMarkAsRead: async () => { onMarkAsRead: async () => {
if (selectedEmail && client) { if (!client) return;
if (selectedEmailIds.size > 0) {
await batchMarkAsRead(client, true);
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, true); await markAsRead(client, selectedEmail.id, true);
} }
}, },
onToggleSpam: () => { onToggleSpam: async () => {
if (selectedEmail) { const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
// Check if we're in junk folder const isInJunk = currentMailbox?.role === 'junk';
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); if (selectedEmailIds.size > 0 && client) {
const isInJunk = currentMailbox?.role === 'junk'; const ids = Array.from(selectedEmailIds);
try {
if (isInJunk) {
await batchUndoSpam(client, ids);
} else {
await batchMarkAsSpam(client, ids);
}
} catch (error) {
console.error("Failed to batch toggle spam:", error);
}
} else if (selectedEmail) {
if (isInJunk) { if (isInJunk) {
handleUndoSpam(); handleUndoSpam();
} else { } else {
@@ -408,13 +464,14 @@ export default function Home() {
clearSelection(); clearSelection();
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet]); }), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]);
// Initialize keyboard shortcuts // Initialize keyboard shortcuts
useKeyboardShortcuts({ useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer, enabled: isAuthenticated && !showComposer,
emails, emails,
selectedEmailId: selectedEmail?.id, selectedEmailId: selectedEmail?.id,
selectionCount: selectedEmailIds.size,
handlers: keyboardHandlers, handlers: keyboardHandlers,
}); });
@@ -595,11 +652,50 @@ export default function Home() {
}); });
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]); }, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
// System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or
// `?openLatestUnread=1` (generic "New mail" toast — happens when the
// preview API failed). We resolve those params once after the inbox has
// finished loading and open the right message, then strip the params so a
// refresh doesn't re-open it.
const notificationParamHandledRef = useRef(false);
useEffect(() => {
if (notificationParamHandledRef.current) return;
if (!isAuthenticated || !client) return;
if (mailboxes.length === 0) return;
const params = new URLSearchParams(window.location.search);
const emailIdParam = params.get('email');
const openLatestUnread = params.get('openLatestUnread') === '1';
if (!emailIdParam && !openLatestUnread) return;
// For the latest-unread case we need the inbox emails loaded; bail and
// let the effect re-run once `emails` is populated.
if (openLatestUnread && emails.length === 0) return;
notificationParamHandledRef.current = true;
window.history.replaceState({}, '', window.location.pathname);
if (emailIdParam) {
setLoadingEmail(true);
fetchEmailContent(client, emailIdParam).finally(() => setLoadingEmail(false));
return;
}
// emails are sorted receivedAt-desc, so the first unread is the newest.
const newestUnread = emails.find(e => !e.keywords?.$seen);
if (newestUnread) {
selectEmail(newestUnread);
}
}, [isAuthenticated, client, mailboxes.length, emails, fetchEmailContent, selectEmail, setLoadingEmail]);
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive) // Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
useEffect(() => { useEffect(() => {
if (!selectedEmail || !client) return; if (!selectedEmail || !client) return;
// If the email lacks bodyValues, it was auto-selected from the list and needs full content // If the email lacks bodyValues, it was auto-selected from the list and needs full content.
if (!selectedEmail.bodyValues) { // Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
// calling selectEmail on the stub), to avoid a duplicate request.
if (!selectedEmail.bodyValues && !isLoadingEmail) {
const perAccountClient = isUnifiedView && selectedEmail.accountId const perAccountClient = isUnifiedView && selectedEmail.accountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId) ? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
: undefined; : undefined;
@@ -694,6 +790,8 @@ export default function Home() {
fromName?: string; fromName?: string;
identityId?: string; identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
}) => { }) => {
if (!client) return; if (!client) return;
@@ -701,7 +799,7 @@ export default function Home() {
const effectiveMode = pendingDraft?.mode ?? composerMode; const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = selectedEmail?.id; const originalEmailId = selectedEmail?.id;
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments); await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references);
setShowComposer(false); setShowComposer(false);
// Mark the original email with $answered or $forwarded keyword // Mark the original email with $answered or $forwarded keyword
@@ -1393,6 +1491,12 @@ export default function Home() {
const originalEmailId = selectedEmail.id; const originalEmailId = selectedEmail.id;
// RFC 5322 §3.6.4 threading — keep the conversation stitched together (#234).
const threading = computeReplyThreadingHeaders({
messageId: selectedEmail.messageId,
references: selectedEmail.references,
});
// Send reply with just the body text // Send reply with just the body text
await sendEmail( await sendEmail(
client, client,
@@ -1404,7 +1508,11 @@ export default function Home() {
primaryIdentity?.id, primaryIdentity?.id,
primaryIdentity?.email, primaryIdentity?.email,
undefined, undefined,
primaryIdentity?.name || undefined primaryIdentity?.name || undefined,
undefined,
undefined,
threading?.inReplyTo,
threading?.references,
); );
// Mark the original email as answered // Mark the original email as answered
@@ -1446,7 +1554,13 @@ export default function Home() {
setShowComposer(false); setShowComposer(false);
} }
// Set loading state immediately (keep current email visible) // Show the list stub immediately so subject/sender render without
// waiting for the body fetch — avoids the loading flicker.
const listEmail = emails.find(e => e.id === email.id);
if (listEmail) {
selectEmail(listEmail);
}
setLoadingEmail(true); setLoadingEmail(true);
// On mobile, switch to viewer // On mobile, switch to viewer
@@ -1463,7 +1577,6 @@ export default function Home() {
try { try {
// In unified view each email carries its own accountId. Use that // In unified view each email carries its own accountId. Use that
// account's client so we fetch from the server that actually owns it. // account's client so we fetch from the server that actually owns it.
const listEmail = emails.find(e => e.id === email.id);
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined; const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
const perAccountClient = emailAccountId const perAccountClient = emailAccountId
? useAuthStore.getState().getClientForAccount(emailAccountId) ? useAuthStore.getState().getClientForAccount(emailAccountId)
@@ -2055,6 +2168,9 @@ export default function Home() {
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined, htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
receivedAt: selectedEmail.receivedAt, receivedAt: selectedEmail.receivedAt,
attachments: selectedEmail.attachments, attachments: selectedEmail.attachments,
messageId: selectedEmail.messageId,
inReplyTo: selectedEmail.inReplyTo,
references: selectedEmail.references,
} : undefined)} } : undefined)}
initialDraftText={composerDraftText} initialDraftText={composerDraftText}
initialData={pendingDraft} initialData={pendingDraft}
+2
View File
@@ -14,6 +14,7 @@ import {
KeyRound, KeyRound,
Puzzle, Puzzle,
SwatchBook, SwatchBook,
Activity,
Mail, Mail,
Calendar, Calendar,
BookUser, BookUser,
@@ -56,6 +57,7 @@ const NAV_GROUPS = [
{ {
label: 'System', label: 'System',
items: [ items: [
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText }, { href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
], ],
}, },
+542
View File
@@ -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>
);
}
+36 -25
View File
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react'; import Link from 'next/link';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
interface Extension { interface Extension {
@@ -262,10 +263,11 @@ function ExtensionCard({
onInstall: () => void; onInstall: () => void;
}) { }) {
const isPlugin = extension.type === 'plugin'; const isPlugin = extension.type === 'plugin';
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
return ( return (
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors"> <div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
<div className="p-4"> <Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
{/* Header */} {/* Header */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0"> <div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
@@ -277,7 +279,9 @@ function ExtensionCard({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground truncate">{extension.name}</span> <span className="text-sm font-medium text-foreground truncate group-hover:underline">
{extension.name}
</span>
{extension.featured && ( {extension.featured && (
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" /> <Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
)} )}
@@ -315,7 +319,7 @@ function ExtensionCard({
</div> </div>
)} )}
{/* Footer */} {/* Footer (download count + permissions) */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border"> <div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@@ -328,27 +332,34 @@ function ExtensionCard({
</span> </span>
)} )}
</div> </div>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
{extension.installed ? ( <Eye className="w-3 h-3" />
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium"> Preview
<Check className="w-3 h-3" /> </span>
Installed
</span>
) : (
<button
onClick={onInstall}
disabled={installing}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Install
</button>
)}
</div> </div>
</Link>
{/* Quick install button (sits over the link, stops navigation) */}
<div className="px-4 pb-4 -mt-1">
{extension.installed ? (
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
<Check className="w-3 h-3" />
Installed
</span>
) : (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
disabled={installing}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Quick install
</button>
)}
</div> </div>
</div> </div>
); );
+19 -1
View File
@@ -31,6 +31,7 @@ export default function AdminDashboardPage() {
const [pluginCount, setPluginCount] = useState(0); const [pluginCount, setPluginCount] = useState(0);
const [themeCount, setThemeCount] = useState(0); const [themeCount, setThemeCount] = useState(0);
const [policyRuleCount, setPolicyRuleCount] = useState(0); const [policyRuleCount, setPolicyRuleCount] = useState(0);
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown'); const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
useEffect(() => { useEffect(() => {
@@ -38,7 +39,7 @@ export default function AdminDashboardPage() {
}, []); }, []);
async function fetchDashboardData() { async function fetchDashboardData() {
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([ const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
apiFetch('/api/admin/auth'), apiFetch('/api/admin/auth'),
apiFetch('/api/admin/audit?limit=10'), apiFetch('/api/admin/audit?limit=10'),
apiFetch('/api/config'), apiFetch('/api/config'),
@@ -46,6 +47,7 @@ export default function AdminDashboardPage() {
apiFetch('/api/admin/plugins').catch(() => null), apiFetch('/api/admin/plugins').catch(() => null),
apiFetch('/api/admin/themes').catch(() => null), apiFetch('/api/admin/themes').catch(() => null),
apiFetch('/api/admin/policy').catch(() => null), apiFetch('/api/admin/policy').catch(() => null),
apiFetch('/api/admin/telemetry').catch(() => null),
]); ]);
if (statusRes.ok) setStatus(await statusRes.json()); if (statusRes.ok) setStatus(await statusRes.json());
@@ -73,6 +75,12 @@ export default function AdminDashboardPage() {
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0; const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
setPolicyRuleCount(restrictionCount + disabledGates); setPolicyRuleCount(restrictionCount + disabledGates);
} }
if (telemetryRes?.ok) {
const telemetry = await telemetryRes.json();
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
setAccountCounts(telemetry.accountCounts);
}
}
if (configData?.jmapServerUrl) { if (configData?.jmapServerUrl) {
try { try {
@@ -165,6 +173,16 @@ export default function AdminDashboardPage() {
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
{/* Accounts */}
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
</SettingItem>
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
</SettingItem>
</SettingsSection>
{/* Extensions */} {/* Extensions */}
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules"> <SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
<SettingItem label="Plugins"> <SettingItem label="Plugins">
+1
View File
@@ -15,6 +15,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' }, customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' }, templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' }, calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' }, smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' }, externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' }, debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
+250
View File
@@ -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); const buffer = await readFile(resolved);
// SVG can carry inline <script> and event handlers that execute when the
// file is fetched as a top-level document. Defense in depth on top of
// admin-only upload: nosniff blocks MIME confusion, the CSP forces a
// sandboxed unique origin so any script in an SVG is inert and cannot
// touch app cookies or storage.
return new NextResponse(buffer, { return new NextResponse(buffer, {
headers: { headers: {
'Content-Type': contentType, 'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600, must-revalidate', 'Cache-Control': 'public, max-age=3600, must-revalidate',
'Content-Length': String(buffer.length), 'Content-Length': String(buffer.length),
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy':
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
}, },
}); });
} catch { } catch {
+214
View File
@@ -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 },
);
}
}
+2 -2
View File
@@ -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 { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001'; const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
/** /**
* GET /api/admin/marketplace - Search/browse the extension directory * GET /api/admin/marketplace - Search/browse the extension directory
@@ -232,7 +232,7 @@ export async function POST(request: NextRequest) {
// Plugins may declare iframe origins they need for embedded content. // Plugins may declare iframe origins they need for embedded content.
// Anything that doesn't pass strict origin validation is silently // 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. // added to the host CSP.
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins) const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
+2 -2
View File
@@ -139,7 +139,7 @@ export async function POST(request: NextRequest) {
const queryEntry = queryRes.methodResponses?.[0]; const queryEntry = queryRes.methodResponses?.[0];
if (!queryEntry || queryEntry[0] === 'error') { if (!queryEntry || queryEntry[0] === 'error') {
return NextResponse.json({ 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], detail: queryEntry?.[1],
}, { status: 403 }); }, { status: 403 });
} }
@@ -187,7 +187,7 @@ export async function POST(request: NextRequest) {
const setEntry = setRes.methodResponses?.[0]; const setEntry = setRes.methodResponses?.[0];
if (!setEntry || setEntry[0] === 'error') { if (!setEntry || setEntry[0] === 'error') {
return NextResponse.json({ return NextResponse.json({
error: 'Stalwart denied OAuthClient/set admin permissions required.', error: 'Stalwart denied OAuthClient/set - admin permissions required.',
detail: setEntry?.[1], detail: setEntry?.[1],
}, { status: 403 }); }, { status: 403 });
} }
+31 -5
View File
@@ -2,15 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { getPlugin } from '@/lib/admin/plugin-registry'; import { getPlugin } from '@/lib/admin/plugin-registry';
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config'; import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
import { requireAdminAuth } from '@/lib/admin/session'; import { requireAdminAuth } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/** /**
* GET /api/admin/plugins/[id]/config - Read all config for a plugin * GET /api/admin/plugins/[id]/config - Read plugin config
* *
* Returns the full config object for admin-configured plugin settings. * - Admin sessions receive every field, including those declared
* This endpoint is accessible from the client-side plugin API. * `type: 'secret'` in the plugin's configSchema.
* - Authenticated mailbox users (the plugin running in their browser)
* receive only non-secret fields.
* - Anonymous callers are rejected so unauthenticated visitors cannot
* enumerate plugin secrets.
*/ */
export async function GET( export async function GET(
_request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
@@ -20,13 +25,34 @@ export async function GET(
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 }); return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
} }
const adminAuth = await requireAdminAuth();
const isAdmin = !('error' in adminAuth);
if (!isAdmin) {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
}
const plugin = await getPlugin(id); const plugin = await getPlugin(id);
if (!plugin) { if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
} }
const config = await getPluginConfig(id); const config = await getPluginConfig(id);
return NextResponse.json(config, {
let response: Record<string, unknown> = config;
if (!isAdmin && plugin.configSchema) {
response = {};
for (const [key, value] of Object.entries(config)) {
const field = plugin.configSchema[key];
if (field?.type === 'secret') continue;
response[key] = value;
}
}
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
}); });
} catch { } catch {
+135
View File
@@ -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 });
}
}
+32 -1
View File
@@ -10,6 +10,8 @@ import {
setStalwartAuthContextInStore, setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context'; } from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
const COOKIE_OPTIONS = { const COOKIE_OPTIONS = {
...getCookieOptions(), ...getCookieOptions(),
@@ -37,10 +39,37 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server URL
// is configured AND the deployment explicitly allows custom JMAP endpoints
// do we honor the body URL — and even then it must be a public URL.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
let upstreamUrl: string;
let upstreamTrusted: boolean;
if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
upstreamTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
}
upstreamUrl = serverUrl;
upstreamTrusted = false;
} else {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot); const cookieName = sessionCookieName(slot);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader); const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
const token = encryptSession(normalizedServerUrl, username, password); const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS); cookieStore.set(cookieName, token, COOKIE_OPTIONS);
@@ -50,6 +79,8 @@ export async function POST(request: NextRequest) {
authHeader, authHeader,
}); });
void recordLogin(username, normalizedServerUrl);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
if (error instanceof JmapAuthVerificationError) { if (error instanceof JmapAuthVerificationError) {
+33 -1
View File
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context'; import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
function getSlot(request: NextRequest, bodySlot: unknown): number { function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) { if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
@@ -23,8 +26,35 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server URL
// is configured AND the deployment explicitly allows custom JMAP endpoints
// do we honor the body URL — and even then it must be a public URL.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
let upstreamUrl: string;
let upstreamTrusted: boolean;
if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
upstreamTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
}
upstreamUrl = serverUrl;
upstreamTrusted = false;
} else {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
const slot = getSlot(request, bodySlot); const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader); const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
await setStalwartAuthContext(slot, { await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl, serverUrl: normalizedServerUrl,
@@ -32,6 +62,8 @@ export async function POST(request: NextRequest) {
authHeader, authHeader,
}); });
void recordLogin(username, normalizedServerUrl);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
if (error instanceof JmapAuthVerificationError) { if (error instanceof JmapAuthVerificationError) {
+36 -12
View File
@@ -6,6 +6,8 @@ import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env'; import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
/** /**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens. * Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -84,22 +86,39 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0; const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
// Use the server-side JMAP_SERVER_URL if set (may differ from the // Pin the upstream URL to the configured JMAP server so an unauthenticated
// public URL the browser uses, e.g. inside Docker). // caller cannot point this route at internal hosts. Only when no server
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl; // URL is configured (and the deployment explicitly allows custom JMAP
// endpoints) do we fall back to the user-supplied URL - and even then
// it must resolve to a public address.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
const tokenEndpoint = await findTokenEndpoint(internalServerUrl); let upstreamUrl: string;
if (!tokenEndpoint) { if (configuredServerUrl) {
// Also try with the client-provided URL in case the internal one differs upstreamUrl = configuredServerUrl;
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null; } else if (allowCustomEndpoint) {
if (!clientEndpoint) { if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl }); logger.warn('TOTP token exchange: rejected non-public server URL');
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 }); return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
} }
return await attemptAllStrategies(clientEndpoint, username, password, slot); upstreamUrl = serverUrl;
} else {
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
} }
return await attemptAllStrategies(tokenEndpoint, username, password, slot); const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
if (!tokenEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found');
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
}
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot);
} catch (error) { } catch (error) {
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' }); logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -108,6 +127,7 @@ export async function POST(request: NextRequest) {
async function attemptAllStrategies( async function attemptAllStrategies(
tokenEndpoint: string, tokenEndpoint: string,
serverUrl: string,
username: string, username: string,
password: string, password: string,
slot: number, slot: number,
@@ -126,6 +146,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params); const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) { if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC with client_id)'); logger.info('TOTP token exchange succeeded (ROPC with client_id)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot); return await storeAndRespond(result.tokens, slot);
} }
attempts.push({ strategy: 'ROPC with client_id', error: result.error }); attempts.push({ strategy: 'ROPC with client_id', error: result.error });
@@ -137,6 +158,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params); const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) { if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC without client_id)'); logger.info('TOTP token exchange succeeded (ROPC without client_id)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot); return await storeAndRespond(result.tokens, slot);
} }
attempts.push({ strategy: 'ROPC without client_id', error: result.error }); attempts.push({ strategy: 'ROPC without client_id', error: result.error });
@@ -148,6 +170,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth }); const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) { if (result.ok) {
logger.info('TOTP token exchange succeeded (Basic Auth header)'); logger.info('TOTP token exchange succeeded (Basic Auth header)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot); return await storeAndRespond(result.tokens, slot);
} }
attempts.push({ strategy: 'Basic Auth header', error: result.error }); attempts.push({ strategy: 'Basic Auth header', error: result.error });
@@ -159,6 +182,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth }); const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) { if (result.ok) {
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)'); logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot); return await storeAndRespond(result.tokens, slot);
} }
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error }); attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
+140
View File
@@ -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 });
}
}
+76
View File
@@ -204,6 +204,31 @@ body {
min-width: 100%; min-width: 100%;
} }
/* Forces light-theme CSS variables inside the email content area, so when
"Always Show Emails in Light Mode" is enabled in dark theme the surrounding
sender info / attachments / plain-text body don't end up with light text
on a white background. */
.email-content-light {
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-muted: #f1f5f9;
--color-muted-foreground: #64748b;
--color-border: #e2e8f0;
--color-card: #ffffff;
--color-card-foreground: #0f172a;
--color-popover: #ffffff;
--color-popover-foreground: #0f172a;
--color-secondary: #f8fafc;
--color-secondary-foreground: #0f172a;
--color-accent: #dbeafe;
--color-accent-foreground: #1e40af;
--color-input: #e2e8f0;
}
.email-content-light .email-content-text a {
color: #2563eb;
}
.email-content-text { .email-content-text {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
@@ -680,3 +705,54 @@ body {
.tiptap .ProseMirror-selectednode img { .tiptap .ProseMirror-selectednode img {
outline: none; outline: none;
} }
.tiptap table {
border-collapse: collapse;
margin: 0.5rem 0;
table-layout: fixed;
width: 100%;
overflow: hidden;
}
.tiptap table td,
.tiptap table th {
border: 1px solid var(--color-border);
padding: 0.375rem 0.5rem;
vertical-align: top;
position: relative;
min-width: 1em;
}
.tiptap table th {
background-color: var(--color-muted) !important;
color: var(--color-foreground) !important;
font-weight: 600;
text-align: left;
}
.tiptap table p {
margin: 0;
}
.tiptap table .selectedCell::after {
background: rgba(99, 102, 241, 0.15);
content: "";
inset: 0;
pointer-events: none;
position: absolute;
z-index: 2;
}
.tiptap table .column-resize-handle {
background-color: var(--color-primary);
bottom: -2px;
pointer-events: none;
position: absolute;
right: -2px;
top: 0;
width: 4px;
}
.tiptap.resize-cursor {
cursor: col-resize;
}
+10 -1
View File
@@ -20,6 +20,7 @@ interface CalendarDayViewProps {
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void; onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void; onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
isMobile?: boolean; isMobile?: boolean;
@@ -39,6 +40,7 @@ export function CalendarDayView({
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent, onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime, onCreateAtTime,
timeFormat = "24h", timeFormat = "24h",
isMobile, isMobile,
@@ -144,7 +146,13 @@ export function CalendarDayView({
</div> </div>
{(allDayEvents.length > 0 || dayTasks.length > 0) && ( {(allDayEvents.length > 0 || dayTasks.length > 0) && (
<div className="px-4 py-2 border-b border-border"> <div
className="px-4 py-2 border-b border-border"
onContextMenu={onContextMenuEmpty ? (e) => {
if ((e.target as HTMLElement).closest("[data-calendar-event],button")) return;
onContextMenuEmpty(e, selectedDate, undefined, true);
} : undefined}
>
{allDayEvents.length > 0 && ( {allDayEvents.length > 0 && (
<> <>
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div> <div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
@@ -240,6 +248,7 @@ export function CalendarDayView({
aria-label={formatHour(h)} aria-label={formatHour(h)}
onClick={() => handleSlotClick(selectedDate, h)} onClick={() => handleSlotClick(selectedDate, h)}
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)} onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, selectedDate, h, false) : undefined}
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
style={{ height: HOUR_HEIGHT }} style={{ height: HOUR_HEIGHT }}
/> />
@@ -24,6 +24,7 @@ interface CalendarMonthViewProps {
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void; onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime?: (date: Date) => void; onCreateAtTime?: (date: Date) => void;
firstDayOfWeek?: number; firstDayOfWeek?: number;
isMobile?: boolean; isMobile?: boolean;
@@ -39,6 +40,7 @@ export function CalendarMonthView({
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent, onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime, onCreateAtTime,
firstDayOfWeek = 1, firstDayOfWeek = 1,
isMobile, isMobile,
@@ -174,6 +176,7 @@ export function CalendarMonthView({
aria-label={fullDateLabel} aria-label={fullDateLabel}
onClick={() => onSelectDate(day)} onClick={() => onSelectDate(day)}
onDoubleClick={() => onCreateAtTime?.(day)} onDoubleClick={() => onCreateAtTime?.(day)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
onDragOver={(e) => handleCellDragOver(e, key)} onDragOver={(e) => handleCellDragOver(e, key)}
onDragLeave={handleCellDragLeave} onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)} onDrop={(e) => handleCellDrop(e, day)}
+8 -1
View File
@@ -23,6 +23,7 @@ interface CalendarWeekViewProps {
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void; onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void; onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number; firstDayOfWeek?: number;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
@@ -44,6 +45,7 @@ export function CalendarWeekView({
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent, onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime, onCreateAtTime,
firstDayOfWeek = 1, firstDayOfWeek = 1,
timeFormat = "24h", timeFormat = "24h",
@@ -219,7 +221,11 @@ export function CalendarWeekView({
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }} style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
> >
{weekDays.map((day) => ( {weekDays.map((day) => (
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" /> <div
key={format(day, "yyyy-MM-dd")}
className="bg-background min-h-[28px]"
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
/>
))} ))}
<div className="absolute inset-0 pointer-events-none"> <div className="absolute inset-0 pointer-events-none">
@@ -378,6 +384,7 @@ export function CalendarWeekView({
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`} aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
onClick={() => handleSlotClick(day, h)} onClick={() => handleSlotClick(day, h)}
onDoubleClick={() => handleSlotDoubleClick(day, h)} onDoubleClick={() => handleSlotDoubleClick(day, h)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
style={{ height: HOUR_HEIGHT }} style={{ height: HOUR_HEIGHT }}
/> />
@@ -0,0 +1,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>
);
}
+4 -1
View File
@@ -19,6 +19,7 @@ import {
getUserStatus, getUserStatus,
getParticipantList, getParticipantList,
} from "@/lib/calendar-participants"; } from "@/lib/calendar-participants";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
interface EventDetailPopoverProps { interface EventDetailPopoverProps {
event: CalendarEvent; event: CalendarEvent;
@@ -253,6 +254,8 @@ export function EventDetailPopover({
const hasParticipants = participants.length > 0; const hasParticipants = participants.length > 0;
const formatEventDate = useFormatEventDate();
const popover = ( const popover = (
<div <div
ref={popoverRef} ref={popoverRef}
@@ -329,7 +332,7 @@ export function EventDetailPopover({
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> <Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm"> <div className="text-sm">
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{format(startDate, "EEE, MMM d, yyyy")} {formatEventDate(startDate)}
</span> </span>
{event.showWithoutTime ? ( {event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span> <span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
+111 -107
View File
@@ -21,6 +21,7 @@ import {
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils"; import { generateUUID } from "@/lib/utils";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
export interface PendingEventPreview { export interface PendingEventPreview {
start: Date; start: Date;
@@ -35,6 +36,7 @@ interface EventModalProps {
calendars: Calendar[]; calendars: Calendar[];
defaultDate?: Date; defaultDate?: Date;
defaultEndDate?: Date; defaultEndDate?: Date;
defaultAllDay?: boolean;
defaultCalendarId?: string; defaultCalendarId?: string;
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>; onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void; onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
@@ -114,6 +116,7 @@ export function EventModal({
calendars, calendars,
defaultDate, defaultDate,
defaultEndDate, defaultEndDate,
defaultAllDay,
defaultCalendarId, defaultCalendarId,
onSave, onSave,
onDelete, onDelete,
@@ -128,6 +131,7 @@ export function EventModal({
const timeFormat = useSettingsStore((s) => s.timeFormat); const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const isEdit = !!event; const isEdit = !!event;
const formatEventDate = useFormatEventDate();
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit"); const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
const userIsOrganizer = useMemo(() => { const userIsOrganizer = useMemo(() => {
@@ -199,7 +203,7 @@ export function EventModal({
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart())); const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd())); const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd())); const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
const [allDay, setAllDay] = useState(event?.showWithoutTime || false); const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false);
const [calendarId, setCalendarId] = useState<string>(() => { const [calendarId, setCalendarId] = useState<string>(() => {
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || ""; if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId; if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
@@ -493,14 +497,14 @@ export function EventModal({
return ( return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}> <div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2> <h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}> <button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-3"> <div className="px-6 py-4 space-y-3">
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3"> <div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" /> <CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
@@ -515,7 +519,7 @@ export function EventModal({
</div> </div>
<div className="text-sm"> <div className="text-sm">
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span> <span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && ( {!event.showWithoutTime && (
<span className="text-muted-foreground ml-2"> <span className="text-muted-foreground ml-2">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)} {format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
@@ -548,48 +552,48 @@ export function EventModal({
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="px-6 py-4 border-t border-border flex-shrink-0"> <div className="px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span> <span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
size="sm" size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"} variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")} onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted" className={userCurrentStatus === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground" ? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"} : "text-success border-success/30 hover:bg-success/10"}
> >
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />} {userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
{t("participants.accepted")} {t("participants.accepted")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"} variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")} onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative" className={userCurrentStatus === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground" ? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"} : "border border-warning/30 text-warning hover:bg-warning/10"}
> >
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />} {userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
{t("participants.tentative")} {t("participants.tentative")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant={userCurrentStatus === "declined" ? "default" : "ghost"} variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")} onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined" className={userCurrentStatus === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground" ? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"} : "text-destructive hover:bg-destructive/10"}
> >
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />} {userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
{t("participants.declined")} {t("participants.declined")}
</Button> </Button>
</div>
</div> </div>
</div> </div>
</div>
</div> </div>
); );
} }
@@ -636,7 +640,7 @@ export function EventModal({
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> <Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm"> <div className="text-sm">
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{format(startD, "EEE, MMM d, yyyy")} {formatEventDate(startD)}
</span> </span>
{event.showWithoutTime ? ( {event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span> <span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
@@ -765,16 +769,16 @@ export function EventModal({
return ( return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}> <div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")} {isEdit ? t("events.edit") : t("events.create")}
</h2> </h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}> <button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-4"> <div className="px-6 py-4 space-y-4">
<div> <div>
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label> <label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
@@ -984,69 +988,69 @@ export function EventModal({
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isEdit && onDelete && ( {isEdit && onDelete && (
showDeleteConfirm ? ( showDeleteConfirm ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div> <div>
<span className="text-sm text-red-600 dark:text-red-400"> <span className="text-sm text-red-600 dark:text-red-400">
{t("form.delete_confirm")} {t("form.delete_confirm")}
</span> </span>
{hasParticipants && ( {hasParticipants && (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{t("participants.cancel_notification")} {t("participants.cancel_notification")}
</p> </p>
)} )}
</div>
<Button
variant="outline"
size="sm"
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
>
{t("events.delete")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
{t("form.cancel")}
</Button>
</div> </div>
) : (
<Button <Button
variant="ghost" variant="outline"
size="sm" size="sm"
onClick={() => setShowDeleteConfirm(true)} onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400" className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
> >
<Trash2 className="w-4 h-4 mr-1" />
{t("events.delete")} {t("events.delete")}
</Button> </Button>
) <Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
)} {t("form.cancel")}
{isEdit && onDuplicate && !showDeleteConfirm && ( </Button>
</div>
) : (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleDuplicate} onClick={() => setShowDeleteConfirm(true)}
aria-label={t("events.duplicate")} className="text-red-600 dark:text-red-400"
> >
<Copy className="w-4 h-4 mr-1" /> <Trash2 className="w-4 h-4 mr-1" />
{t("events.duplicate")} {t("events.delete")}
</Button> </Button>
)} )
</div> )}
{isEdit && onDuplicate && !showDeleteConfirm && (
<div className="flex gap-2"> <Button
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}> variant="ghost"
{t("form.cancel")} size="sm"
onClick={handleDuplicate}
aria-label={t("events.duplicate")}
>
<Copy className="w-4 h-4 mr-1" />
{t("events.duplicate")}
</Button> </Button>
<Button onClick={handleSave} disabled={!title.trim() || isSaving}> )}
{t("form.save")}
</Button>
</div>
</div> </div>
<div className="flex gap-2">
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
{t("form.cancel")}
</Button>
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
{t("form.save")}
</Button>
</div>
</div>
</div> </div>
); );
} }
+52 -15
View File
@@ -31,6 +31,7 @@ import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types"; import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { findReplyIdentityId } from "@/lib/reply-identity"; import { findReplyIdentityId } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { RichTextEditor } from "@/components/email/rich-text-editor"; import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */ /** Strip HTML tags and decode entities to get a plain-text version */
@@ -67,6 +68,8 @@ interface EmailComposerProps {
fromName?: string; fromName?: string;
identityId?: string; identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
}) => void | Promise<void>; }) => void | Promise<void>;
onClose?: () => void; onClose?: () => void;
onDiscardDraft?: (draftId: string) => void; onDiscardDraft?: (draftId: string) => void;
@@ -87,6 +90,11 @@ interface EmailComposerProps {
receivedAt?: string; receivedAt?: string;
accountId?: string; accountId?: string;
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>; attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
// Threading: parent's Message-ID and References, used to set RFC 5322
// In-Reply-To and References on outgoing replies. See #234.
messageId?: string;
inReplyTo?: string[];
references?: string[];
}; };
} }
@@ -116,6 +124,7 @@ export function EmailComposer({
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode); const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
@@ -721,7 +730,7 @@ export function EmailComposer({
// Generate sub-addressed email if tag is set // Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email const fromEmail = currentIdentity?.email
? subAddressTag ? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag) ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email : currentIdentity.email
: undefined; : undefined;
@@ -736,7 +745,8 @@ export function EmailComposer({
fromEmail, fromEmail,
draftId || undefined, draftId || undefined,
uploadedAttachments, uploadedAttachments,
currentIdentity?.name || undefined currentIdentity?.name || undefined,
plainTextMode ? undefined : body
); );
setDraftId(savedDraftId); setDraftId(savedDraftId);
@@ -812,19 +822,27 @@ export function EmailComposer({
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>; attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
} => { } => {
const known = inlineImagesRef.current; const known = inlineImagesRef.current;
if (known.length === 0) return { html, attachments: [] };
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html'); const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
const used = new Map<string, typeof known[number]>(); const used = new Map<string, typeof known[number]>();
doc.querySelectorAll('img[data-cid]').forEach((img) => { if (known.length > 0) {
const cid = img.getAttribute('data-cid'); doc.querySelectorAll('img[data-cid]').forEach((img) => {
if (!cid) return; const cid = img.getAttribute('data-cid');
const entry = known.find((e) => e.cid === cid); if (!cid) return;
if (!entry) return; const entry = known.find((e) => e.cid === cid);
img.setAttribute('src', `cid:${cid}`); if (!entry) return;
img.removeAttribute('data-cid'); img.setAttribute('src', `cid:${cid}`);
used.set(cid, entry); img.removeAttribute('data-cid');
used.set(cid, entry);
});
}
// Recipient mail clients apply default <p> margins inside table cells,
// inflating row height. Tiptap wraps cell text in <p>, so force margin:0
// to match the composer's tight rows.
doc.querySelectorAll('td > p, th > p').forEach((p) => {
const existing = p.getAttribute('style') || '';
p.setAttribute('style', `margin:0;${existing}`);
}); });
return { return {
@@ -889,7 +907,7 @@ export function EmailComposer({
const fromEmail = currentIdentity?.email const fromEmail = currentIdentity?.email
? subAddressTag ? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag) ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email : currentIdentity.email
: undefined; : undefined;
@@ -905,6 +923,11 @@ export function EmailComposer({
return ''; return '';
}; };
// RFC 5322 §3.6.4 threading — only continues the chain on a reply, not a forward.
const threadingHeaders = (mode === 'reply' || mode === 'replyAll')
? computeReplyThreadingHeaders(replyTo)
: null;
// In plain text mode, send text/plain only (no HTML body) // In plain text mode, send text/plain only (no HTML body)
const finalBody = plainTextMode const finalBody = plainTextMode
? appendPlainTextSignature(body, currentIdentity) ? appendPlainTextSignature(body, currentIdentity)
@@ -968,12 +991,22 @@ export function EmailComposer({
} }
// 4. Build canonical MIME // 4. Build canonical MIME
// mime-builder takes inReplyTo as a single ref-form msg-id (with brackets);
// references stays an array. threadingHeaders contains bare msg-ids.
const mimeInReplyTo = threadingHeaders?.inReplyTo[0]
? `<${threadingHeaders.inReplyTo[0]}>`
: undefined;
const mimeReferences = threadingHeaders?.references.length
? threadingHeaders.references.map(id => `<${id}>`)
: undefined;
const mimeBytes = buildMimeMessage({ const mimeBytes = buildMimeMessage({
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
to: toAddresses.map(e => ({ email: e })), to: toAddresses.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined, cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined, bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
subject, subject,
inReplyTo: mimeInReplyTo,
references: mimeReferences,
textBody: finalBody, textBody: finalBody,
htmlBody: finalHtmlBody, htmlBody: finalHtmlBody,
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined, attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
@@ -986,6 +1019,8 @@ export function EmailComposer({
to: toAddresses.map(e => ({ email: e })), to: toAddresses.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined, cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
subject, subject,
inReplyTo: mimeInReplyTo,
references: mimeReferences,
}; };
// 5. Sign if enabled // 5. Sign if enabled
@@ -1042,6 +1077,8 @@ export function EmailComposer({
fromName: currentIdentity?.name || undefined, fromName: currentIdentity?.name || undefined,
identityId: currentIdentity?.id, identityId: currentIdentity?.id,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
}); });
} }
@@ -1179,7 +1216,7 @@ export function EmailComposer({
> >
{identities.map((identity) => { {identities.map((identity) => {
const displayEmail = subAddressTag const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag) ? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email; : identity.email;
return ( return (
<option key={identity.id} value={identity.id}> <option key={identity.id} value={identity.id}>
@@ -1192,7 +1229,7 @@ export function EmailComposer({
<span className="text-sm text-foreground flex-1 truncate"> <span className="text-sm text-foreground flex-1 truncate">
{subAddressTag ? ( {subAddressTag ? (
<span className="font-mono"> <span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)} {generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
</span> </span>
) : ( ) : (
<> <>
+6 -4
View File
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { Email, Identity } from '@/lib/jmap/types'; import type { Email, Identity } from '@/lib/jmap/types';
import { parseSubAddress } from '@/lib/sub-addressing'; import { parseSubAddress } from '@/lib/sub-addressing';
import { useSettingsStore } from '@/stores/settings-store';
interface EmailIdentityBadgeProps { interface EmailIdentityBadgeProps {
email: Email; email: Email;
@@ -20,12 +21,13 @@ export function EmailIdentityBadge({
className, className,
}: EmailIdentityBadgeProps) { }: EmailIdentityBadgeProps) {
const t = useTranslations('identities.badge'); const t = useTranslations('identities.badge');
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
const fromAddress = email.from?.[0]?.email; const fromAddress = email.from?.[0]?.email;
if (!fromAddress) return null; if (!fromAddress) return null;
// Parse the from address to check for sub-addressing // Parse the from address to check for sub-addressing
const parsedFrom = parseSubAddress(fromAddress); const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
// Find matching identity (email sent BY the user) // Find matching identity (email sent BY the user)
const matchingIdentity = identities.find( const matchingIdentity = identities.find(
@@ -37,7 +39,7 @@ export function EmailIdentityBadge({
if (!matchingIdentity) { if (!matchingIdentity) {
// Check all TO addresses for sub-address tags matching user's identities // Check all TO addresses for sub-address tags matching user's identities
for (const recipient of email.to || []) { for (const recipient of email.to || []) {
const parsedTo = parseSubAddress(recipient.email); const parsedTo = parseSubAddress(recipient.email, subAddressDelimiter);
if (parsedTo.tag) { if (parsedTo.tag) {
// Check if this base email matches any of the user's identities // Check if this base email matches any of the user's identities
const matchingToIdentity = identities.find( const matchingToIdentity = identities.find(
@@ -70,7 +72,7 @@ export function EmailIdentityBadge({
title={t('sub_address_tag', { tag: displayTag })} title={t('sub_address_tag', { tag: displayTag })}
> >
<Tag className="w-3 h-3" /> <Tag className="w-3 h-3" />
<span className="font-mono">+{displayTag}</span> <span className="font-mono">{subAddressDelimiter}{displayTag}</span>
</div> </div>
); );
} }
@@ -114,7 +116,7 @@ export function EmailIdentityBadge({
aria-label={t('sub_address_tag', { tag: displayTag })} aria-label={t('sub_address_tag', { tag: displayTag })}
> >
<Tag className="w-3 h-3" /> <Tag className="w-3 h-3" />
<span className="font-mono">{t('subaddress_tag', { tag: displayTag })}</span> <span className="font-mono">{subAddressDelimiter}{displayTag}</span>
</div> </div>
)} )}
+131 -35
View File
@@ -2285,7 +2285,7 @@ export function EmailViewer({
// Sanitize and prepare email HTML content // Sanitize and prepare email HTML content
const emailContent = useMemo(() => { const emailContent = useMemo(() => {
if (!email) return { html: "", isHtml: false }; if (!email) return { html: "", isHtml: false, hasStyleTag: false };
// Check if we have body values // Check if we have body values
if (email.bodyValues) { if (email.bodyValues) {
@@ -2338,8 +2338,7 @@ export function EmailViewer({
); );
if (shouldBlockExternal) { if (shouldBlockExternal) {
sanitizeConfig.FORBID_TAGS.push('link'); sanitizeConfig.FORBID_TAGS = [...sanitizeConfig.FORBID_TAGS, 'link'];
sanitizeConfig.FORBID_ATTR.push('background');
} }
DOMPurify.addHook('afterSanitizeAttributes', (node) => { DOMPurify.addHook('afterSanitizeAttributes', (node) => {
@@ -2357,11 +2356,19 @@ export function EmailViewer({
} }
} }
const bgAttr = node.getAttribute?.('background');
if (bgAttr && (bgAttr.startsWith('http://') || bgAttr.startsWith('https://') || bgAttr.startsWith('//'))) {
node.setAttribute('data-blocked-background', bgAttr);
node.removeAttribute('background');
blockedExternalContent = true;
}
if (htmlNode.style) { if (htmlNode.style) {
const style = htmlNode.style.cssText; const style = htmlNode.style.cssText;
if (style && style.includes('url(')) { if (style && style.includes('url(')) {
const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi); const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
if (urlMatch) { if (urlMatch) {
node.setAttribute('data-blocked-style', style);
htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()'); htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
blockedExternalContent = true; blockedExternalContent = true;
} }
@@ -2395,7 +2402,8 @@ export function EmailViewer({
return { return {
html: cleanHtml, html: cleanHtml,
isHtml: true isHtml: true,
hasStyleTag: /<style[\s>]/i.test(htmlContent),
}; };
} }
@@ -2405,7 +2413,8 @@ export function EmailViewer({
return { return {
html: plainTextToSafeHtml(textContent), html: plainTextToSafeHtml(textContent),
isHtml: false isHtml: false,
hasStyleTag: false,
}; };
} }
} }
@@ -2419,15 +2428,21 @@ export function EmailViewer({
return { return {
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`, html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
isHtml: false isHtml: false,
hasStyleTag: false,
}; };
} }
return { return {
html: '<p style="color: var(--color-muted-foreground);">No content available</p>', html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false isHtml: false,
hasStyleTag: false,
}; };
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]); // Intentionally omit allowExternalContent and trust state from deps:
// toggling permission imperatively unblocks content via restoreBlockedContent
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, externalContentPolicy, cidBlobUrls]);
// Override email content with S/MIME decrypted content when available // Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => { const effectiveEmailContent = useMemo(() => {
@@ -2439,26 +2454,26 @@ export function EmailViewer({
} }
); );
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG); const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true }; return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(smimeDecryptedHtml) };
} }
if (smimeDecryptedText) { if (smimeDecryptedText) {
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false }; return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false };
} }
// TNEF (winmail.dat) extracted content // TNEF (winmail.dat) extracted content
if (tnefHtml) { if (tnefHtml) {
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG); const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true }; return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml) };
} }
if (tnefText) { if (tnefText) {
return { html: plainTextToSafeHtml(tnefText), isHtml: false }; return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false };
} }
// Embedded message/rfc822 unwrapped content // Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) { if (embeddedEmailHtml) {
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG); const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true }; return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml) };
} }
if (embeddedEmailText) { if (embeddedEmailText) {
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false }; return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false };
} }
return emailContent; return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
@@ -2600,10 +2615,14 @@ export function EmailViewer({
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light'; const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
// Bare HTML emails (no <style>) tend to be plain prose without their own
// layout — give them the same padding as plain-text mails (.email-content-text).
const bodyPadding = effectiveEmailContent.hasStyleTag ? '0' : '1rem 1.25rem';
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<style> <style>
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; } body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100% !important; height: auto !important; } img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; } a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; } table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
@@ -2613,6 +2632,79 @@ export function EmailViewer({
</style></head><body>${effectiveEmailContent.html}</body></html>`; </style></head><body>${effectiveEmailContent.html}</body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]); }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
// Imperatively restore blocked external content inside the iframe document.
// Avoids re-rendering the iframe srcDoc (which would reload and flash) when
// the user clicks "Load images" or "Trust sender".
const restoreBlockedContent = useCallback(() => {
const doc = iframeRef.current?.contentDocument;
if (!doc) return;
doc.querySelectorAll('img[data-blocked-src]').forEach((node) => {
const el = node as HTMLImageElement;
const src = el.getAttribute('data-blocked-src');
if (src) {
el.setAttribute('src', src);
el.style.display = '';
el.removeAttribute('data-blocked-src');
}
});
doc.querySelectorAll('[data-blocked-style]').forEach((node) => {
const el = node as HTMLElement;
const style = el.getAttribute('data-blocked-style');
if (style !== null) {
el.style.cssText = style;
el.removeAttribute('data-blocked-style');
}
});
doc.querySelectorAll('[data-blocked-background]').forEach((node) => {
const el = node as HTMLElement;
const bg = el.getAttribute('data-blocked-background');
if (bg) {
el.setAttribute('background', bg);
el.removeAttribute('data-blocked-background');
}
});
doc.querySelectorAll('[data-blocked-collapsed-style]').forEach((node) => {
const el = node as HTMLElement;
const style = el.getAttribute('data-blocked-collapsed-style');
if (style !== null) {
el.style.cssText = style;
el.removeAttribute('data-blocked-collapsed-style');
}
});
}, []);
// Whenever permission is granted (allow toggled, or sender becomes trusted),
// restore blocked content in the existing iframe — no srcDoc rebuild.
const senderEmailLower = email?.from?.[0]?.email?.toLowerCase();
const senderIsTrustedNow = senderEmailLower
? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower))
: false;
useEffect(() => {
if (!hasBlockedContent) return;
if (!allowExternalContent && !senderIsTrustedNow) return;
restoreBlockedContent();
}, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]);
// Tracks the last rendered body height so the loading skeleton can hold
// the same size — avoids the body shrink/expand flash when switching emails.
const lastBodyHeightRef = useRef<number>(300);
// True while the new email's body is still being fetched. Catches the
// window between selectedEmail changing and isLoading flipping true, so the
// quick reply / body don't flicker through a partial render.
const isBodyLoading = isLoading || !email?.bodyValues || Object.keys(email.bodyValues).length === 0;
// Gates the quick reply on the iframe having loaded the current srcDoc, so
// it doesn't flash in below a still-resizing iframe.
const [iframeReady, setIframeReady] = useState(false);
useLayoutEffect(() => {
setIframeReady(false);
}, [emailIframeSrcDoc]);
const handleIframeLoad = useCallback(() => { const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current; const iframe = iframeRef.current;
if (!iframe) return; if (!iframe) return;
@@ -2623,9 +2715,13 @@ export function EmailViewer({
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
const height = doc.documentElement.scrollHeight; const height = doc.documentElement.scrollHeight;
iframe.style.height = height + 'px'; iframe.style.height = height + 'px';
lastBodyHeightRef.current = height;
}); });
resizeObserver.observe(doc.body); resizeObserver.observe(doc.body);
iframe.style.height = doc.documentElement.scrollHeight + 'px'; const initialHeight = doc.documentElement.scrollHeight;
iframe.style.height = initialHeight + 'px';
lastBodyHeightRef.current = initialHeight;
setIframeReady(true);
// Make links open in new tab // Make links open in new tab
doc.querySelectorAll('a').forEach(a => { doc.querySelectorAll('a').forEach(a => {
@@ -2946,11 +3042,6 @@ export function EmailViewer({
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */} {/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
<div className="flex items-center gap-0 sm:gap-0.5"> <div className="flex items-center gap-0 sm:gap-0.5">
{isLoading && (
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
)}
{/* Archive */} {/* Archive */}
<Button <Button
variant="ghost" variant="ghost"
@@ -3629,15 +3720,6 @@ export function EmailViewer({
)} )}
{/* Main email content */} {/* Main email content */}
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0"> <div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
{/* Loading overlay when fetching new email */}
{isLoading && (
<div className="absolute inset-0 bg-background/60 backdrop-blur-[2px] z-50 flex items-center justify-center animate-in fade-in duration-200">
<div className="bg-background rounded-lg shadow-lg border border-border p-4 flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
<span className="text-sm font-medium text-foreground">{t('loading_email')}</span>
</div>
</div>
)}
{/* === TOOLBAR (top position) === */} {/* === TOOLBAR (top position) === */}
{toolbarPosition === 'top' && ( {toolbarPosition === 'top' && (
<div className={cn( <div className={cn(
@@ -4634,14 +4716,28 @@ export function EmailViewer({
<PluginSlot name="email-banner" extraProps={{ email }} /> <PluginSlot name="email-banner" extraProps={{ email }} />
{/* Email Body */} {/* Email Body */}
<div className="email-content-wrapper overflow-x-auto"> <div className={cn(
{effectiveEmailContent.isHtml ? ( "email-content-wrapper overflow-x-auto",
emailAlwaysLightMode ? "bg-white email-content-light" : "bg-background"
)}>
{isBodyLoading ? (
<div
className="space-y-3 px-6 py-4 animate-pulse"
style={{ minHeight: `${lastBodyHeightRef.current}px` }}
>
<div className="h-2 bg-muted/15 rounded w-full"></div>
<div className="h-2 bg-muted/15 rounded w-5/6"></div>
<div className="h-2 bg-muted/15 rounded w-4/6"></div>
<div className="h-2 bg-muted/15 rounded w-full"></div>
<div className="h-2 bg-muted/15 rounded w-3/4"></div>
</div>
) : effectiveEmailContent.isHtml ? (
<iframe <iframe
ref={iframeRef} ref={iframeRef}
srcDoc={emailIframeSrcDoc} srcDoc={emailIframeSrcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title="Email content" title="Email content"
className="w-full border-0 rounded" className="w-full border-0"
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }} style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
onLoad={handleIframeLoad} onLoad={handleIframeLoad}
/> />
@@ -4662,8 +4758,8 @@ export function EmailViewer({
<PluginSlot name="email-footer" /> <PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts */} {/* Quick Reply Section - hidden for drafts and while loading a new email */}
{!isDraft && (<div className={cn( {!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn(
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all", "mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border" isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
)}> )}>
+164 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useEffect, useCallback } from "react"; import React, { useEffect, useCallback, useState, useRef } from "react";
import { useEditor, EditorContent } from "@tiptap/react"; import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline"; import Underline from "@tiptap/extension-underline";
@@ -10,6 +10,10 @@ import { TextStyle } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color"; import Color from "@tiptap/extension-color";
import { ResizableImage } from "@/components/email/resizable-image"; import { ResizableImage } from "@/components/email/resizable-image";
import Placeholder from "@tiptap/extension-placeholder"; import Placeholder from "@tiptap/extension-placeholder";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Bold, Bold,
@@ -29,6 +33,10 @@ import {
RemoveFormatting, RemoveFormatting,
Heading1, Heading1,
Heading2, Heading2,
Table as TableIcon,
Trash2,
Rows3,
Columns3,
} from "lucide-react"; } from "lucide-react";
export interface InlineImageUpload { export interface InlineImageUpload {
@@ -79,6 +87,43 @@ function ToolbarSeparator() {
return <div className="w-px h-5 bg-border mx-0.5" />; return <div className="w-px h-5 bg-border mx-0.5" />;
} }
const TABLE_PICKER_ROWS = 6;
const TABLE_PICKER_COLS = 8;
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
return (
<div>
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `repeat(${TABLE_PICKER_COLS}, 1fr)` }}
onMouseLeave={() => setHover(null)}
>
{Array.from({ length: TABLE_PICKER_ROWS * TABLE_PICKER_COLS }).map((_, i) => {
const r = Math.floor(i / TABLE_PICKER_COLS);
const c = i % TABLE_PICKER_COLS;
const active = hover && r <= hover.r && c <= hover.c;
return (
<button
key={i}
type="button"
onMouseEnter={() => setHover({ r, c })}
onClick={() => onPick(r + 1, c + 1)}
className={cn(
"w-4 h-4 border border-border/60 rounded-[2px] transition-colors",
active ? "bg-primary border-primary" : "bg-background hover:bg-accent"
)}
/>
);
})}
</div>
<div className="text-xs text-muted-foreground mt-1.5 text-center">
{hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"}
</div>
</div>
);
}
export function RichTextEditor({ export function RichTextEditor({
content, content,
onChange, onChange,
@@ -109,6 +154,27 @@ export function RichTextEditor({
Placeholder.configure({ Placeholder.configure({
placeholder, placeholder,
}), }),
Table.configure({
resizable: true,
HTMLAttributes: {
border: "1",
cellpadding: "6",
cellspacing: "0",
width: "100%",
style: "width:100%;border-collapse:collapse;",
},
}),
TableRow,
TableHeader.configure({
HTMLAttributes: {
style: "padding:6px 8px;border:1px solid #ccc;background-color:#f5f5f5;color:#1f2937;text-align:left;",
},
}),
TableCell.configure({
HTMLAttributes: {
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
},
}),
], ],
content, content,
editorProps: { editorProps: {
@@ -188,6 +254,20 @@ export function RichTextEditor({
.run(); .run();
}, [editor]); }, [editor]);
const [tableMenuOpen, setTableMenuOpen] = useState(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!tableMenuOpen) return;
const handler = (e: MouseEvent) => {
if (tableWrapperRef.current && !tableWrapperRef.current.contains(e.target as Node)) {
setTableMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [tableMenuOpen]);
if (!editor) { if (!editor) {
return ( return (
<div className={cn("min-h-[100px]", className)} /> <div className={cn("min-h-[100px]", className)} />
@@ -309,6 +389,89 @@ export function RichTextEditor({
<LinkIcon className="w-4 h-4" /> <LinkIcon className="w-4 h-4" />
</ToolbarButton> </ToolbarButton>
<div ref={tableWrapperRef} className="relative">
<ToolbarButton
active={editor.isActive("table")}
onClick={() => setTableMenuOpen((v) => !v)}
title="Table"
>
<TableIcon className="w-4 h-4" />
</ToolbarButton>
{tableMenuOpen && (
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
{editor.isActive("table") ? (
<div className="flex flex-col gap-0.5">
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Add row above
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Add row below
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
>
<Columns3 className="w-4 h-4" /> Add column before
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
>
<Columns3 className="w-4 h-4" /> Add column after
</button>
<div className="h-px bg-border my-1" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete row
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete column
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Toggle header row
</button>
<div className="h-px bg-border my-1" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left text-red-600 dark:text-red-400"
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete table
</button>
</div>
) : (
<TableSizePicker
onPick={(rows, cols) => {
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
setTableMenuOpen(false);
}}
/>
)}
</div>
)}
</div>
<ToolbarSeparator /> <ToolbarSeparator />
<ToolbarButton <ToolbarButton
+4 -2
View File
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useIdentityStore } from '@/stores/identity-store'; import { useIdentityStore } from '@/stores/identity-store';
import { useSettingsStore } from '@/stores/settings-store';
import { import {
generateSubAddress, generateSubAddress,
extractDomain, extractDomain,
@@ -35,6 +36,7 @@ export function SubAddressHelper({
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore(); const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore();
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
// Get suggestions based on recipient (memoized for performance) // Get suggestions based on recipient (memoized for performance)
const suggestions = useMemo(() => { const suggestions = useMemo(() => {
@@ -47,7 +49,7 @@ export function SubAddressHelper({
}, [recipientEmails]); }, [recipientEmails]);
// Generate preview // Generate preview
const preview = tag ? generateSubAddress(baseEmail, tag) : baseEmail; const preview = tag ? generateSubAddress(baseEmail, tag, subAddressDelimiter) : baseEmail;
// Close popover when clicking outside // Close popover when clicking outside
useEffect(() => { useEffect(() => {
@@ -226,7 +228,7 @@ export function SubAddressHelper({
{/* Help Text */} {/* Help Text */}
<div className="mb-3 text-xs text-muted-foreground"> <div className="mb-3 text-xs text-muted-foreground">
{t('help_text')} {t('help_text', { delimiter: subAddressDelimiter })}
</div> </div>
{/* Use Address Button */} {/* Use Address Button */}
+1 -1
View File
@@ -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 && ( {isStalwartAdmin && (
<a <a
href="/admin" href="/admin"
+3 -1
View File
@@ -16,12 +16,13 @@ import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json'; import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json'; import ptMessages from '@/locales/pt/common.json';
import ruMessages from '@/locales/ru/common.json'; import ruMessages from '@/locales/ru/common.json';
import trMessages from '@/locales/tr/common.json';
import ukMessages from '@/locales/uk/common.json'; import ukMessages from '@/locales/uk/common.json';
import zhMessages from '@/locales/zh/common.json'; import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime) // Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = { const ALL_MESSAGES = {
cs: csMessages, cs: csMessages,
en: enMessages, en: enMessages,
fr: frMessages, fr: frMessages,
ja: jaMessages, ja: jaMessages,
@@ -34,6 +35,7 @@ const ALL_MESSAGES = {
pl: plMessages, pl: plMessages,
pt: ptMessages, pt: ptMessages,
ru: ruMessages, ru: ruMessages,
tr: trMessages,
uk: ukMessages, uk: ukMessages,
zh: zhMessages, zh: zhMessages,
}; };
+53 -1
View File
@@ -4,8 +4,16 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { Mail, X } from 'lucide-react'; import { Mail, X } from 'lucide-react';
import {
SUPPORTED_SUB_ADDRESS_DELIMITERS,
isSupportedSubAddressDelimiter,
isValidSubAddressDelimiter,
} from '@/lib/sub-addressing';
const CUSTOM_DELIMITER_SENTINEL = '__custom__';
const DEFAULT_CUSTOM_DELIMITER = '~';
export function ComposingSettings() { export function ComposingSettings() {
const t = useTranslations('settings.email_behavior'); const t = useTranslations('settings.email_behavior');
@@ -17,6 +25,7 @@ export function ComposingSettings() {
autoSelectReplyIdentity, autoSelectReplyIdentity,
attachmentReminderEnabled, attachmentReminderEnabled,
attachmentReminderKeywords, attachmentReminderKeywords,
subAddressDelimiter,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
@@ -40,6 +49,49 @@ export function ComposingSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem
label={t('sub_address_delimiter.label')}
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
>
<div className="flex flex-col items-end gap-2">
<Select
value={isSupportedSubAddressDelimiter(subAddressDelimiter) ? subAddressDelimiter : CUSTOM_DELIMITER_SENTINEL}
onChange={(value) => {
if (value === CUSTOM_DELIMITER_SENTINEL) {
if (isSupportedSubAddressDelimiter(subAddressDelimiter)) {
updateSetting('subAddressDelimiter', DEFAULT_CUSTOM_DELIMITER);
}
} else {
updateSetting('subAddressDelimiter', value);
}
}}
options={[
...SUPPORTED_SUB_ADDRESS_DELIMITERS.map((delim) => ({
value: delim,
label: t('sub_address_delimiter.option', { delimiter: delim }),
})),
{ value: CUSTOM_DELIMITER_SENTINEL, label: t('sub_address_delimiter.custom') },
]}
/>
{!isSupportedSubAddressDelimiter(subAddressDelimiter) && (
<input
type="text"
maxLength={1}
value={subAddressDelimiter}
onChange={(e) => {
const next = e.target.value.slice(0, 1);
if (next && isValidSubAddressDelimiter(next)) {
updateSetting('subAddressDelimiter', next);
}
}}
aria-label={t('sub_address_delimiter.custom_input_label')}
placeholder={DEFAULT_CUSTOM_DELIMITER}
className="w-16 px-2 py-1 text-sm font-mono text-center bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
)}
</div>
</SettingItem>
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}> <SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch <ToggleSwitch
checked={attachmentReminderEnabled} checked={attachmentReminderEnabled}
+170 -1
View File
@@ -1,13 +1,32 @@
"use client"; "use client";
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section'; import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound'; import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound'; import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Volume2 } from 'lucide-react'; import { CheckCircle2, Volume2, XCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import {
DEFAULT_RELAY_BASE_URL,
WebPushUnsupportedError,
disableWebPush,
enableWebPush,
isWebPushEnabled,
isWebPushSupported,
} from '@/lib/web-push';
type PushStatus =
| { kind: 'idle' }
| { kind: 'busy' }
| { kind: 'enabled' }
| { kind: 'unsupported' }
| { kind: 'error'; message: string };
export function NotificationSettings() { export function NotificationSettings() {
const t = useTranslations('settings.notifications'); const t = useTranslations('settings.notifications');
@@ -21,6 +40,77 @@ export function NotificationSettings() {
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
const client = useAuthStore((s) => s.client);
const username = useAuthStore((s) => s.username);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const supported = typeof window !== 'undefined' && isWebPushSupported();
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
const [pushStatus, setPushStatus] = useState<PushStatus>(
supported ? { kind: 'idle' } : { kind: 'unsupported' },
);
useEffect(() => {
if (!supported) return;
void (async () => {
const enabled = await isWebPushEnabled();
if (enabled) setPushStatus({ kind: 'enabled' });
})();
}, [supported]);
const trimmedRelay = relayUrl.trim().replace(/\/+$/, '');
const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay);
const busy = pushStatus.kind === 'busy';
const handleEnablePush = async () => {
if (!client) {
setPushStatus({ kind: 'error', message: 'Sign in first' });
return;
}
if (!isValidRelay) {
setPushStatus({ kind: 'error', message: 'Enter a valid https:// URL' });
return;
}
setPushStatus({ kind: 'busy' });
try {
await enableWebPush({
client,
relayBaseUrl: trimmedRelay,
accountLabel: username ?? undefined,
});
setPushStatus({ kind: 'enabled' });
} catch (err) {
if (err instanceof WebPushUnsupportedError) {
setPushStatus({ kind: 'unsupported' });
return;
}
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to enable push',
});
}
};
const handleDisablePush = async () => {
if (!client) return;
const confirmed = await confirmDialog({
title: t('push.confirm_disable_title'),
message: t('push.confirm_disable_message'),
confirmText: t('push.disable'),
variant: 'destructive',
});
if (!confirmed) return;
setPushStatus({ kind: 'busy' });
try {
await disableWebPush({ client, relayBaseUrl: trimmedRelay });
setPushStatus({ kind: 'idle' });
} catch (err) {
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to disable push',
});
}
};
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({ const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
value: s.id, value: s.id,
@@ -29,6 +119,46 @@ export function NotificationSettings() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SettingsSection title={t('push.title')} description={t('push.description')}>
<div className="rounded-md border p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium" htmlFor="push-relay-url">
{t('push.relay_label')}
</label>
<PushStatusBadge status={pushStatus} t={t} />
</div>
<p className="text-xs text-muted-foreground">{t('push.relay_desc')}</p>
<input
id="push-relay-url"
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
placeholder={t('push.relay_placeholder')}
disabled={busy || pushStatus.kind === 'unsupported'}
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleEnablePush}
disabled={busy || pushStatus.kind === 'unsupported' || !isValidRelay || !client}
>
{pushStatus.kind === 'enabled' ? t('push.reenable') : t('push.enable')}
</Button>
{pushStatus.kind === 'enabled' && (
<Button variant="outline" onClick={handleDisablePush} disabled={busy}>
{t('push.disable')}
</Button>
)}
</div>
{pushStatus.kind === 'unsupported' && (
<p className="text-xs text-muted-foreground">{t('push.ios_hint')}</p>
)}
</div>
</SettingsSection>
<SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}> <SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}>
<SettingItem <SettingItem
label={t('sound_selection.choose')} label={t('sound_selection.choose')}
@@ -118,6 +248,45 @@ export function NotificationSettings() {
/> />
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
<ConfirmDialog {...confirmDialogProps} />
</div> </div>
); );
} }
function PushStatusBadge({
status,
t,
}: {
status: PushStatus;
t: ReturnType<typeof useTranslations>;
}) {
if (status.kind === 'enabled') {
return (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{t('push.status_active')}
</span>
);
}
if (status.kind === 'busy') {
return <span className="text-xs text-muted-foreground">{t('push.status_busy')}</span>;
}
if (status.kind === 'unsupported') {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<XCircle className="w-3.5 h-3.5" />
{t('push.status_unsupported')}
</span>
);
}
if (status.kind === 'error') {
return (
<span className="inline-flex items-center gap-1 text-xs text-destructive" title={status.message}>
<XCircle className="w-3.5 h-3.5" />
{status.message}
</span>
);
}
return <span className="text-xs text-muted-foreground">{t('push.status_inactive')}</span>;
}
+13
View File
@@ -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 */ /** Ukraine Blue, Yellow horizontal */
export function FlagUA(props: FlagProps) { export function FlagUA(props: FlagProps) {
return ( return (
@@ -192,6 +204,7 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
pl: FlagPL, pl: FlagPL,
pt: FlagBR, pt: FlagBR,
ru: FlagRU, ru: FlagRU,
tr: FlagTR,
uk: FlagUA, uk: FlagUA,
zh: FlagCN, zh: FlagCN,
}; };
+2 -1
View File
@@ -8,7 +8,7 @@ import { cn } from '@/lib/utils';
import { flagComponents } from './flag-icons'; import { flagComponents } from './flag-icons';
const languages = [ const languages = [
{ value: 'cs', label: 'Česky' }, { value: 'cs', label: 'Česky' },
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' }, { value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' }, { value: 'ja', label: '日本語' },
@@ -21,6 +21,7 @@ const languages = [
{ value: 'pl', label: 'Polski' }, { value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' }, { value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' }, { value: 'ru', label: 'Русский' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'uk', label: 'Українська' }, { value: 'uk', label: 'Українська' },
{ value: 'zh', label: '简体中文' }, { value: 'zh', label: '简体中文' },
]; ];
+4
View File
@@ -13,6 +13,9 @@ services:
- bulwark-settings:/app/data/settings - bulwark-settings:/app/data/settings
# Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR). # Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR).
- bulwark-admin:/app/data/admin - bulwark-admin:/app/data/admin
# Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR).
# Persisting this preserves the admin's consent choice and stable instance id across upgrades.
- bulwark-telemetry:/app/data/telemetry
healthcheck: healthcheck:
test: test:
[ [
@@ -32,3 +35,4 @@ services:
volumes: volumes:
bulwark-settings: bulwark-settings:
bulwark-admin: bulwark-admin:
bulwark-telemetry:
+27
View File
@@ -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]
);
}
+10 -6
View File
@@ -39,6 +39,7 @@ export interface UseKeyboardShortcutsOptions {
enabled?: boolean; enabled?: boolean;
emails: Email[]; emails: Email[];
selectedEmailId?: string; selectedEmailId?: string;
selectionCount?: number;
handlers: KeyboardShortcutHandlers; handlers: KeyboardShortcutHandlers;
} }
@@ -58,6 +59,7 @@ export function useKeyboardShortcuts({
enabled = true, enabled = true,
emails, emails,
selectedEmailId, selectedEmailId,
selectionCount = 0,
handlers, handlers,
}: UseKeyboardShortcutsOptions) { }: UseKeyboardShortcutsOptions) {
const handlersRef = useRef(handlers); const handlersRef = useRef(handlers);
@@ -90,6 +92,8 @@ export function useKeyboardShortcuts({
// Shortcuts that should NOT work with modifiers // Shortcuts that should NOT work with modifiers
if (hasModifier) return; if (hasModifier) return;
const hasBatchTarget = !!selectedEmailId || selectionCount > 0;
switch (key) { switch (key) {
// Navigation // Navigation
case "j": case "j":
@@ -152,7 +156,7 @@ export function useKeyboardShortcuts({
break; break;
case "e": case "e":
if (selectedEmailId) { if (hasBatchTarget) {
event.preventDefault(); event.preventDefault();
h.onArchive?.(); h.onArchive?.();
} }
@@ -161,28 +165,28 @@ export function useKeyboardShortcuts({
case "#": case "#":
case "delete": case "delete":
case "backspace": case "backspace":
if (selectedEmailId && (key === "#" || key === "delete" || key === "backspace")) { if (hasBatchTarget) {
event.preventDefault(); event.preventDefault();
h.onDelete?.(); h.onDelete?.();
} }
break; break;
case "u": case "u":
if (selectedEmailId) { if (hasBatchTarget) {
event.preventDefault(); event.preventDefault();
h.onMarkAsUnread?.(); h.onMarkAsUnread?.();
} }
break; break;
case "i": case "i":
if (selectedEmailId && event.shiftKey) { if (hasBatchTarget && event.shiftKey) {
event.preventDefault(); event.preventDefault();
h.onMarkAsRead?.(); h.onMarkAsRead?.();
} }
break; break;
case "!": case "!":
if (selectedEmailId) { if (hasBatchTarget) {
event.preventDefault(); event.preventDefault();
h.onToggleSpam?.(); h.onToggleSpam?.();
} }
@@ -220,7 +224,7 @@ export function useKeyboardShortcuts({
break; break;
} }
}, },
[selectedEmailId] [selectedEmailId, selectionCount]
); );
useEffect(() => { useEffect(() => {
+3
View File
@@ -47,6 +47,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'ru': case 'ru':
messages = (await import('../locales/ru/common.json')).default; messages = (await import('../locales/ru/common.json')).default;
break; break;
case 'tr':
messages = (await import('../locales/tr/common.json')).default;
break;
case 'uk': case 'uk':
messages = (await import('../locales/uk/common.json')).default; messages = (await import('../locales/uk/common.json')).default;
break; break;
+1 -1
View File
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'as-needed'; | 'as-needed';
export const routing = defineRouting({ export const routing = defineRouting({
locales: ['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', defaultLocale: 'en',
localePrefix localePrefix
}); });
+8
View File
@@ -51,6 +51,14 @@ configManager.load()
.then(() => { .then(() => {
console.info("Admin dashboard initialized"); console.info("Admin dashboard initialized");
}) })
.then(async () => {
// Anonymous telemetry - on by default. Admins can disable via the
// admin UI, the BULWARK_TELEMETRY env var, or by clearing the endpoint.
// See https://bulwarkmail.org/docs/legal/privacy/telemetry
const { startScheduler, markProcessStart } = await import("./lib/telemetry");
markProcessStart();
await startScheduler();
})
.catch((err) => { .catch((err) => {
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err); console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
}); });
+86
View File
@@ -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();
});
});
+151
View File
@@ -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();
});
});
+126 -15
View File
@@ -28,23 +28,11 @@ describe('extractTheme', () => {
}); });
it('rejects oversized theme', async () => { it('rejects oversized theme', async () => {
const zip = new JSZip(); // Theme size limit is 2 MB; create a file just past it.
zip.file('manifest.json', JSON.stringify({ const oversizedFile = new File([new ArrayBuffer(2 * 1024 * 1024 + 1)], 'big.zip');
id: 'big-theme',
name: 'Big',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
}));
// Make a large file > 1MB
zip.file('theme.css', 'x'.repeat(1024 * 1024 + 1));
// Manually create oversized File
const oversizedFile = new File([new ArrayBuffer(1024 * 1024 + 1)], 'big.zip');
const result = await extractTheme(oversizedFile); const result = await extractTheme(oversizedFile);
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.errors).toContain('Theme ZIP exceeds 1 MB size limit'); expect(result.errors).toContain('Theme ZIP exceeds 2 MB size limit');
}); });
it('rejects non-ZIP file', async () => { it('rejects non-ZIP file', async () => {
@@ -139,6 +127,129 @@ describe('extractTheme', () => {
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
expect(result.manifest!.id).toBe('nested-theme'); expect(result.manifest!.id).toBe('nested-theme');
}); });
// ── Theme API v2 (advanced manifest) ──────────────────────────────
it('compiles a v2 manifest with tokens and no theme.css', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'tokens-only',
name: 'Tokens Only',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light', 'dark'],
apiVersion: 2,
tokens: {
light: { primary: '#1373d9', background: '#ffffff' },
dark: { primary: '#58c9ff', background: '#1a202c' },
},
}));
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.css).toContain('--color-primary: #1373d9');
expect(result.css).toContain('--color-primary: #58c9ff');
});
it('concatenates compiled tokens with author-supplied theme.css', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'tokens-plus-css',
name: 'Tokens + CSS',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
apiVersion: 2,
tokens: { light: { primary: '#000' } },
}));
zip.file('theme.css', '@font-face { font-family: "X"; src: local("X"); }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.css).toContain('--color-primary: #000');
expect(result.css).toContain('@font-face');
});
it('extracts a skin.css when shipped with a v2 manifest', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'with-skin',
name: 'With Skin',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
apiVersion: 2,
tokens: { light: { primary: '#000' } },
}));
zip.file('skin.css', '[data-tour="email-list"] { font-size: 13px; }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.skin).not.toBeNull();
expect(result.skin!).toContain('[data-tour="email-list"]');
});
it('strips dangerous patterns from skin.css', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'evil-skin',
name: 'Evil',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
apiVersion: 2,
tokens: { light: { primary: '#000' } },
}));
zip.file('skin.css', '@import url("https://x.com/p.css"); button { background: javascript:alert(1); }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.skin).not.toBeNull();
expect(result.skin!).not.toContain('javascript:');
expect(result.skin!).not.toContain('@import');
expect(result.warnings.some((w) => w.toLowerCase().includes('skin'))).toBe(true);
});
it('ignores skin.css when manifest is not v2', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'v1-with-skin',
name: 'V1',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
}));
zip.file('theme.css', ':root { --color-primary: #000; }');
zip.file('skin.css', 'body { display: none; }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.skin).toBeNull();
expect(result.warnings.some((w) => w.includes('skin.css ignored'))).toBe(true);
});
it('rejects a v2 manifest with invalid density', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'bad-density',
name: 'Bad',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
density: 'gigantic',
tokens: { light: { primary: '#000' } },
}));
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('density'))).toBe(true);
});
}); });
describe('extractPlugin', () => { describe('extractPlugin', () => {
+123
View File
@@ -6,6 +6,10 @@ import {
suggestTagsForDomain, suggestTagsForDomain,
isValidTag, isValidTag,
getTagValidationError, getTagValidationError,
isSupportedSubAddressDelimiter,
isValidSubAddressDelimiter,
SUPPORTED_SUB_ADDRESS_DELIMITERS,
DEFAULT_SUB_ADDRESS_DELIMITER,
MAX_TAG_LENGTH, MAX_TAG_LENGTH,
} from '../sub-addressing'; } from '../sub-addressing';
@@ -354,3 +358,122 @@ describe('getTagValidationError', () => {
expect(getTagValidationError('日本語')).toBe('INVALID_CHARS'); expect(getTagValidationError('日本語')).toBe('INVALID_CHARS');
}); });
}); });
describe('custom delimiter', () => {
describe('parseSubAddress with non-default delimiter', () => {
it('should parse with "-" delimiter', () => {
const result = parseSubAddress('user-shopping@example.com', '-');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('shopping');
});
it('should parse with "." delimiter', () => {
const result = parseSubAddress('user.shopping@example.com', '.');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('shopping');
});
it('should parse with "=" delimiter', () => {
const result = parseSubAddress('user=shopping@example.com', '=');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('shopping');
});
it('should ignore "+" when "-" is configured as the delimiter', () => {
const result = parseSubAddress('user+shopping@example.com', '-');
expect(result.baseUser).toBe('user+shopping');
expect(result.tag).toBeNull();
});
it('should split on first occurrence when delimiter appears multiple times', () => {
const result = parseSubAddress('alice-shop-orders@example.com', '-');
expect(result.baseUser).toBe('alice');
expect(result.tag).toBe('shop-orders');
});
});
describe('generateSubAddress with non-default delimiter', () => {
it('should generate using "-" delimiter', () => {
expect(generateSubAddress('user@example.com', 'shopping', '-')).toBe('user-shopping@example.com');
});
it('should generate using "." delimiter', () => {
expect(generateSubAddress('user@example.com', 'shopping', '.')).toBe('user.shopping@example.com');
});
it('should replace existing tag using the configured delimiter', () => {
expect(generateSubAddress('user-old@example.com', 'new', '-')).toBe('user-new@example.com');
});
it('should not strip a "+" sign in the local part when delimiter is "-"', () => {
// "+" is not the delimiter so it should remain part of the base user
expect(generateSubAddress('user+plus@example.com', 'tag', '-')).toBe('user+plus-tag@example.com');
});
});
describe('isSupportedSubAddressDelimiter', () => {
it('accepts every supported delimiter', () => {
for (const delim of SUPPORTED_SUB_ADDRESS_DELIMITERS) {
expect(isSupportedSubAddressDelimiter(delim)).toBe(true);
}
});
it('rejects unsupported characters', () => {
expect(isSupportedSubAddressDelimiter('_')).toBe(false);
expect(isSupportedSubAddressDelimiter('++')).toBe(false);
expect(isSupportedSubAddressDelimiter('')).toBe(false);
});
it('default delimiter is supported', () => {
expect(isSupportedSubAddressDelimiter(DEFAULT_SUB_ADDRESS_DELIMITER)).toBe(true);
});
});
describe('isValidSubAddressDelimiter', () => {
it('accepts every preset delimiter', () => {
for (const delim of SUPPORTED_SUB_ADDRESS_DELIMITERS) {
expect(isValidSubAddressDelimiter(delim)).toBe(true);
}
});
it('accepts atext special characters as custom delimiters', () => {
const customs = ['~', '!', '#', '$', '%', '&', "'", '*', '/', '?', '^', '_', '`', '{', '|', '}'];
for (const c of customs) {
expect(isValidSubAddressDelimiter(c)).toBe(true);
}
});
it('rejects alphanumeric characters', () => {
expect(isValidSubAddressDelimiter('a')).toBe(false);
expect(isValidSubAddressDelimiter('Z')).toBe(false);
expect(isValidSubAddressDelimiter('0')).toBe(false);
});
it('rejects "@", whitespace, and quotes', () => {
expect(isValidSubAddressDelimiter('@')).toBe(false);
expect(isValidSubAddressDelimiter(' ')).toBe(false);
expect(isValidSubAddressDelimiter('\t')).toBe(false);
expect(isValidSubAddressDelimiter('"')).toBe(false);
});
it('rejects multi-character strings', () => {
expect(isValidSubAddressDelimiter('++')).toBe(false);
expect(isValidSubAddressDelimiter('abc')).toBe(false);
});
it('rejects empty / non-string inputs', () => {
expect(isValidSubAddressDelimiter('')).toBe(false);
expect(isValidSubAddressDelimiter(null)).toBe(false);
expect(isValidSubAddressDelimiter(undefined)).toBe(false);
expect(isValidSubAddressDelimiter(1)).toBe(false);
});
it('round-trips through parse/generate with a custom "~" delimiter', () => {
const generated = generateSubAddress('user@example.com', 'shopping', '~');
expect(generated).toBe('user~shopping@example.com');
const parsed = parseSubAddress(generated, '~');
expect(parsed.baseUser).toBe('user');
expect(parsed.tag).toBe('shopping');
});
});
});
+181
View File
@@ -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);
});
});
+61
View File
@@ -1,9 +1,12 @@
import { describe, it, expect, afterEach } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { import {
sanitizeThemeCSS, sanitizeThemeCSS,
sanitizeSkinCSS,
validateThemeSelectors, validateThemeSelectors,
injectThemeCSS, injectThemeCSS,
removeThemeCSS, removeThemeCSS,
injectThemeSkinCSS,
removeThemeSkinCSS,
validateThemeCSSSafety, validateThemeCSSSafety,
} from '../theme-loader'; } from '../theme-loader';
@@ -144,6 +147,64 @@ describe('theme-loader', () => {
}); });
}); });
describe('injectThemeSkinCSS / removeThemeSkinCSS', () => {
afterEach(() => {
removeThemeSkinCSS();
});
it('injects a separate <style> tag from the colour block', () => {
injectThemeCSS(':root { --color-primary: red; }');
injectThemeSkinCSS('button { padding: 4px; }', 'thunderbird');
expect(document.getElementById('active-theme')).not.toBeNull();
expect(document.getElementById('active-theme-skin')).not.toBeNull();
expect(document.getElementById('active-theme-skin')?.textContent).toContain('button');
});
it('sets data-theme-skin on body to the active theme id', () => {
injectThemeSkinCSS('button { padding: 4px; }', 'my-theme');
expect(document.body.getAttribute('data-theme-skin')).toBe('my-theme');
});
it('removes the skin tag and body attribute on remove', () => {
injectThemeSkinCSS('button { padding: 4px; }', 'my-theme');
removeThemeSkinCSS();
expect(document.getElementById('active-theme-skin')).toBeNull();
expect(document.body.getAttribute('data-theme-skin')).toBeNull();
});
it('does not throw when removing without a prior inject', () => {
expect(() => removeThemeSkinCSS()).not.toThrow();
});
});
describe('sanitizeSkinCSS', () => {
it('preserves component-level selectors', () => {
const css = '[data-tour="email-list"] { font-size: 13px; } button { padding: 4px; }';
const { css: cleaned, warnings } = sanitizeSkinCSS(css);
expect(cleaned).toBe(css);
expect(warnings).toHaveLength(0);
});
it('strips dangerous patterns', () => {
const { css: cleaned, warnings } = sanitizeSkinCSS(
'@import url("https://x.com/p.css"); button { background: javascript:alert(1); }',
);
expect(cleaned).not.toContain('@import');
expect(cleaned).not.toContain('javascript:');
expect(warnings.length).toBeGreaterThanOrEqual(2);
});
it('strips @charset and @namespace', () => {
const { css: cleaned, warnings } = sanitizeSkinCSS(
'@charset "utf-8"; @namespace url(http://www.w3.org/1999/xhtml); button { padding: 4px; }',
);
expect(cleaned).not.toContain('@charset');
expect(cleaned).not.toContain('@namespace');
expect(cleaned).toContain('button');
expect(warnings.length).toBeGreaterThanOrEqual(2);
});
});
describe('validateThemeCSSSafety', () => { describe('validateThemeCSSSafety', () => {
it('accepts valid theme CSS', () => { it('accepts valid theme CSS', () => {
const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }'; const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }';
+34
View File
@@ -138,4 +138,38 @@ describe('verifyJmapAuth SSRF protection', () => {
}); });
expect(fetchSpy).not.toHaveBeenCalled(); 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();
});
}); });
+8 -2
View File
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env'; 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 { function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
@@ -35,6 +35,7 @@ class ConfigManager {
this.policyCache = { this.policyCache = {
...DEFAULT_POLICY, ...DEFAULT_POLICY,
...policy, ...policy,
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) }, themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
}; };
} else { } else {
@@ -143,7 +144,12 @@ class ConfigManager {
* Update the settings policy. Writes to disk. * Update the settings policy. Writes to disk.
*/ */
async setPolicy(policy: SettingsPolicy): Promise<void> { async setPolicy(policy: SettingsPolicy): Promise<void> {
this.policyCache = { ...DEFAULT_POLICY, ...policy }; this.policyCache = {
...DEFAULT_POLICY,
...policy,
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
};
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>); await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
} }
+2 -2
View File
@@ -5,7 +5,7 @@
* domains in the host CSP. * domains in the host CSP.
* *
* Origins are validated at install time and re-validated here as defense in * 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. * inject arbitrary CSP fragments.
*/ */
@@ -65,7 +65,7 @@ const CACHE_TTL_MS = 5_000;
* the server-side registry, deduped and validated. * the server-side registry, deduped and validated.
* *
* Returns an empty array on any failure (missing file, parse error, ) so * 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[]> { export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
const now = Date.now(); const now = Date.now();
+2
View File
@@ -39,6 +39,7 @@ export interface FeatureGates {
folderIconsEnabled: boolean; folderIconsEnabled: boolean;
hoverActionsConfigEnabled: boolean; hoverActionsConfigEnabled: boolean;
filesEnabled: boolean; filesEnabled: boolean;
contactsEnabled: boolean;
} }
export const DEFAULT_FEATURE_GATES: FeatureGates = { export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -58,6 +59,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
folderIconsEnabled: true, folderIconsEnabled: true,
hoverActionsConfigEnabled: true, hoverActionsConfigEnabled: true,
filesEnabled: true, filesEnabled: true,
contactsEnabled: true,
}; };
export interface ThemePolicy { export interface ThemePolicy {
+7 -3
View File
@@ -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); const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
validateProxyAuthHeader(authHeader); validateProxyAuthHeader(authHeader);
if (!(await isPublicHttpUrl(normalizedServerUrl))) { if (!options.trusted && !(await isPublicHttpUrl(normalizedServerUrl))) {
throw new JmapAuthVerificationError('Server URL is not allowed', 400); 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; let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) { 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); throw new JmapAuthVerificationError('Server URL is not allowed', 400);
} }
+24 -3
View File
@@ -95,6 +95,18 @@ export class DemoJMAPClient implements IJMAPClient {
getLastStates(): AccountStates { return { ...this.lastStates }; } getLastStates(): AccountStates { return { ...this.lastStates }; }
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; } setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
// PushSubscription endpoints have no meaning in demo mode - the demo client
// never makes real network calls so there's nothing for the relay to push to.
async listPushSubscriptions() { return []; }
async createPushSubscription(): Promise<string> {
throw new Error('Push subscriptions are not available in demo mode');
}
async verifyPushSubscription(): Promise<void> {
throw new Error('Push subscriptions are not available in demo mode');
}
async updatePushSubscription(): Promise<boolean> { return false; }
async destroyPushSubscription(): Promise<void> { /* no-op */ }
// ── Quota ───────────────────────────────────────────────────── // ── Quota ─────────────────────────────────────────────────────
async getQuota(): Promise<{ used: number; total: number } | null> { async getQuota(): Promise<{ used: number; total: number } | null> {
@@ -383,6 +395,7 @@ export class DemoJMAPClient implements IJMAPClient {
draftId?: string, draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
_fromName?: string, _fromName?: string,
htmlBody?: string,
): Promise<string> { ): Promise<string> {
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts'); const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
const id = draftId || generateDemoId('email'); const id = draftId || generateDemoId('email');
@@ -402,9 +415,13 @@ export class DemoJMAPClient implements IJMAPClient {
sentAt: new Date().toISOString(), sentAt: new Date().toISOString(),
preview: body.substring(0, 200), preview: body.substring(0, 200),
hasAttachment: !!attachments?.length, hasAttachment: !!attachments?.length,
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }], textBody: [{ partId: htmlBody ? 'text' : '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
htmlBody: [], htmlBody: htmlBody
bodyValues: { '1': { value: body } }, ? [{ partId: 'html', blobId: generateDemoId('blob'), size: htmlBody.length, type: 'text/html' }]
: [],
bodyValues: htmlBody
? { text: { value: body }, html: { value: htmlBody } }
: { '1': { value: body } },
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })), attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
messageId: `<${id}@demo.example.com>`, messageId: `<${id}@demo.example.com>`,
}; };
@@ -430,6 +447,8 @@ export class DemoJMAPClient implements IJMAPClient {
_fromName?: string, _fromName?: string,
htmlBody?: string, htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
): Promise<void> { ): Promise<void> {
// Remove draft if updating // Remove draft if updating
if (draftId) { if (draftId) {
@@ -455,6 +474,8 @@ export class DemoJMAPClient implements IJMAPClient {
bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } }, bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } },
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })), attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
messageId: `<${generateDemoId('msg')}@demo.example.com>`, messageId: `<${generateDemoId('msg')}@demo.example.com>`,
inReplyTo: inReplyTo?.length ? inReplyTo : undefined,
references: references?.length ? references : undefined,
}; };
this.data.emails.push(email); this.data.emails.push(email);
this.recalcMailboxCounts(); this.recalcMailboxCounts();
+1
View File
@@ -151,6 +151,7 @@ export function collapseBlockedImageContainers(html: string): string {
const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas'); const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
const hasLinks = el.querySelector('a[href]'); const hasLinks = el.querySelector('a[href]');
if (!hasVisibleText && !hasVisibleMedia && !hasLinks) { if (!hasVisibleText && !hasVisibleMedia && !hasLinks) {
el.setAttribute('data-blocked-collapsed-style', el.style.cssText);
el.style.display = 'none'; el.style.display = 'none';
el.style.height = '0'; el.style.height = '0';
el.style.padding = '0'; el.style.padding = '0';
+51
View File
@@ -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 };
}
+18 -1
View File
@@ -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"; import type { SieveScript, SieveCapabilities } from "./sieve-types";
/** /**
@@ -51,6 +51,20 @@ export interface IJMAPClient {
getLastStates(): AccountStates; getLastStates(): AccountStates;
setLastStates(states: AccountStates): void; setLastStates(states: AccountStates): void;
// ── PushSubscription (RFC 8620 §7.2) ───────────────────────────
// Browser-driven Web Push setup: register a relay URL the JMAP server can
// forward StateChange events to. Mobile uses the same primitives.
listPushSubscriptions(): Promise<PushSubscription[]>;
createPushSubscription(params: {
deviceClientId: string;
url: string;
types: string[];
expires?: string;
}): Promise<string>;
verifyPushSubscription(id: string, verificationCode: string): Promise<void>;
updatePushSubscription(id: string, patch: { expires?: string; types?: string[] }): Promise<boolean>;
destroyPushSubscription(id: string): Promise<void>;
// ── Quota ───────────────────────────────────────────────────── // ── Quota ─────────────────────────────────────────────────────
getQuota(): Promise<{ used: number; total: number } | null>; getQuota(): Promise<{ used: number; total: number } | null>;
@@ -115,6 +129,7 @@ export interface IJMAPClient {
draftId?: string, draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
fromName?: string, fromName?: string,
htmlBody?: string,
): Promise<string>; ): Promise<string>;
sendEmail( sendEmail(
@@ -129,6 +144,8 @@ export interface IJMAPClient {
fromName?: string, fromName?: string,
htmlBody?: string, htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
): Promise<void>; ): Promise<void>;
sendImipReply(opts: { sendImipReply(opts: {
+104 -6
View File
@@ -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 { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface"; import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils"; import { toWildcardQuery } from "./search-utils";
@@ -294,6 +294,12 @@ function foldIcsLine(line: string): string {
return chunks.join('\r\n'); return chunks.join('\r\n');
} }
// JMAP RFC 8621 stores Message-IDs without angle brackets. Strip any that
// snuck in (e.g. when echoing values that originated from RFC 5322 headers).
function stripMessageIdBrackets(id: string): string {
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
}
export class JMAPClient implements IJMAPClient { export class JMAPClient implements IJMAPClient {
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000; private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
@@ -1933,7 +1939,8 @@ export class JMAPClient implements IJMAPClient {
fromEmail?: string, fromEmail?: string,
draftId?: string, draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
fromName?: string fromName?: string,
htmlBody?: string
): Promise<string> { ): Promise<string> {
const mailboxes = await this.getMailboxes(); const mailboxes = await this.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
@@ -1952,7 +1959,8 @@ export class JMAPClient implements IJMAPClient {
keywords: Record<string, boolean>; keywords: Record<string, boolean>;
mailboxIds: Record<string, boolean>; mailboxIds: Record<string, boolean>;
bodyValues: Record<string, { value: string }>; bodyValues: Record<string, { value: string }>;
textBody: { partId: string }[]; textBody: { partId: string; type?: string }[];
htmlBody?: { partId: string; type: string }[];
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[]; attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
} }
@@ -1964,8 +1972,13 @@ export class JMAPClient implements IJMAPClient {
subject, subject,
keywords: { "$draft": true }, keywords: { "$draft": true },
mailboxIds: { [draftsMailbox.id]: true }, mailboxIds: { [draftsMailbox.id]: true },
bodyValues: { "1": { value: body } }, bodyValues: htmlBody
textBody: [{ partId: "1" }], ? { "text": { value: body }, "html": { value: htmlBody } }
: { "1": { value: body } },
textBody: htmlBody
? [{ partId: "text", type: "text/plain" }]
: [{ partId: "1" }],
...(htmlBody ? { htmlBody: [{ partId: "html", type: "text/html" }] } : {}),
}; };
if (attachments?.length) { if (attachments?.length) {
@@ -2027,7 +2040,9 @@ export class JMAPClient implements IJMAPClient {
draftId?: string, draftId?: string,
fromName?: string, fromName?: string,
htmlBody?: string, htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[]
): Promise<void> { ): Promise<void> {
const emailId = `send-${Date.now()}`; const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes(); const mailboxes = await this.getMailboxes();
@@ -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 // Always create a new email with the final body content
const emailCreate: Record<string, unknown> = { const emailCreate: Record<string, unknown> = {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }], from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
@@ -2075,6 +2095,8 @@ export class JMAPClient implements IJMAPClient {
cc: cc?.map(email => ({ email })), cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })), bcc: bcc?.map(email => ({ email })),
subject, subject,
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
references: normalizedReferences?.length ? normalizedReferences : undefined,
keywords: { "$seen": true, "$draft": true }, keywords: { "$seen": true, "$draft": true },
mailboxIds: { [draftsMailbox.id]: true }, mailboxIds: { [draftsMailbox.id]: true },
}; };
@@ -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
View File
@@ -7,8 +7,21 @@ export interface OAuthMetadata {
} }
const CACHE_TTL_MS = 10 * 60 * 1000; const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 64;
const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>(); const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>();
function rememberMetadata(serverUrl: string, metadata: OAuthMetadata): void {
// Bound the cache so callers that can supply arbitrary serverUrl values
// (e.g. unauthenticated routes that fall back to user input) cannot
// exhaust memory. Map preserves insertion order, so the oldest entry is
// always the first one yielded by keys().
if (metadataCache.size >= CACHE_MAX_ENTRIES) {
const oldest = metadataCache.keys().next().value;
if (oldest !== undefined) metadataCache.delete(oldest);
}
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
}
export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata | null> { export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata | null> {
const cached = metadataCache.get(serverUrl); const cached = metadataCache.get(serverUrl);
if (cached && cached.expiresAt > Date.now()) return cached.metadata; if (cached && cached.expiresAt > Date.now()) return cached.metadata;
@@ -38,7 +51,7 @@ export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata |
revocation_endpoint: data.revocation_endpoint, revocation_endpoint: data.revocation_endpoint,
end_session_endpoint: data.end_session_endpoint, end_session_endpoint: data.end_session_endpoint,
}; };
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS }); rememberMetadata(serverUrl, metadata);
return metadata; return metadata;
} }
errors.push(`${url} response missing required endpoints`); errors.push(`${url} response missing required endpoints`);
+12
View File
@@ -365,6 +365,18 @@ export const themeHooks = {
onThemeChange: new HookBus(), onThemeChange: new HookBus(),
onCustomThemeChange: new HookBus(), onCustomThemeChange: new HookBus(),
onLocaleChange: new HookBus(), onLocaleChange: new HookBus(),
/**
* Transform hook fired immediately before a theme's compiled CSS is
* injected into the document.
*
* handler(css: string, ctx: { themeId: string | null; variant: 'light' | 'dark' }): string | undefined
*
* Return a new CSS string to override what gets injected, or `undefined`
* to pass through unchanged. Use this to inject extra `@font-face` rules,
* patch a third-party theme's variables for accessibility, or implement
* site-wide design-token overrides.
*/
onThemeBeforeApply: new HookBus(),
}; };
// §7.15 Toast Hooks // §7.15 Toast Hooks
+18 -1
View File
@@ -1,9 +1,11 @@
// IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews) // IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews)
const DB_NAME = 'bulwark-plugins'; 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_PLUGINS = 'plugin-code';
const STORE_THEMES = 'theme-css'; const STORE_THEMES = 'theme-css';
const STORE_THEME_SKINS = 'theme-skin';
const STORE_PREVIEWS = 'previews'; const STORE_PREVIEWS = 'previews';
function openDB(): Promise<IDBDatabase> { function openDB(): Promise<IDBDatabase> {
@@ -18,6 +20,9 @@ function openDB(): Promise<IDBDatabase> {
if (!db.objectStoreNames.contains(STORE_THEMES)) { if (!db.objectStoreNames.contains(STORE_THEMES)) {
db.createObjectStore(STORE_THEMES); db.createObjectStore(STORE_THEMES);
} }
if (!db.objectStoreNames.contains(STORE_THEME_SKINS)) {
db.createObjectStore(STORE_THEME_SKINS);
}
if (!db.objectStoreNames.contains(STORE_PREVIEWS)) { if (!db.objectStoreNames.contains(STORE_PREVIEWS)) {
db.createObjectStore(STORE_PREVIEWS); db.createObjectStore(STORE_PREVIEWS);
} }
@@ -83,6 +88,18 @@ export const pluginStorage = {
await deleteItem(STORE_THEMES, themeId); 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) // Preview images (stored as data URIs)
async savePreview(id: string, dataUri: string): Promise<void> { async savePreview(id: string, dataUri: string): Promise<void> {
await putItem(STORE_PREVIEWS, id, dataUri); await putItem(STORE_PREVIEWS, id, dataUri);
+76 -2
View File
@@ -11,6 +11,41 @@ export type ThemeVariant = 'light' | 'dark';
// ─── Manifests ─────────────────────────────────────────────── // ─── 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 { export interface ThemeManifest {
id: string; id: string;
name: string; name: string;
@@ -21,6 +56,22 @@ export interface ThemeManifest {
preview?: string; preview?: string;
variants: ThemeVariant[]; variants: ThemeVariant[];
minAppVersion?: string; 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 { export interface PluginManifest {
@@ -70,12 +121,29 @@ export interface InstalledTheme {
author: string; author: string;
description: string; description: string;
preview?: string; // data: URI or blob URL 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[]; variants: ThemeVariant[];
enabled: boolean; enabled: boolean;
builtIn: boolean; builtIn: boolean;
managed?: boolean; managed?: boolean;
forceEnabled?: 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 { export interface InstalledPlugin {
@@ -527,7 +595,13 @@ export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle'
// ─── Validation ────────────────────────────────────────────── // ─── Validation ──────────────────────────────────────────────
export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB 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([ export const ALLOWED_PLUGIN_FILES = new Set([
'.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp', '.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp',
+91 -14
View File
@@ -8,9 +8,11 @@ import {
ALL_PERMISSIONS, ALL_PERMISSIONS,
MAX_PLUGIN_SIZE, MAX_PLUGIN_SIZE,
MAX_THEME_SIZE, MAX_THEME_SIZE,
MAX_THEME_SKIN_BYTES,
ALLOWED_PLUGIN_FILES, ALLOWED_PLUGIN_FILES,
} from './plugin-types'; } 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 { export interface ValidationResult {
valid: boolean; valid: boolean;
@@ -21,6 +23,11 @@ export interface ValidationResult {
export interface ThemeExtractionResult extends ValidationResult { export interface ThemeExtractionResult extends ValidationResult {
manifest: ThemeManifest | null; manifest: ThemeManifest | null;
css: string; 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 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"'); 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 }; if (errors.length > 0) return { result: null, errors };
return { return {
@@ -157,7 +187,12 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
// Size check // Size check
if (file.size > MAX_THEME_SIZE) { 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; let zip: JSZip;
@@ -165,7 +200,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
zip = await JSZip.loadAsync(buffer); zip = await JSZip.loadAsync(buffer);
} catch { } 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); const root = findZipRoot(zip);
@@ -173,7 +208,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
// Read manifest // Read manifest
const manifestFile = zip.file(root + 'manifest.json'); const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) { 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>; let manifestData: Record<string, unknown>;
@@ -181,28 +216,49 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
const raw = await manifestFile.async('string'); const raw = await manifestFile.async('string');
manifestData = JSON.parse(raw); manifestData = JSON.parse(raw);
} catch { } 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); const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData);
errors.push(...manifestErrors); errors.push(...manifestErrors);
if (!manifest) { 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'); 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'); 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 // Run sanitizer over the final compiled output as a defence-in-depth check.
const safety = validateThemeCSSSafety(rawCSS);
if (!safety.valid) {
// Sanitize instead of rejecting
const sanitized = sanitizeThemeCSS(rawCSS); const sanitized = sanitizeThemeCSS(rawCSS);
rawCSS = sanitized.css; rawCSS = sanitized.css;
warnings.push(...sanitized.warnings); 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 { return {
valid: errors.length === 0, valid: errors.length === 0,
errors, errors,
warnings, warnings,
manifest, manifest,
css: rawCSS, css: rawCSS,
skin,
preview, preview,
}; };
} }
+45 -14
View File
@@ -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 * 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 // Constants for tag validation
const MAX_TAG_LENGTH = 30; const MAX_TAG_LENGTH = 30;
const TAG_REGEX = /^[a-zA-Z0-9-]{1,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 = export type TagValidationErrorCode =
| 'EMPTY' | 'EMPTY'
| 'TOO_LONG' | 'TOO_LONG'
@@ -22,10 +42,14 @@ export interface ParsedAddress {
} }
/** /**
* Parse an email address to extract sub-address tag * Parse an email address to extract sub-address tag.
* Example: "user+shopping@example.com" -> { baseUser: "user", tag: "shopping" } * 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('@'); const [localPart, domain] = email.split('@');
if (!localPart || !domain) { 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 { return {
localPart, localPart,
baseUser: localPart, baseUser: localPart,
@@ -50,8 +74,8 @@ export function parseSubAddress(email: string): ParsedAddress {
}; };
} }
const baseUser = localPart.substring(0, plusIndex); const baseUser = localPart.substring(0, delimiterIndex);
const tag = localPart.substring(plusIndex + 1); const tag = localPart.substring(delimiterIndex + delimiter.length);
return { return {
localPart, localPart,
@@ -63,18 +87,25 @@ export function parseSubAddress(email: string): ParsedAddress {
} }
/** /**
* Generate a sub-addressed email * Generate a sub-addressed email.
* Example: generateSubAddress("user@example.com", "shopping") -> "user+shopping@example.com" * 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('@'); const [localPart, domain] = baseEmail.split('@');
if (!localPart || !domain || !tag) { if (!localPart || !domain || !tag) {
return baseEmail; return baseEmail;
} }
// Remove existing tag if present // Strip an existing tag if one is already present
const cleanLocal = localPart.split('+')[0]; const existingDelimiterIndex = localPart.indexOf(delimiter);
const cleanLocal = existingDelimiterIndex === -1
? localPart
: localPart.substring(0, existingDelimiterIndex);
// Sanitize tag (alphanumeric and dash only) // Sanitize tag (alphanumeric and dash only)
const cleanTag = tag.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase(); 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 baseEmail;
} }
return `${cleanLocal}+${cleanTag}@${domain}`; return `${cleanLocal}${delimiter}${cleanTag}@${domain}`;
} }
/** /**
+115
View File
@@ -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 };
}
}
+15
View File
@@ -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';
+125
View File
@@ -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 };
}
}
+173
View File
@@ -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,
};
}
+108
View File
@@ -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();
}
+101
View File
@@ -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;
}
+47
View File
@@ -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';
+295
View File
@@ -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
);
}
+63
View File
@@ -3,6 +3,8 @@
import { DISALLOWED_CSS_PATTERNS } from './plugin-types'; import { DISALLOWED_CSS_PATTERNS } from './plugin-types';
const THEME_STYLE_ID = 'active-theme'; 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(), * 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. * Check if a theme CSS string is valid and safe.
*/ */
+352
View File
@@ -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
View File
@@ -127,6 +127,7 @@
"demo_tour": "Průvodce", "demo_tour": "Průvodce",
"tags": "Štítky", "tags": "Štítky",
"folders": "Složky", "folders": "Složky",
"shared": "Sdílené",
"mail": "Pošta", "mail": "Pošta",
"nav_label": "Navigace", "nav_label": "Navigace",
"add_app": "Aplikace" "add_app": "Aplikace"
@@ -426,8 +427,8 @@
"event_updated": "Aktualizace #{sequence}", "event_updated": "Aktualizace #{sequence}",
"event_status_tentative": "Nezávazně", "event_status_tentative": "Nezávazně",
"event_status_cancelled": "Zrušeno", "event_status_cancelled": "Zrušeno",
"expand": "Zobrazit detaily", "expand": "Zobrazit podrobnosti",
"collapse": "Skrýt detaily" "collapse": "Skrýt podrobnosti"
}, },
"send": "Odeslat", "send": "Odeslat",
"more": "více" "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?", "message": "Vaše zpráva obsahuje slovo \"{keyword}\", ale není k ní připojen žádný soubor. Přesto odeslat?",
"send_anyway": "Přesto odeslat", "send_anyway": "Přesto odeslat",
"back": "Zpět k úpravám" "back": "Zpět k úpravám"
} },
"add_link": "Přidat odkaz",
"link_url_prompt": "Zadejte URL",
"sending": "Odesílání..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Potvrdit", "confirm": "Potvrdit",
@@ -602,7 +606,6 @@
}, },
"language": { "language": {
"title": "Jazyk", "title": "Jazyk",
"czech": "Česky",
"english": "English", "english": "English",
"french": "Français", "french": "Français",
"japanese": "日本語", "japanese": "日本語",
@@ -614,7 +617,6 @@
"portuguese": "Português", "portuguese": "Português",
"russian": "Русский", "russian": "Русский",
"select_language": "Vybrat jazyk", "select_language": "Vybrat jazyk",
"switch_to_czech": "Přepnout na češtinu",
"switch_to_english": "Přepnout na angličtinu", "switch_to_english": "Přepnout na angličtinu",
"switch_to_french": "Přepnout na francouzštinu", "switch_to_french": "Přepnout na francouzštinu",
"switch_to_japanese": "Přepnout na japonš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", "sound_desc": "Přehrát zvukové upozornění pro připomenutí kalendáře",
"invitation_parsing": "Rozpoznávat e-mailové pozvánky", "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" "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": { "language_region": {
@@ -892,6 +911,13 @@
"label": "Automaticky vybírat adresu pro odpověď", "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" "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": { "attachment_click_action": {
"label": "Akce po kliknutí na přílohu", "label": "Akce po kliknutí na přílohu",
"description": "Vyberte, zda má kliknutí na přílohu zobrazit náhled, nebo ji ihned stáhnout", "description": "Vyberte, zda má kliknutí na přílohu zobrazit náhled, nebo ji ihned stáhnout",
@@ -1327,8 +1353,8 @@
"contacts": { "contacts": {
"title": "Kontakty", "title": "Kontakty",
"description": "Import a export kontaktů", "description": "Import a export kontaktů",
"group_by_letter_label": "Seskupit podle prvního písmene", "group_by_letter_label": "Seskupit podle prvního písmena",
"group_by_letter_description": "Zobrazovat abecední nadpisy v seznamu kontaktů", "group_by_letter_description": "Zobrazit záhlaví sekcí podle abecedy v seznamu kontaktů",
"import_label": "Importovat kontakty", "import_label": "Importovat kontakty",
"import_description": "Importovat kontakty ze souboru vCard (.vcf)", "import_description": "Importovat kontakty ze souboru vCard (.vcf)",
"export_label": "Exportovat kontakty", "export_label": "Exportovat kontakty",
@@ -1737,7 +1763,7 @@
"use_address": "Použít tuto adresu", "use_address": "Použít tuto adresu",
"invalid_tag": "Štítek může obsahovat pouze písmena, číslice a pomlčky", "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ů", "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": { "validation": {
"empty": "Štítek nesmí být prázdný", "empty": "Štítek nesmí být prázdný",
"too_long": "Štítek může mít maximálně {max} znaků", "too_long": "Štítek může mít maximálně {max} znaků",
@@ -2134,7 +2160,11 @@
"tomorrow_header": "Zítra", "tomorrow_header": "Zítra",
"export_ics": "Exportovat jako .ics", "export_ics": "Exportovat jako .ics",
"copy_title": "Kopírovat název", "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": { "detail": {
"add_note": "Přidat poznámku...", "add_note": "Přidat poznámku...",
@@ -2433,6 +2463,20 @@
"due_today": "Dnes", "due_today": "Dnes",
"due_tomorrow": "Zítra", "due_tomorrow": "Zítra",
"overdue": "Po termínu" "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": { "advanced_search": {
+54 -5
View File
@@ -424,7 +424,9 @@
"cancel_info": "Der Organisator hat diesen Termin abgesagt.", "cancel_info": "Der Organisator hat diesen Termin abgesagt.",
"event_updated": "Aktualisierung #{sequence}", "event_updated": "Aktualisierung #{sequence}",
"event_status_tentative": "Vorläufig", "event_status_tentative": "Vorläufig",
"event_status_cancelled": "Abgesagt" "event_status_cancelled": "Abgesagt",
"collapse": "Details ausblenden",
"expand": "Details anzeigen"
}, },
"previous": "Zurück", "previous": "Zurück",
"next": "Weiter", "next": "Weiter",
@@ -508,7 +510,10 @@
"message": "Ihre Nachricht enthält \"{keyword}\", aber es ist keine Datei angehängt. Trotzdem senden?", "message": "Ihre Nachricht enthält \"{keyword}\", aber es ist keine Datei angehängt. Trotzdem senden?",
"send_anyway": "Trotzdem senden", "send_anyway": "Trotzdem senden",
"back": "Zurück zur Bearbeitung" "back": "Zurück zur Bearbeitung"
} },
"add_link": "Link hinzufügen",
"link_url_prompt": "URL eingeben",
"sending": "Wird gesendet..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Bestätigen", "confirm": "Bestätigen",
@@ -801,6 +806,23 @@
"sound_desc": "Einen Ton für Kalendererinnerungen abspielen", "sound_desc": "Einen Ton für Kalendererinnerungen abspielen",
"invitation_parsing": "E-Mail-Einladungen erkennen", "invitation_parsing": "E-Mail-Einladungen erkennen",
"invitation_parsing_desc": "Kalendereinladungen in E-Mail-Anhängen erkennen und Kalenderaktionen anzeigen" "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": { "language_region": {
@@ -889,6 +911,13 @@
"label": "Antwortadresse automatisch wählen", "label": "Antwortadresse automatisch wählen",
"description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat" "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": { "attachment_click_action": {
"label": "Aktion beim Klick auf Anhänge", "label": "Aktion beim Klick auf Anhänge",
"description": "Festlegen, ob ein Dateianhang beim Anklicken in der Vorschau geöffnet oder sofort heruntergeladen wird", "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", "no_address_books": "Keine Adressbücher gefunden",
"categories_title": "Kategorien", "categories_title": "Kategorien",
"categories_description": "Kontaktkategorien umbenennen", "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": { "filters": {
"title": "E-Mail-Filter", "title": "E-Mail-Filter",
@@ -1732,7 +1763,7 @@
"use_address": "Diese Adresse verwenden", "use_address": "Diese Adresse verwenden",
"invalid_tag": "Tag darf nur alphanumerisch und Bindestriche enthalten", "invalid_tag": "Tag darf nur alphanumerisch und Bindestriche enthalten",
"tag_too_long": "Tag darf maximal 30 Zeichen lang sein", "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": { "validation": {
"empty": "Tag darf nicht leer sein", "empty": "Tag darf nicht leer sein",
"too_long": "Tag darf maximal {max} Zeichen lang sein", "too_long": "Tag darf maximal {max} Zeichen lang sein",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Morgen", "tomorrow_header": "Morgen",
"export_ics": "Als .ics exportieren", "export_ics": "Als .ics exportieren",
"copy_title": "Titel kopieren", "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": { "detail": {
"add_note": "Notiz hinzufügen...", "add_note": "Notiz hinzufügen...",
@@ -2428,6 +2463,20 @@
"due_today": "Heute fällig", "due_today": "Heute fällig",
"due_tomorrow": "Morgen fällig", "due_tomorrow": "Morgen fällig",
"overdue": "Überfä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": { "advanced_search": {
+44 -2
View File
@@ -783,6 +783,23 @@
"swift": "Swift Gesture", "swift": "Swift Gesture",
"relax": "Relax" "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": { "sound_selection": {
"title": "Notification Sound", "title": "Notification Sound",
"description": "Choose which sound to play for notifications", "description": "Choose which sound to play for notifications",
@@ -894,6 +911,13 @@
"label": "Auto-select Reply Address", "label": "Auto-select Reply Address",
"description": "When replying, automatically switch the From address to the identity that originally received the message" "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": { "attachment_click_action": {
"label": "Attachment Click Action", "label": "Attachment Click Action",
"description": "Choose whether clicking a file attachment previews it or downloads it immediately", "description": "Choose whether clicking a file attachment previews it or downloads it immediately",
@@ -1739,7 +1763,7 @@
"use_address": "Use This Address", "use_address": "Use This Address",
"invalid_tag": "Tag must be alphanumeric and dashes only", "invalid_tag": "Tag must be alphanumeric and dashes only",
"tag_too_long": "Tag must be 30 characters or less", "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": { "validation": {
"empty": "Tag cannot be empty", "empty": "Tag cannot be empty",
"too_long": "Tag must be {max} characters or less", "too_long": "Tag must be {max} characters or less",
@@ -2136,7 +2160,11 @@
"tomorrow_header": "Tomorrow", "tomorrow_header": "Tomorrow",
"export_ics": "Export as .ics", "export_ics": "Export as .ics",
"copy_title": "Copy title", "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": { "detail": {
"add_note": "Add a note...", "add_note": "Add a note...",
@@ -2268,6 +2296,20 @@
"sat": "Sat", "sat": "Sat",
"sun": "Sun" "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": { "notifications": {
"event_created": "Event created", "event_created": "Event created",
"event_updated": "Event updated", "event_updated": "Event updated",
+54 -5
View File
@@ -424,7 +424,9 @@
"cancel_info": "El organizador ha cancelado este evento.", "cancel_info": "El organizador ha cancelado este evento.",
"event_updated": "Actualización #{sequence}", "event_updated": "Actualización #{sequence}",
"event_status_tentative": "Provisional", "event_status_tentative": "Provisional",
"event_status_cancelled": "Cancelado" "event_status_cancelled": "Cancelado",
"collapse": "Ocultar detalles",
"expand": "Mostrar detalles"
}, },
"previous": "Anterior", "previous": "Anterior",
"next": "Siguiente", "next": "Siguiente",
@@ -508,7 +510,10 @@
"message": "Tu mensaje menciona \"{keyword}\" pero no hay ningún archivo adjunto. ¿Enviar de todos modos?", "message": "Tu mensaje menciona \"{keyword}\" pero no hay ningún archivo adjunto. ¿Enviar de todos modos?",
"send_anyway": "Enviar de todos modos", "send_anyway": "Enviar de todos modos",
"back": "Volver a editar" "back": "Volver a editar"
} },
"add_link": "Añadir enlace",
"link_url_prompt": "Introduce la URL",
"sending": "Enviando..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmar", "confirm": "Confirmar",
@@ -801,6 +806,23 @@
"sound_desc": "Reproducir un sonido para recordatorios del calendario", "sound_desc": "Reproducir un sonido para recordatorios del calendario",
"invitation_parsing": "Analizar invitaciones por correo", "invitation_parsing": "Analizar invitaciones por correo",
"invitation_parsing_desc": "Detectar invitaciones de calendario en archivos adjuntos y mostrar acciones de calendario" "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": { "language_region": {
@@ -884,6 +906,13 @@
"label": "Seleccionar dirección de respuesta automáticamente", "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" "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": { "show_preview": {
"label": "Mostrar Vista Previa", "label": "Mostrar Vista Previa",
"description": "Mostrar vista previa del correo en la lista", "description": "Mostrar vista previa del correo en la lista",
@@ -1333,7 +1362,9 @@
"no_address_books": "No se encontraron libretas de direcciones", "no_address_books": "No se encontraron libretas de direcciones",
"categories_title": "Categorías", "categories_title": "Categorías",
"categories_description": "Renombrar categorías de contactos", "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": { "filters": {
"title": "Filtros de correo", "title": "Filtros de correo",
@@ -1732,7 +1763,7 @@
"use_address": "Usar Esta Dirección", "use_address": "Usar Esta Dirección",
"invalid_tag": "La etiqueta debe ser solo alfanumérica y guiones", "invalid_tag": "La etiqueta debe ser solo alfanumérica y guiones",
"tag_too_long": "La etiqueta debe tener 30 caracteres o menos", "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": { "validation": {
"empty": "La etiqueta no puede estar vacía", "empty": "La etiqueta no puede estar vacía",
"too_long": "La etiqueta debe tener {max} caracteres o menos", "too_long": "La etiqueta debe tener {max} caracteres o menos",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Mañana", "tomorrow_header": "Mañana",
"export_ics": "Exportar como .ics", "export_ics": "Exportar como .ics",
"copy_title": "Copiar título", "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": { "detail": {
"add_note": "Añadir una nota...", "add_note": "Añadir una nota...",
@@ -2428,6 +2463,20 @@
"due_today": "Vence hoy", "due_today": "Vence hoy",
"due_tomorrow": "Vence mañana", "due_tomorrow": "Vence mañana",
"overdue": "Vencida" "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": { "advanced_search": {
+54 -5
View File
@@ -424,7 +424,9 @@
"cancel_info": "L'organisateur a annulé cet événement.", "cancel_info": "L'organisateur a annulé cet événement.",
"event_updated": "Mise à jour #{sequence}", "event_updated": "Mise à jour #{sequence}",
"event_status_tentative": "Provisoire", "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", "previous": "Précédent",
"next": "Suivant", "next": "Suivant",
@@ -508,7 +510,10 @@
"message": "Votre message mentionne \"{keyword}\" mais aucun fichier n'est joint. Envoyer quand même ?", "message": "Votre message mentionne \"{keyword}\" mais aucun fichier n'est joint. Envoyer quand même ?",
"send_anyway": "Envoyer quand même", "send_anyway": "Envoyer quand même",
"back": "Retour à l'édition" "back": "Retour à l'édition"
} },
"add_link": "Ajouter un lien",
"link_url_prompt": "Saisissez l'URL",
"sending": "Envoi..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmer", "confirm": "Confirmer",
@@ -801,6 +806,23 @@
"sound_desc": "Jouer un son pour les rappels de calendrier", "sound_desc": "Jouer un son pour les rappels de calendrier",
"invitation_parsing": "Analyser les invitations par e-mail", "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" "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": { "language_region": {
@@ -884,6 +906,13 @@
"label": "Sélection automatique de l'adresse de réponse", "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" "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": { "show_preview": {
"label": "Afficher l'aperçu", "label": "Afficher l'aperçu",
"description": "Afficher l'aperçu de l'email dans la liste", "description": "Afficher l'aperçu de l'email dans la liste",
@@ -1333,7 +1362,9 @@
"no_address_books": "Aucun carnet d'adresses trouvé", "no_address_books": "Aucun carnet d'adresses trouvé",
"categories_title": "Catégories", "categories_title": "Catégories",
"categories_description": "Renommer les catégories de contacts", "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": { "filters": {
"title": "Filtres de courrier", "title": "Filtres de courrier",
@@ -1732,7 +1763,7 @@
"use_address": "Utiliser cette adresse", "use_address": "Utiliser cette adresse",
"invalid_tag": "Le tag doit contenir uniquement des lettres, chiffres et tirets", "invalid_tag": "Le tag doit contenir uniquement des lettres, chiffres et tirets",
"tag_too_long": "Le tag doit faire 30 caractères ou moins", "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": { "validation": {
"empty": "Le tag ne peut pas être vide", "empty": "Le tag ne peut pas être vide",
"too_long": "Le tag doit faire {max} caractères ou moins", "too_long": "Le tag doit faire {max} caractères ou moins",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Demain", "tomorrow_header": "Demain",
"export_ics": "Exporter en .ics", "export_ics": "Exporter en .ics",
"copy_title": "Copier le titre", "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": { "detail": {
"add_note": "Ajouter une note...", "add_note": "Ajouter une note...",
@@ -2261,6 +2296,20 @@
"sat": "Sam", "sat": "Sam",
"sun": "Dim" "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": { "notifications": {
"event_created": "Événement créé", "event_created": "Événement créé",
"event_updated": "Événement mis à jour", "event_updated": "Événement mis à jour",
+54 -5
View File
@@ -424,7 +424,9 @@
"cancel_info": "L'organizzatore ha annullato questo evento.", "cancel_info": "L'organizzatore ha annullato questo evento.",
"event_updated": "Aggiornamento #{sequence}", "event_updated": "Aggiornamento #{sequence}",
"event_status_tentative": "Provvisorio", "event_status_tentative": "Provvisorio",
"event_status_cancelled": "Annullato" "event_status_cancelled": "Annullato",
"collapse": "Nascondi dettagli",
"expand": "Mostra dettagli"
}, },
"previous": "Precedente", "previous": "Precedente",
"next": "Successivo", "next": "Successivo",
@@ -508,7 +510,10 @@
"message": "Il tuo messaggio menziona \"{keyword}\" ma nessun file è allegato. Inviare comunque?", "message": "Il tuo messaggio menziona \"{keyword}\" ma nessun file è allegato. Inviare comunque?",
"send_anyway": "Invia comunque", "send_anyway": "Invia comunque",
"back": "Torna alla modifica" "back": "Torna alla modifica"
} },
"add_link": "Aggiungi link",
"link_url_prompt": "Inserisci l'URL",
"sending": "Invio in corso..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Conferma", "confirm": "Conferma",
@@ -801,6 +806,23 @@
"sound_desc": "Riproduci un suono per i promemoria del calendario", "sound_desc": "Riproduci un suono per i promemoria del calendario",
"invitation_parsing": "Analizza inviti via e-mail", "invitation_parsing": "Analizza inviti via e-mail",
"invitation_parsing_desc": "Rileva inviti calendario negli allegati e mostra azioni calendario" "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": { "language_region": {
@@ -884,6 +906,13 @@
"label": "Seleziona automaticamente l'indirizzo di risposta", "label": "Seleziona automaticamente l'indirizzo di risposta",
"description": "Quando rispondi, passa automaticamente l'indirizzo mittente all'identità che ha ricevuto il messaggio originale" "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": { "show_preview": {
"label": "Mostra anteprima testo", "label": "Mostra anteprima testo",
"description": "Visualizza l'anteprima del messaggio nell'elenco", "description": "Visualizza l'anteprima del messaggio nell'elenco",
@@ -1333,7 +1362,9 @@
"no_address_books": "Nessuna rubrica trovata", "no_address_books": "Nessuna rubrica trovata",
"categories_title": "Categorie", "categories_title": "Categorie",
"categories_description": "Rinomina le categorie dei contatti", "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": { "filters": {
"title": "Filtri email", "title": "Filtri email",
@@ -1732,7 +1763,7 @@
"use_address": "Usa questo indirizzo", "use_address": "Usa questo indirizzo",
"invalid_tag": "Il tag deve contenere solo caratteri alfanumerici e trattini", "invalid_tag": "Il tag deve contenere solo caratteri alfanumerici e trattini",
"tag_too_long": "Il tag deve essere di massimo 30 caratteri", "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": { "validation": {
"empty": "Il tag non può essere vuoto", "empty": "Il tag non può essere vuoto",
"too_long": "Il tag deve essere di massimo {max} caratteri", "too_long": "Il tag deve essere di massimo {max} caratteri",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Domani", "tomorrow_header": "Domani",
"export_ics": "Esporta come .ics", "export_ics": "Esporta come .ics",
"copy_title": "Copia titolo", "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": { "detail": {
"add_note": "Aggiungi una nota...", "add_note": "Aggiungi una nota...",
@@ -2428,6 +2463,20 @@
"due_today": "Scade oggi", "due_today": "Scade oggi",
"due_tomorrow": "Scade domani", "due_tomorrow": "Scade domani",
"overdue": "Scaduta" "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": { "advanced_search": {
+54 -5
View File
@@ -424,7 +424,9 @@
"cancel_info": "主催者がこのイベントをキャンセルしました。", "cancel_info": "主催者がこのイベントをキャンセルしました。",
"event_updated": "更新 #{sequence}", "event_updated": "更新 #{sequence}",
"event_status_tentative": "仮", "event_status_tentative": "仮",
"event_status_cancelled": "キャンセル済み" "event_status_cancelled": "キャンセル済み",
"collapse": "詳細を非表示",
"expand": "詳細を表示"
}, },
"previous": "前へ", "previous": "前へ",
"next": "次へ", "next": "次へ",
@@ -508,7 +510,10 @@
"message": "メッセージに「{keyword}」が含まれていますが、ファイルが添付されていません。このまま送信しますか?", "message": "メッセージに「{keyword}」が含まれていますが、ファイルが添付されていません。このまま送信しますか?",
"send_anyway": "そのまま送信", "send_anyway": "そのまま送信",
"back": "編集に戻る" "back": "編集に戻る"
} },
"add_link": "リンクを追加",
"link_url_prompt": "URLを入力してください",
"sending": "送信中..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "確認", "confirm": "確認",
@@ -801,6 +806,23 @@
"sound_desc": "カレンダーリマインダーの音を鳴らす", "sound_desc": "カレンダーリマインダーの音を鳴らす",
"invitation_parsing": "メール招待を解析", "invitation_parsing": "メール招待を解析",
"invitation_parsing_desc": "メール添付ファイルのカレンダー招待を検出し、カレンダーアクションを表示" "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": { "language_region": {
@@ -884,6 +906,13 @@
"label": "返信元アドレスを自動選択", "label": "返信元アドレスを自動選択",
"description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます" "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": { "show_preview": {
"label": "プレビューテキストを表示", "label": "プレビューテキストを表示",
"description": "リストにメールのプレビューを表示", "description": "リストにメールのプレビューを表示",
@@ -1333,7 +1362,9 @@
"no_address_books": "アドレス帳が見つかりません", "no_address_books": "アドレス帳が見つかりません",
"categories_title": "カテゴリ", "categories_title": "カテゴリ",
"categories_description": "連絡先カテゴリの名前を変更", "categories_description": "連絡先カテゴリの名前を変更",
"no_categories": "カテゴリが見つかりません" "no_categories": "カテゴリが見つかりません",
"group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示",
"group_by_letter_label": "頭文字でグループ化"
}, },
"filters": { "filters": {
"title": "メールフィルター", "title": "メールフィルター",
@@ -1732,7 +1763,7 @@
"use_address": "このアドレスを使用", "use_address": "このアドレスを使用",
"invalid_tag": "タグは英数字とハイフンのみ使用できます", "invalid_tag": "タグは英数字とハイフンのみ使用できます",
"tag_too_long": "タグは30文字以内にしてください", "tag_too_long": "タグは30文字以内にしてください",
"help_text": "user+tag@domain.comに送信されたメールは受信トレイに届きます", "help_text": "user{delimiter}tag@domain.comに送信されたメールは受信トレイに届きます",
"validation": { "validation": {
"empty": "タグは空にできません", "empty": "タグは空にできません",
"too_long": "タグは{max}文字以内にしてください", "too_long": "タグは{max}文字以内にしてください",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "明日", "tomorrow_header": "明日",
"export_ics": ".icsとしてエクスポート", "export_ics": ".icsとしてエクスポート",
"copy_title": "タイトルをコピー", "copy_title": "タイトルをコピー",
"copy_link": "会議リンクをコピー" "copy_link": "会議リンクをコピー",
"go_to_today": "今日に移動",
"new_all_day_event": "新しい終日イベント",
"new_event": "新しいイベント",
"new_task": "新しいタスク"
}, },
"detail": { "detail": {
"add_note": "メモを追加...", "add_note": "メモを追加...",
@@ -2428,6 +2463,20 @@
"due_today": "今日が期限", "due_today": "今日が期限",
"due_tomorrow": "明日が期限", "due_tomorrow": "明日が期限",
"overdue": "期限切れ" "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": { "advanced_search": {
+54 -5
View File
@@ -426,7 +426,9 @@
"cancel_info": "주최자가 이 일정을 취소했어요.", "cancel_info": "주최자가 이 일정을 취소했어요.",
"event_updated": "업데이트 #{sequence}", "event_updated": "업데이트 #{sequence}",
"event_status_tentative": "미정", "event_status_tentative": "미정",
"event_status_cancelled": "취소됨" "event_status_cancelled": "취소됨",
"collapse": "세부정보 숨기기",
"expand": "세부정보 표시"
}, },
"send": "보내기", "send": "보내기",
"more": "더보기" "more": "더보기"
@@ -508,7 +510,10 @@
"message": "메시지에 \"{keyword}\"이(가) 언급되어 있지만 파일이 첨부되지 않았습니다. 그래도 보내시겠습니까?", "message": "메시지에 \"{keyword}\"이(가) 언급되어 있지만 파일이 첨부되지 않았습니다. 그래도 보내시겠습니까?",
"send_anyway": "그래도 보내기", "send_anyway": "그래도 보내기",
"back": "편집으로 돌아가기" "back": "편집으로 돌아가기"
} },
"add_link": "링크 추가",
"link_url_prompt": "URL을 입력하세요",
"sending": "전송 중..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "확인", "confirm": "확인",
@@ -801,6 +806,23 @@
"sound_desc": "일정 알림이 올 때 소리로 알려줘요", "sound_desc": "일정 알림이 올 때 소리로 알려줘요",
"invitation_parsing": "이메일 초대장 분석", "invitation_parsing": "이메일 초대장 분석",
"invitation_parsing_desc": "이메일 첨부파일에서 캘린더 초대장을 감지하고 캘린더에 표시해요" "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": { "language_region": {
@@ -889,6 +911,13 @@
"label": "답장 시 보내는 사람 자동 선택", "label": "답장 시 보내는 사람 자동 선택",
"description": "답장할 때 메일을 받았던 주소로 보내는 사람을 자동으로 변경해요" "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": { "attachment_click_action": {
"label": "첨부파일 클릭 동작", "label": "첨부파일 클릭 동작",
"description": "파일을 클릭했을 때 미리보기를 할지, 바로 다운로드할지 선택해 주세요", "description": "파일을 클릭했을 때 미리보기를 할지, 바로 다운로드할지 선택해 주세요",
@@ -1333,7 +1362,9 @@
"no_address_books": "주소록을 찾을 수 없음", "no_address_books": "주소록을 찾을 수 없음",
"categories_title": "카테고리", "categories_title": "카테고리",
"categories_description": "연락처 카테고리 이름 변경", "categories_description": "연락처 카테고리 이름 변경",
"no_categories": "카테고리를 찾을 수 없음" "no_categories": "카테고리를 찾을 수 없음",
"group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시",
"group_by_letter_label": "첫 글자로 그룹화"
}, },
"filters": { "filters": {
"title": "이메일 필터", "title": "이메일 필터",
@@ -1732,7 +1763,7 @@
"use_address": "이 주소 사용하기", "use_address": "이 주소 사용하기",
"invalid_tag": "태그는 알파벳, 숫자, 대시(-)만 쓸 수 있어요", "invalid_tag": "태그는 알파벳, 숫자, 대시(-)만 쓸 수 있어요",
"tag_too_long": "태그는 30자 이하여야 해요", "tag_too_long": "태그는 30자 이하여야 해요",
"help_text": "user+tag@domain.com 으로 보낸 메일은 내 받은편지함으로 들어와요", "help_text": "user{delimiter}tag@domain.com 으로 보낸 메일은 내 받은편지함으로 들어와요",
"validation": { "validation": {
"empty": "태그를 비워둘 수 없어요", "empty": "태그를 비워둘 수 없어요",
"too_long": "태그는 {max}자 이하여야 해요", "too_long": "태그는 {max}자 이하여야 해요",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "내일", "tomorrow_header": "내일",
"export_ics": ".ics로 내보내기", "export_ics": ".ics로 내보내기",
"copy_title": "제목 복사", "copy_title": "제목 복사",
"copy_link": "회의 링크 복사" "copy_link": "회의 링크 복사",
"go_to_today": "오늘로 이동",
"new_all_day_event": "새 종일 이벤트",
"new_event": "새 이벤트",
"new_task": "새 작업"
}, },
"detail": { "detail": {
"add_note": "메모 추가...", "add_note": "메모 추가...",
@@ -2428,6 +2463,20 @@
"due_today": "오늘 마감", "due_today": "오늘 마감",
"due_tomorrow": "내일 마감", "due_tomorrow": "내일 마감",
"overdue": "기한 지남" "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": { "advanced_search": {
+55 -6
View File
@@ -426,7 +426,9 @@
"cancel_info": "Organizators atcēla šo pasākumu.", "cancel_info": "Organizators atcēla šo pasākumu.",
"event_updated": "Atjauninājums Nr. {sequence}", "event_updated": "Atjauninājums Nr. {sequence}",
"event_status_tentative": "Pagaidām", "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", "send": "Sūtīt",
"more": "vairāk" "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?", "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ā", "send_anyway": "Sūtīt tik un tā",
"back": "Atpakaļ pie rediģēšanas" "back": "Atpakaļ pie rediģēšanas"
} },
"add_link": "Pievienot saiti",
"link_url_prompt": "Ievadiet URL",
"sending": "Sūta..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Apstiprināt", "confirm": "Apstiprināt",
@@ -801,6 +806,23 @@
"sound_desc": "Atskaņot skaņas signālu kalendāra atgādinājumiem", "sound_desc": "Atskaņot skaņas signālu kalendāra atgādinājumiem",
"invitation_parsing": "Atpazīt uzaicinājumus e-pastā", "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" "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": { "language_region": {
@@ -884,6 +906,13 @@
"label": "Automātiski izvēlēties atbildes adresi", "label": "Automātiski izvēlēties atbildes adresi",
"description": "Atbildot automātiski izmantot to kontu, uz kuru vēstule tika saņemta" "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": { "show_preview": {
"label": "Rādīt priekšskatījuma tekstu", "label": "Rādīt priekšskatījuma tekstu",
"description": "Rādīt vēstules fragmentu sarakstā", "description": "Rādīt vēstules fragmentu sarakstā",
@@ -1333,7 +1362,9 @@
"no_address_books": "Adrešu grāmatas nav atrastas", "no_address_books": "Adrešu grāmatas nav atrastas",
"categories_title": "Kategorijas", "categories_title": "Kategorijas",
"categories_description": "Pārdēvēt kontaktu 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": { "filters": {
"title": "Pasta filtri", "title": "Pasta filtri",
@@ -1732,7 +1763,7 @@
"use_address": "Izmantot šo adresi", "use_address": "Izmantot šo adresi",
"invalid_tag": "Tags var saturēt tikai burtus, ciparus un domuzīmes", "invalid_tag": "Tags var saturēt tikai burtus, ciparus un domuzīmes",
"tag_too_long": "Tags nedrīkst pārsniegt 30 rakstzī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": { "validation": {
"empty": "Tags nevar būt tukšs", "empty": "Tags nevar būt tukšs",
"too_long": "Tags nedrīkst pārsniegt {max} rakstzīmes", "too_long": "Tags nedrīkst pārsniegt {max} rakstzīmes",
@@ -2128,7 +2159,11 @@
"tomorrow_header": "Rīt", "tomorrow_header": "Rīt",
"export_ics": "Eksportēt kā .ics", "export_ics": "Eksportēt kā .ics",
"copy_title": "Kopēt nosaukumu", "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": { "detail": {
"add_note": "Pievienot piezīmi...", "add_note": "Pievienot piezīmi...",
@@ -2428,7 +2463,21 @@
"due_tomorrow": "Rīt", "due_tomorrow": "Rīt",
"overdue": "Kavēts" "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": { "advanced_search": {
"title": "Izvērstā meklēšana", "title": "Izvērstā meklēšana",
+54 -5
View File
@@ -424,7 +424,9 @@
"cancel_info": "De organisator heeft dit evenement geannuleerd.", "cancel_info": "De organisator heeft dit evenement geannuleerd.",
"event_updated": "Update #{sequence}", "event_updated": "Update #{sequence}",
"event_status_tentative": "Voorlopig", "event_status_tentative": "Voorlopig",
"event_status_cancelled": "Geannuleerd" "event_status_cancelled": "Geannuleerd",
"collapse": "Details verbergen",
"expand": "Details tonen"
}, },
"previous": "Vorige", "previous": "Vorige",
"next": "Volgende", "next": "Volgende",
@@ -508,7 +510,10 @@
"message": "Uw bericht vermeldt \"{keyword}\" maar er is geen bestand bijgevoegd. Toch verzenden?", "message": "Uw bericht vermeldt \"{keyword}\" maar er is geen bestand bijgevoegd. Toch verzenden?",
"send_anyway": "Toch verzenden", "send_anyway": "Toch verzenden",
"back": "Terug naar bewerken" "back": "Terug naar bewerken"
} },
"add_link": "Link toevoegen",
"link_url_prompt": "Voer de URL in",
"sending": "Bezig met verzenden..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Bevestigen", "confirm": "Bevestigen",
@@ -801,6 +806,23 @@
"sound_desc": "Een geluid afspelen voor agendaherinneringen", "sound_desc": "Een geluid afspelen voor agendaherinneringen",
"invitation_parsing": "E-mailuitnodigingen herkennen", "invitation_parsing": "E-mailuitnodigingen herkennen",
"invitation_parsing_desc": "Agenda-uitnodigingen in e-mailbijlagen detecteren en agendaacties tonen" "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": { "language_region": {
@@ -884,6 +906,13 @@
"label": "Antwoordadres automatisch selecteren", "label": "Antwoordadres automatisch selecteren",
"description": "Schakel bij het beantwoorden automatisch het Van-adres om naar de identiteit die het oorspronkelijke bericht ontving" "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": { "show_preview": {
"label": "Voorbeeldtekst tonen", "label": "Voorbeeldtekst tonen",
"description": "E-mailvoorbeeld weergeven in de lijst", "description": "E-mailvoorbeeld weergeven in de lijst",
@@ -1333,7 +1362,9 @@
"no_address_books": "Geen adresboeken gevonden", "no_address_books": "Geen adresboeken gevonden",
"categories_title": "Categorieën", "categories_title": "Categorieën",
"categories_description": "Contactcategorieën hernoemen", "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": { "filters": {
"title": "E-mailfilters", "title": "E-mailfilters",
@@ -1732,7 +1763,7 @@
"use_address": "Dit adres gebruiken", "use_address": "Dit adres gebruiken",
"invalid_tag": "Tag mag alleen letters, cijfers en streepjes bevatten", "invalid_tag": "Tag mag alleen letters, cijfers en streepjes bevatten",
"tag_too_long": "Tag mag maximaal 30 tekens 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": { "validation": {
"empty": "Tag mag niet leeg zijn", "empty": "Tag mag niet leeg zijn",
"too_long": "Tag mag maximaal {max} tekens bevatten", "too_long": "Tag mag maximaal {max} tekens bevatten",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Morgen", "tomorrow_header": "Morgen",
"export_ics": "Exporteren als .ics", "export_ics": "Exporteren als .ics",
"copy_title": "Titel kopiëren", "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": { "detail": {
"add_note": "Notitie toevoegen...", "add_note": "Notitie toevoegen...",
@@ -2428,6 +2463,20 @@
"due_today": "Vervalt vandaag", "due_today": "Vervalt vandaag",
"due_tomorrow": "Vervalt morgen", "due_tomorrow": "Vervalt morgen",
"overdue": "Achterstallig" "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": { "advanced_search": {
+54 -5
View File
@@ -426,7 +426,9 @@
"cancel_info": "Organizator anulował to wydarzenie.", "cancel_info": "Organizator anulował to wydarzenie.",
"event_updated": "Aktualizacja #{sequence}", "event_updated": "Aktualizacja #{sequence}",
"event_status_tentative": "Wstępne", "event_status_tentative": "Wstępne",
"event_status_cancelled": "Anulowane" "event_status_cancelled": "Anulowane",
"collapse": "Ukryj szczegóły",
"expand": "Pokaż szczegóły"
}, },
"send": "Wyślij", "send": "Wyślij",
"more": "więcej" "more": "więcej"
@@ -508,7 +510,10 @@
"message": "Twoja wiadomość wspomina o \"{keyword}\", ale nie załączono żadnego pliku. Wysłać mimo to?", "message": "Twoja wiadomość wspomina o \"{keyword}\", ale nie załączono żadnego pliku. Wysłać mimo to?",
"send_anyway": "Wyślij mimo to", "send_anyway": "Wyślij mimo to",
"back": "Wróć do edycji" "back": "Wróć do edycji"
} },
"add_link": "Dodaj link",
"link_url_prompt": "Wprowadź adres URL",
"sending": "Wysyłanie..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Potwierdź", "confirm": "Potwierdź",
@@ -801,6 +806,23 @@
"sound_desc": "Odtwarzaj sygnał dźwiękowy dla przypomnień kalendarza", "sound_desc": "Odtwarzaj sygnał dźwiękowy dla przypomnień kalendarza",
"invitation_parsing": "Rozpoznawaj zaproszenia e-mail", "invitation_parsing": "Rozpoznawaj zaproszenia e-mail",
"invitation_parsing_desc": "Wykrywaj zaproszenia kalendarzowe w załącznikach wiadomości e-mail i pokazuj akcje kalendarza" "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": { "language_region": {
@@ -889,6 +911,13 @@
"label": "Automatycznie wybieraj adres odpowiedzi", "label": "Automatycznie wybieraj adres odpowiedzi",
"description": "Podczas odpowiadania automatycznie przełączaj adres nadawcy na tożsamość, która pierwotnie otrzymała wiadomość" "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": { "attachment_click_action": {
"label": "Akcja po kliknięciu załącznika", "label": "Akcja po kliknięciu załącznika",
"description": "Wybierz, czy kliknięcie załącznika pliku ma pokazać podgląd, czy od razu go pobrać", "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", "no_address_books": "Nie znaleziono książek adresowych",
"categories_title": "Kategorie", "categories_title": "Kategorie",
"categories_description": "Zmień nazwy kategorii kontaktów", "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": { "filters": {
"title": "Filtry wiadomości e-mail", "title": "Filtry wiadomości e-mail",
@@ -1732,7 +1763,7 @@
"use_address": "Użyj tego adresu", "use_address": "Użyj tego adresu",
"invalid_tag": "Tag może zawierać tylko litery, cyfry i myślniki", "invalid_tag": "Tag może zawierać tylko litery, cyfry i myślniki",
"tag_too_long": "Tag może mieć maksymalnie 30 znaków", "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": { "validation": {
"empty": "Tag nie może być pusty", "empty": "Tag nie może być pusty",
"too_long": "Tag może mieć maksymalnie {max} znaków", "too_long": "Tag może mieć maksymalnie {max} znaków",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Jutro", "tomorrow_header": "Jutro",
"export_ics": "Eksportuj jako .ics", "export_ics": "Eksportuj jako .ics",
"copy_title": "Kopiuj tytuł", "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": { "detail": {
"add_note": "Dodaj notatkę...", "add_note": "Dodaj notatkę...",
@@ -2428,6 +2463,20 @@
"due_today": "Dzisiaj", "due_today": "Dzisiaj",
"due_tomorrow": "Jutro", "due_tomorrow": "Jutro",
"overdue": "Zaległe" "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": { "advanced_search": {
+55 -6
View File
@@ -424,7 +424,9 @@
"cancel_info": "O organizador cancelou este evento.", "cancel_info": "O organizador cancelou este evento.",
"event_updated": "Atualização #{sequence}", "event_updated": "Atualização #{sequence}",
"event_status_tentative": "Provisório", "event_status_tentative": "Provisório",
"event_status_cancelled": "Cancelado" "event_status_cancelled": "Cancelado",
"collapse": "Ocultar detalhes",
"expand": "Mostrar detalhes"
}, },
"previous": "Anterior", "previous": "Anterior",
"next": "Próximo", "next": "Próximo",
@@ -508,7 +510,10 @@
"message": "A sua mensagem menciona \"{keyword}\" mas nenhum arquivo está anexado. Enviar mesmo assim?", "message": "A sua mensagem menciona \"{keyword}\" mas nenhum arquivo está anexado. Enviar mesmo assim?",
"send_anyway": "Enviar mesmo assim", "send_anyway": "Enviar mesmo assim",
"back": "Voltar à edição" "back": "Voltar à edição"
} },
"add_link": "Adicionar link",
"link_url_prompt": "Insira a URL",
"sending": "Enviando..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmar", "confirm": "Confirmar",
@@ -801,6 +806,23 @@
"sound_desc": "Reproduzir um som para lembretes do calendário", "sound_desc": "Reproduzir um som para lembretes do calendário",
"invitation_parsing": "Analisar convites por e-mail", "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" "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": { "language_region": {
@@ -884,6 +906,13 @@
"label": "Selecionar automaticamente o endereço de resposta", "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" "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": { "show_preview": {
"label": "Mostrar Texto de Visualização", "label": "Mostrar Texto de Visualização",
"description": "Exibir visualização do e-mail na lista", "description": "Exibir visualização do e-mail na lista",
@@ -1333,7 +1362,9 @@
"no_address_books": "Nenhum catálogo de endereços encontrado", "no_address_books": "Nenhum catálogo de endereços encontrado",
"categories_title": "Categorias", "categories_title": "Categorias",
"categories_description": "Renomear categorias de contatos", "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": { "filters": {
"title": "Filtros de e-mail", "title": "Filtros de e-mail",
@@ -1732,7 +1763,7 @@
"use_address": "Usar Este Endereço", "use_address": "Usar Este Endereço",
"invalid_tag": "A tag deve conter apenas caracteres alfanuméricos e hífens", "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", "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": { "validation": {
"empty": "A tag não pode estar vazia", "empty": "A tag não pode estar vazia",
"too_long": "A tag deve ter no máximo {max} caracteres", "too_long": "A tag deve ter no máximo {max} caracteres",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Amanhã", "tomorrow_header": "Amanhã",
"export_ics": "Exportar como .ics", "export_ics": "Exportar como .ics",
"copy_title": "Copiar título", "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": { "detail": {
"add_note": "Adicionar uma nota...", "add_note": "Adicionar uma nota...",
@@ -2261,6 +2296,20 @@
"sat": "Sáb", "sat": "Sáb",
"sun": "Dom" "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": { "notifications": {
"event_created": "Evento criado", "event_created": "Evento criado",
"event_updated": "Evento atualizado", "event_updated": "Evento atualizado",
@@ -2738,4 +2787,4 @@
"custom": "Personalizado" "custom": "Personalizado"
} }
} }
} }
+54 -5
View File
@@ -426,7 +426,9 @@
"cancel_info": "Организатор отменил это событие.", "cancel_info": "Организатор отменил это событие.",
"event_updated": "Обновление №{sequence}", "event_updated": "Обновление №{sequence}",
"event_status_tentative": "Под вопросом", "event_status_tentative": "Под вопросом",
"event_status_cancelled": "Отменено" "event_status_cancelled": "Отменено",
"collapse": "Скрыть детали",
"expand": "Показать детали"
}, },
"send": "Отправить", "send": "Отправить",
"more": "ещё" "more": "ещё"
@@ -508,7 +510,10 @@
"message": "В вашем сообщении упоминается \"{keyword}\", но файл не прикреплён. Отправить всё равно?", "message": "В вашем сообщении упоминается \"{keyword}\", но файл не прикреплён. Отправить всё равно?",
"send_anyway": "Отправить всё равно", "send_anyway": "Отправить всё равно",
"back": "Вернуться к редактированию" "back": "Вернуться к редактированию"
} },
"add_link": "Добавить ссылку",
"link_url_prompt": "Введите URL",
"sending": "Отправка..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Подтвердить", "confirm": "Подтвердить",
@@ -801,6 +806,23 @@
"sound_desc": "Воспроизводить звуковой сигнал для напоминаний календаря", "sound_desc": "Воспроизводить звуковой сигнал для напоминаний календаря",
"invitation_parsing": "Распознавать приглашения по почте", "invitation_parsing": "Распознавать приглашения по почте",
"invitation_parsing_desc": "Обнаруживать приглашения календаря во вложениях писем и показывать действия календаря" "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": { "language_region": {
@@ -884,6 +906,13 @@
"label": "Автоматически выбирать адрес для ответа", "label": "Автоматически выбирать адрес для ответа",
"description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение" "description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение"
}, },
"sub_address_delimiter": {
"label": "Разделитель суб-адресов",
"description": "Символ, отделяющий имя пользователя от тега суб-адреса. Используйте разделитель, настроенный на вашем почтовом сервере (например, user{delimiter}tag@domain.com).",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "Свой…",
"custom_input_label": "Свой символ-разделитель"
},
"show_preview": { "show_preview": {
"label": "Показывать текст предпросмотра", "label": "Показывать текст предпросмотра",
"description": "Отображать предпросмотр письма в списке", "description": "Отображать предпросмотр письма в списке",
@@ -1333,7 +1362,9 @@
"no_address_books": "Адресные книги не найдены", "no_address_books": "Адресные книги не найдены",
"categories_title": "Категории", "categories_title": "Категории",
"categories_description": "Переименование категорий контактов", "categories_description": "Переименование категорий контактов",
"no_categories": "Категории не найдены" "no_categories": "Категории не найдены",
"group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов",
"group_by_letter_label": "Группировать по первой букве"
}, },
"filters": { "filters": {
"title": "Фильтры почты", "title": "Фильтры почты",
@@ -1732,7 +1763,7 @@
"use_address": "Использовать этот адрес", "use_address": "Использовать этот адрес",
"invalid_tag": "Тег должен содержать только буквы, цифры и дефисы", "invalid_tag": "Тег должен содержать только буквы, цифры и дефисы",
"tag_too_long": "Тег не должен превышать 30 символов", "tag_too_long": "Тег не должен превышать 30 символов",
"help_text": "Письма на адрес user+tag@domain.com будут приходить в ваш ящик", "help_text": "Письма на адрес user{delimiter}tag@domain.com будут приходить в ваш ящик",
"validation": { "validation": {
"empty": "Тег не может быть пустым", "empty": "Тег не может быть пустым",
"too_long": "Тег не должен превышать {max} символов", "too_long": "Тег не должен превышать {max} символов",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Завтра", "tomorrow_header": "Завтра",
"export_ics": "Экспорт в .ics", "export_ics": "Экспорт в .ics",
"copy_title": "Скопировать название", "copy_title": "Скопировать название",
"copy_link": "Скопировать ссылку встречи" "copy_link": "Скопировать ссылку встречи",
"go_to_today": "Перейти к сегодня",
"new_all_day_event": "Новое событие на весь день",
"new_event": "Новое событие",
"new_task": "Новая задача"
}, },
"detail": { "detail": {
"add_note": "Добавить заметку...", "add_note": "Добавить заметку...",
@@ -2428,6 +2463,20 @@
"due_today": "Сегодня", "due_today": "Сегодня",
"due_tomorrow": "Завтра", "due_tomorrow": "Завтра",
"overdue": "Просрочено" "overdue": "Просрочено"
},
"months": {
"jan": "янв.",
"feb": "февр.",
"mar": "март",
"apr": "апр.",
"may": "май",
"jun": "июнь",
"jul": "июль",
"aug": "авг.",
"sep": "сент.",
"oct": "окт.",
"nov": "нояб.",
"dec": "дек."
} }
}, },
"advanced_search": { "advanced_search": {
File diff suppressed because it is too large Load Diff
+54 -5
View File
@@ -426,7 +426,9 @@
"cancel_info": "Організатор скасував цю подію.", "cancel_info": "Організатор скасував цю подію.",
"event_updated": "Оновлення №{sequence}", "event_updated": "Оновлення №{sequence}",
"event_status_tentative": "Орієнтовний", "event_status_tentative": "Орієнтовний",
"event_status_cancelled": "Скасовано" "event_status_cancelled": "Скасовано",
"collapse": "Сховати деталі",
"expand": "Показати деталі"
}, },
"send": "Надіслати", "send": "Надіслати",
"more": "більше" "more": "більше"
@@ -508,7 +510,10 @@
"message": "У вашому повідомленні згадується \"{keyword}\", але файл не вкладено. Все одно надіслати?", "message": "У вашому повідомленні згадується \"{keyword}\", але файл не вкладено. Все одно надіслати?",
"send_anyway": "Все одно надішліть", "send_anyway": "Все одно надішліть",
"back": "Назад до редагування" "back": "Назад до редагування"
} },
"add_link": "Додати посилання",
"link_url_prompt": "Введіть URL",
"sending": "Надсилання..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Підтвердити", "confirm": "Підтвердити",
@@ -801,6 +806,23 @@
"sound_desc": "Відтворення звукового сповіщення для нагадувань календаря", "sound_desc": "Відтворення звукового сповіщення для нагадувань календаря",
"invitation_parsing": "Проаналізуйте запрошення електронною поштою", "invitation_parsing": "Проаналізуйте запрошення електронною поштою",
"invitation_parsing_desc": "Виявляти запрошення календаря у вкладеннях електронної пошти та показувати дії календаря" "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": { "language_region": {
@@ -889,6 +911,13 @@
"label": "Автоматичний вибір адреси для відповіді", "label": "Автоматичний вибір адреси для відповіді",
"description": "Під час відповіді автоматично змінюйте адресу відправника на особу, яка спочатку отримала повідомлення" "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": { "attachment_click_action": {
"label": "Вкладення Натисніть Дія", "label": "Вкладення Натисніть Дія",
"description": "Виберіть, чи клацання вкладеного файлу попередньо переглядає його чи негайно завантажує", "description": "Виберіть, чи клацання вкладеного файлу попередньо переглядає його чи негайно завантажує",
@@ -1333,7 +1362,9 @@
"no_address_books": "Адресних книг не знайдено", "no_address_books": "Адресних книг не знайдено",
"categories_title": "Категорії", "categories_title": "Категорії",
"categories_description": "Перейменувати категорії контактів", "categories_description": "Перейменувати категорії контактів",
"no_categories": "Категорії не знайдено" "no_categories": "Категорії не знайдено",
"group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів",
"group_by_letter_label": "Групувати за першою літерою"
}, },
"filters": { "filters": {
"title": "Фільтри електронної пошти", "title": "Фільтри електронної пошти",
@@ -1732,7 +1763,7 @@
"use_address": "Використовуйте цю адресу", "use_address": "Використовуйте цю адресу",
"invalid_tag": "Тег має бути лише буквено-цифровим і тире", "invalid_tag": "Тег має бути лише буквено-цифровим і тире",
"tag_too_long": "Тег має містити 30 символів або менше", "tag_too_long": "Тег має містити 30 символів або менше",
"help_text": "Електронні листи, надіслані на user+tag@domain.com, надходитимуть до вашої скриньки", "help_text": "Електронні листи, надіслані на user{delimiter}tag@domain.com, надходитимуть до вашої скриньки",
"validation": { "validation": {
"empty": "Тег не може бути порожнім", "empty": "Тег не може бути порожнім",
"too_long": "Тег має містити не більше {max} символів", "too_long": "Тег має містити не більше {max} символів",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "завтра", "tomorrow_header": "завтра",
"export_ics": "Експортувати як .ics", "export_ics": "Експортувати як .ics",
"copy_title": "Скопіювати назву", "copy_title": "Скопіювати назву",
"copy_link": "Скопіювати посилання зустрічі" "copy_link": "Скопіювати посилання зустрічі",
"go_to_today": "Перейти до сьогодні",
"new_all_day_event": "Нова подія на весь день",
"new_event": "Нова подія",
"new_task": "Нове завдання"
}, },
"detail": { "detail": {
"add_note": "Додати примітку...", "add_note": "Додати примітку...",
@@ -2428,6 +2463,20 @@
"due_today": "Сьогодні", "due_today": "Сьогодні",
"due_tomorrow": "завтра", "due_tomorrow": "завтра",
"overdue": "Прострочена" "overdue": "Прострочена"
},
"months": {
"jan": "січ.",
"feb": "лют.",
"mar": "бер.",
"apr": "квіт.",
"may": "трав.",
"jun": "черв.",
"jul": "лип.",
"aug": "серп.",
"sep": "верес.",
"oct": "жовт.",
"nov": "лист.",
"dec": "груд."
} }
}, },
"advanced_search": { "advanced_search": {
+54 -5
View File
@@ -426,7 +426,9 @@
"cancel_info": "组织者已取消此活动。", "cancel_info": "组织者已取消此活动。",
"event_updated": "更新 #{sequence}", "event_updated": "更新 #{sequence}",
"event_status_tentative": "暂定", "event_status_tentative": "暂定",
"event_status_cancelled": "已取消" "event_status_cancelled": "已取消",
"collapse": "隐藏详情",
"expand": "显示详情"
}, },
"send": "发送", "send": "发送",
"more": "更多" "more": "更多"
@@ -508,7 +510,10 @@
"message": "您的消息中提到了“{keyword}”,但未附加任何文件。仍然发送吗?", "message": "您的消息中提到了“{keyword}”,但未附加任何文件。仍然发送吗?",
"send_anyway": "仍然发送", "send_anyway": "仍然发送",
"back": "返回编辑" "back": "返回编辑"
} },
"add_link": "添加链接",
"link_url_prompt": "输入 URL",
"sending": "发送中..."
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "确认", "confirm": "确认",
@@ -801,6 +806,23 @@
"sound_desc": "播放日历提醒的音频提醒", "sound_desc": "播放日历提醒的音频提醒",
"invitation_parsing": "解析邮件邀请", "invitation_parsing": "解析邮件邀请",
"invitation_parsing_desc": "检测邮件附件中的日历邀请并显示日历操作" "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": { "language_region": {
@@ -889,6 +911,13 @@
"label": "自动选择回复地址", "label": "自动选择回复地址",
"description": "回复时自动将发件人地址切换为最初收到该邮件的身份" "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": { "attachment_click_action": {
"label": "附件单击操作", "label": "附件单击操作",
"description": "选择点击附件时是预览还是直接下载", "description": "选择点击附件时是预览还是直接下载",
@@ -1333,7 +1362,9 @@
"no_address_books": "未找到地址簿", "no_address_books": "未找到地址簿",
"categories_title": "类别", "categories_title": "类别",
"categories_description": "重命名联系人类别", "categories_description": "重命名联系人类别",
"no_categories": "未找到类别" "no_categories": "未找到类别",
"group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题",
"group_by_letter_label": "按首字母分组"
}, },
"filters": { "filters": {
"title": "邮件过滤器", "title": "邮件过滤器",
@@ -1732,7 +1763,7 @@
"use_address": "使用此地址", "use_address": "使用此地址",
"invalid_tag": "标签只能是字母数字和破折号", "invalid_tag": "标签只能是字母数字和破折号",
"tag_too_long": "标签不得超过 30 个字符", "tag_too_long": "标签不得超过 30 个字符",
"help_text": "发送到 user+tag@domain.com 的邮件将送达您的收件箱", "help_text": "发送到 user{delimiter}tag@domain.com 的邮件将送达您的收件箱",
"validation": { "validation": {
"empty": "标签不能为空", "empty": "标签不能为空",
"too_long": "标签不得超过 {max} 个字符", "too_long": "标签不得超过 {max} 个字符",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "明天", "tomorrow_header": "明天",
"export_ics": "导出为 .ics", "export_ics": "导出为 .ics",
"copy_title": "复制标题", "copy_title": "复制标题",
"copy_link": "复制会议链接" "copy_link": "复制会议链接",
"go_to_today": "前往今天",
"new_all_day_event": "新建全天事件",
"new_event": "新建事件",
"new_task": "新建任务"
}, },
"detail": { "detail": {
"add_note": "添加注释...", "add_note": "添加注释...",
@@ -2428,6 +2463,20 @@
"due_today": "今天", "due_today": "今天",
"due_tomorrow": "明天", "due_tomorrow": "明天",
"overdue": "逾期" "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": { "advanced_search": {
+59 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.5.2", "version": "1.5.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.5.2", "version": "1.5.4",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.13.24", "@tanstack/react-virtual": "^3.13.24",
@@ -14,6 +14,10 @@
"@tiptap/extension-image": "^3.22.4", "@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4", "@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^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-align": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4", "@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4", "@tiptap/extension-underline": "^3.22.4",
@@ -3639,6 +3643,59 @@
"@tiptap/core": "3.22.4" "@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": { "node_modules/@tiptap/extension-text": {
"version": "3.22.4", "version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.4.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.4.tgz",
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.5.2", "version": "1.5.4",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>", "author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
@@ -37,6 +37,10 @@
"@tiptap/extension-image": "^3.22.4", "@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4", "@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^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-align": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4", "@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4", "@tiptap/extension-underline": "^3.22.4",
+1 -1
View File
@@ -23,7 +23,7 @@ export async function proxy(request: NextRequest) {
? `'self' 'nonce-${nonce}' 'unsafe-eval' blob:` ? `'self' 'nonce-${nonce}' 'unsafe-eval' blob:`
: `'self' 'nonce-${nonce}' 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'"; const frameAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
+136 -4
View File
@@ -1,8 +1,14 @@
/* eslint-disable no-undef */ /* eslint-disable no-undef */
// Minimal service worker satisfies the PWA installability requirement // Bulwark service worker.
// without caching any assets. All requests fall through to the network, //
// so there is no risk of serving stale chunks after a deployment. // 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.addEventListener("install", () => {
self.skipWaiting(); self.skipWaiting();
@@ -12,5 +18,131 @@ self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim()); event.waitUntil(self.clients.claim());
}); });
// Network-only fetch handler no caching.
self.addEventListener("fetch", () => {}); 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