diff --git a/.env.dev.example b/.env.dev.example index 4ab88223..6d5d44d6 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -1,11 +1,11 @@ -# Bulwark Webmail — Development Configuration +# Bulwark Webmail - Development Configuration # Copy this file to .env.local to run with the built-in mock JMAP server. -# No external mail server required — great for UI development and testing. +# No external mail server required - great for UI development and testing. # # Usage: # cp .env.dev.example .env.local # npm run dev -# Open http://localhost:3000 — log in with any username/password. +# Open http://localhost:3000 - log in with any username/password. # ============================================================================= # Mock JMAP Server diff --git a/.env.example b/.env.example index d0b8874c..99f857fa 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Bulwark Webmail — Production Configuration +# Bulwark Webmail - Production Configuration # Copy this file to .env.local and fill in your values. # For development with the built-in mock server, see .env.dev.example instead. @@ -76,7 +76,7 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Directory for storing encrypted settings files (default: ./data/settings). # For Docker, the working directory is /app, so the default resolves to -# /app/data/settings — mount a persistent volume there: +# /app/data/settings - mount a persistent volume there: # volumes: # - bulwark-settings:/app/data/settings # SETTINGS_DATA_DIR=./data/settings diff --git a/CHANGELOG.md b/CHANGELOG.md index fc768557..1aebdf4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,14 @@ Thank you for your donations: - _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ - **One-time** + - [@mkorthaus-private](https://github.com/mkorthaus-private) - [@boris22100](https://github.com/boris22100) **Monthly** -- [@pr0ton11](https://github.com/pr0ton11) +- [@pr0ton11](https://github.com/pr0ton11) ### Features @@ -45,10 +45,12 @@ Thank you for your donations: Thank you for your donations: **One-time** + - [@boris22100](https://github.com/boris22100) - [@mkorthaus-private](https://github.com/mkorthaus-private) **Monthly** + - _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ ### Features @@ -78,9 +80,11 @@ Thank you for your donations: Thank you for your donations: **One-time** + - [@mkorthaus-private](https://github.com/mkorthaus-private) **Monthly** + - _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ ### Features @@ -476,7 +480,7 @@ Thank you for your donations: ### Fixes - **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19) -- **Move to folder**: Fix emails not actually moving on the server — JMAP response errors were silently ignored and shared account IDs were not resolved correctly +- **Move to folder**: Fix emails not actually moving on the server - JMAP response errors were silently ignored and shared account IDs were not resolved correctly - **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node ## 1.1.1 (2026-02-28) @@ -486,7 +490,7 @@ Thank you for your donations: - **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18) - **Email viewer**: Details toggle text is now properly translated (was hardcoded in English) - **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module -- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) — upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5 +- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) - upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5 ## 1.1.0 (2026-02-28) diff --git a/README.md b/README.md index 97877c55..0feff814 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,16 @@ Built with Next.js and the JMAP protocol. -Inbox — three-pane layout with sidebar, email list, and viewer (dark mode) +Inbox - three-pane layout with sidebar, email list, and viewer (dark mode) -**Mail** — Three-pane layout with sidebar, email list, and viewer +**Mail** - Three-pane layout with sidebar, email list, and viewer Calendar -**Calendar** — Month, week, day, and agenda views with event management +**Calendar** - Month, week, day, and agenda views with event management @@ -44,14 +44,14 @@ Built with Next.js and the JMAP protocol. Contacts -**Contacts** — Contact management with groups and vCard support +**Contacts** - Contact management with groups and vCard support File browser -**Files** — Cloud file browser with upload, preview, and folder navigation +**Files** - Cloud file browser with upload, preview, and folder navigation @@ -63,16 +63,16 @@ Built with Next.js and the JMAP protocol. -Inbox — light mode +Inbox - light mode -**Light mode** — Full theme support with intelligent color transformation +**Light mode** - Full theme support with intelligent color transformation Settings -**Settings** — Appearance, identities, filters, templates, and more +**Settings** - Appearance, identities, filters, templates, and more @@ -81,7 +81,7 @@ Built with Next.js and the JMAP protocol. Login page -**Login** — Configurable branding with OAuth2/OIDC and 2FA support +**Login** - Configurable branding with OAuth2/OIDC and 2FA support @@ -95,29 +95,29 @@ Built with Next.js and the JMAP protocol. ### Mail - **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding) -- **Threading** — Gmail-style inline expansion with thread navigation; optional conversation threading toggle -- **Unified mailbox** — view emails across all accounts in a single list +- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle +- **Unified mailbox** - view emails across all accounts in a single list - **Draft auto-save** with discard confirmation; full draft editing with identity preservation -- **Attachments** — upload, download, and inline preview; attachment-keyword warning if a file is forgotten -- **Search** — full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions -- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag -- **Archive modes** — archive directly or organize archived mail by year or month +- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten +- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions +- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag +- **Archive modes** - archive directly or organize archived mail by year or month - **Print** emails directly from the viewer - **Answered/forwarded status icons** in email list and thread views - **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment - **Star/unstar** with configurable mark-as-read delay - **Virtual scrolling** for large mailboxes - **Quick reply** from the viewer -- **Hover actions** — configurable quick-action buttons on email rows with customizable placement -- **Sender avatars** — favicon-based with negative caching for performance +- **Hover actions** - configurable quick-action buttons on email rows with customizable placement +- **Sender avatars** - favicon-based with negative caching for performance - **Recipient popover** for quick contact interaction - **Plain text composer mode** and auto-select reply identity - **Reply-to addresses** support in the composer -- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically +- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically - **message/rfc822 unwrapping** for embedded messages -- **Folder management** — create, rename, delete folders with icon picker and subfolder support -- **Tag counts** — unread and total counts displayed in sidebar -- **Browser history sync** — back/forward navigation mirrors mail view state +- **Folder management** - create, rename, delete folders with icon picker and subfolder support +- **Tag counts** - unread and total counts displayed in sidebar +- **Browser history sync** - back/forward navigation mirrors mail view state ### Calendar @@ -125,13 +125,13 @@ Built with Next.js and the JMAP protocol. - **Event hover preview** popover with configurable details - **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap) - **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion -- **Participant scheduling** — iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment -- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import +- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment +- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import - **iCalendar import** with preview, bulk create, and UID deduplication - **iCal / webcal subscriptions** with editing and batch import -- **Birthday calendar** — auto-generated from contacts -- **Virtual locations** — video conference URLs as first-class event fields -- **Task management** — create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird) +- **Birthday calendar** - auto-generated from contacts +- **Virtual locations** - video conference URLs as first-class event fields +- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird) - **Shared calendars** with visual grouping in the sidebar - **CalDAV discovery** with automatic calendar home resolution for multi-account setups - **Week numbers** in mini-calendar sidebar @@ -141,66 +141,66 @@ Built with Next.js and the JMAP protocol. ### Contacts - **Contact management** with JMAP sync (RFC 9553/9610) and local fallback -- **Multiple address books** — create, rename, drag-and-drop between books, with editor picker in contact form +- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form - **Collapsible sidebar** with address book grouping and bulk operations - **Contact groups** with group expansion and member management - **vCard import/export** (RFC 6350) with duplicate detection - **Trusted senders** stored in a dedicated JMAP address book - **Autocomplete** in composer (To/Cc/Bcc) -- **Bulk operations** — multi-select, delete, group add, export +- **Bulk operations** - multi-select, delete, group add, export ### Filters & Automation - **Server-side email filters** via JMAP Sieve Scripts (RFC 9661) -- **Visual rule builder** — conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view -- **External rule preservation** — rules authored in other clients are displayed and preserved +- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view +- **External rule preservation** - rules authored in other clients are displayed and preserved - **Raw Sieve editor** with syntax validation - **Vacation responder** with date range scheduling and sidebar indicator -- **Email templates** — reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.) +- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.) ### Files - **File browser** with JMAP FileNode cloud storage (Stalwart native) - **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering) -- **Dynamic upload limits** — respects the server-configured maximum upload size +- **Dynamic upload limits** - respects the server-configured maximum upload size - **Folder navigation** with breadcrumb path and tree sidebar - **Grid and list views** with sorting by name, size, or date -- **Clipboard operations** — cut, copy, paste, duplicate files +- **Clipboard operations** - cut, copy, paste, duplicate files - **File preview** for images, text, audio, video, and more - **Favorites and recent files** for quick access -- **Bulk operations** — multi-select, delete, move, download +- **Bulk operations** - multi-select, delete, move, download ### Security & Privacy -- **External content blocked** by default — trusted senders list for auto-load +- **External content blocked** by default - trusted senders list for auto-load - **HTML sanitization** via DOMPurify with XSS prevention -- **S/MIME** — manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation +- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation - **SPF/DKIM/DMARC** status indicators - **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments - **TOTP two-factor authentication** -- **Account security panel** — manage passwords and 2FA via Stalwart admin API -- **"Remember me"** — AES-256-GCM encrypted httpOnly cookie (opt-in) -- **Security headers** — enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention -- **Plugin hardening** — dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure) +- **Account security panel** - manage passwords and 2FA via Stalwart admin API +- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in) +- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention +- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure) - **Newsletter unsubscribe** (RFC 2369) ### Interface -- **Three-pane layout** — sidebar, email list, viewer with resizable columns +- **Three-pane layout** - sidebar, email list, viewer with resizable columns - **Dark and light themes** with intelligent email color transformation - **Always-light email rendering** option for problematic HTML messages in dark theme -- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support -- **Keyboard shortcuts** — full navigation without a mouse +- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support +- **Keyboard shortcuts** - full navigation without a mouse - **Drag-and-drop** email organization between mailboxes and tag assignment -- **Interactive guided tour** — onboarding walkthrough for new users +- **Interactive guided tour** - onboarding walkthrough for new users - **Right-click context menus**, toast notifications with undo, form validation with shake feedback - **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding -- **Sidebar apps** — pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes -- **Settings sync** — preferences synchronized with the server (encrypted) +- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes +- **Settings sync** - preferences synchronized with the server (encrypted) - **Storage quota** display - **Version badge** in settings - **Focused mode** with proper viewport bounds -- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions +- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions ### Internationalization @@ -212,32 +212,32 @@ Automatic browser detection with persistent preference. Configurable locale URL - **Multiple sender identities** with per-identity signatures - **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes -- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions +- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions - **Identity badges** in viewer and email list ### Multi-Account - **Up to 5 simultaneous accounts** with instant switching and per-account session persistence - **Account switcher** with connection status, default account selection, and per-account logout -- **Per-account settings** — encrypted settings storage with server-side sync +- **Per-account settings** - encrypted settings storage with server-side sync - **Shared folders** across accounts -- **Custom JMAP server endpoints** — optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) +- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) ### Admin & Extensibility -- **Stalwart admin dashboard** — sidebar access with reorganized dashboard and dedicated policy sections -- **Plugin system** — schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement -- **Themes** — upload, enforce, and manage admin-controlled themes with ZIP bundles -- **Extension marketplace** — browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`) -- **Bundled plugins** — Jitsi Meet calendar integration +- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections +- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement +- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles +- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`) +- **Bundled plugins** - Jitsi Meet calendar integration ### Operations -- **Progressive Web App (PWA)** — installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors) -- **Automatic update check** — server logs when a newer release is available +- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors) +- **Automatic update check** - server logs when a newer release is available - **Logging categories** with `text` or `json` formats for log aggregation -- **Docker images** — release (`main`) and development (`dev`) channels on GHCR -- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required +- **Docker images** - release (`main`) and development (`dev`) channels on GHCR +- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required --- @@ -255,7 +255,7 @@ Or with Docker Compose: ```bash cp .env.example .env.local -# Edit .env.local — set JMAP_SERVER_URL +# Edit .env.local - set JMAP_SERVER_URL docker compose up -d ``` @@ -266,7 +266,7 @@ git clone https://github.com/bulwarkmail/webmail.git cd webmail npm install cp .env.example .env.local -# Edit .env.local — set JMAP_SERVER_URL +# Edit .env.local - set JMAP_SERVER_URL npm run build && npm start ``` @@ -290,7 +290,7 @@ JMAP_SERVER_URL=https://mail.example.com APP_NAME=My Webmail ``` -All variables are **runtime** — Docker deployments can be configured without rebuilding. +All variables are **runtime** - Docker deployments can be configured without rebuilding.
Server Listen Address @@ -424,7 +424,7 @@ LOG_LEVEL=info # "error", "warn", "info", "debug" ## Why Stalwart? -[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** — not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies. +[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies. ## Contributing diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx index e5c48f22..8032d75a 100644 --- a/app/[locale]/auth/callback/page.tsx +++ b/app/[locale]/auth/callback/page.tsx @@ -35,7 +35,7 @@ function OAuthCallbackInner() { const savedState = sessionStorage.getItem("oauth_state"); if (savedState) { - // Classic flow — sessionStorage has the PKCE state (same-tab OAuth) + // Classic flow - sessionStorage has the PKCE state (same-tab OAuth) if (!state || state !== savedState) { setError("invalid_state"); return; @@ -76,7 +76,7 @@ function OAuthCallbackInner() { setError("token_exchange_failed"); }); } else if (state) { - // Server-side SSO flow — state was stored in encrypted httpOnly cookie + // Server-side SSO flow - state was stored in encrypted httpOnly cookie const ssoPrefix = getPathPrefix(params.locale as string); loginWithServerSso(code, state) .then((success) => { diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 5b79c442..1739b766 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -255,7 +255,7 @@ export default function LoginPage() { try { window.top!.location.href = authorize_url; } catch { - // Cross-origin restriction — fall back to current frame + // Cross-origin restriction - fall back to current frame window.location.href = authorize_url; } } else { @@ -759,7 +759,7 @@ export default function LoginPage() { )}

- Dev mode — logging in as dev@localhost + Dev mode - logging in as dev@localhost

) : oauthOnly ? ( diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index af6e1f57..f389bc99 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -256,7 +256,7 @@ export default function Home() { // Restore conversation thread (mobile only). We can clear it directly, // but reopening requires the thread group; if the user pressed forward - // to return to a thread, we silently skip — back navigation always works. + // to return to a thread, we silently skip - back navigation always works. if ((state.threadId ?? null) !== ctx.conversationThreadId) { if (state.threadId === null) { setConversationThread(null); @@ -826,7 +826,7 @@ export default function Home() { if (archiveMode === 'year') { await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id); } else { - // archiveMode === 'month' — find or create month subfolder under year + // archiveMode === 'month' - find or create month subfolder under year const yearId = yearMailbox.originalId || yearMailbox.id; let monthMailbox = mailboxes.find( m => m.name === month && m.parentId === yearId @@ -1255,7 +1255,7 @@ export default function Home() { // Handle back navigation from viewer on mobile. // Delegate to the browser history stack so this button is equivalent to - // the OS back button / mouse back button — popstate then restores the + // the OS back button / mouse back button - popstate then restores the // previous snapshot via handleNavRestore. The viewer is only reachable // from a state that pushed history, so back() always lands on an app entry. const handleMobileBack = () => { diff --git a/app/admin/logs/page.tsx b/app/admin/logs/page.tsx index 9a8d6462..4ff02b21 100644 --- a/app/admin/logs/page.tsx +++ b/app/admin/logs/page.tsx @@ -139,7 +139,7 @@ export default function AdminLogsPage() { } function formatDetail(detail: Record): string { - if (!detail || Object.keys(detail).length === 0) return '—'; + if (!detail || Object.keys(detail).length === 0) return '-'; if (detail.reason) return String(detail.reason); if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`; if (detail.changes && Array.isArray(detail.changes)) { diff --git a/app/admin/marketplace/page.tsx b/app/admin/marketplace/page.tsx index 1a547953..5c1cc481 100644 --- a/app/admin/marketplace/page.tsx +++ b/app/admin/marketplace/page.tsx @@ -117,7 +117,7 @@ export default function AdminMarketplacePage() { setMessage({ type: 'error', text: data.error || 'Installation failed' }); } } catch { - setMessage({ type: 'error', text: 'Installation failed — network error' }); + setMessage({ type: 'error', text: 'Installation failed - network error' }); } finally { setInstalling(null); } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index db5808e0..5fb6d75e 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -99,8 +99,8 @@ export default function AdminDashboardPage() { setWarnings(w); } - const jmapUrl = config?.jmapServerUrl || '—'; - const jmapHostname = jmapUrl !== '—' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—'; + const jmapUrl = config?.jmapServerUrl || '-'; + const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-'; return (
@@ -127,9 +127,9 @@ export default function AdminDashboardPage() { {/* Server Info */} - {config?.appName || '—'} + {config?.appName || '-'} - + {jmapHostname} diff --git a/app/admin/plugins/[id]/page.tsx b/app/admin/plugins/[id]/page.tsx index 1411b444..181f3fcb 100644 --- a/app/admin/plugins/[id]/page.tsx +++ b/app/admin/plugins/[id]/page.tsx @@ -231,7 +231,7 @@ export default function PluginConfigPage() { onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))} className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" > - + {field.options.map(opt => ( ))} diff --git a/app/api/admin/audit/route.ts b/app/api/admin/audit/route.ts index f48e30c4..7e68903b 100644 --- a/app/api/admin/audit/route.ts +++ b/app/api/admin/audit/route.ts @@ -4,7 +4,7 @@ import { readAuditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; /** - * GET /api/admin/audit — Get paginated audit log entries (admin-protected) + * GET /api/admin/audit - Get paginated audit log entries (admin-protected) */ export async function GET(request: NextRequest) { try { diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 509fa15d..803edd08 100644 --- a/app/api/admin/auth/route.ts +++ b/app/api/admin/auth/route.ts @@ -30,7 +30,7 @@ async function checkStalwartAdmin(request: NextRequest): Promise { } /** - * POST /api/admin/auth — Login + * POST /api/admin/auth - Login */ export async function POST(request: NextRequest) { try { @@ -92,7 +92,7 @@ export async function POST(request: NextRequest) { } /** - * GET /api/admin/auth — Check session status + * GET /api/admin/auth - Check session status * Also checks if the user is a Stalwart admin (admin panel enabled even without password). */ export async function GET(request: NextRequest) { @@ -136,7 +136,7 @@ export async function GET(request: NextRequest) { } /** - * DELETE /api/admin/auth — Logout + * DELETE /api/admin/auth - Logout */ export async function DELETE(request: NextRequest) { try { diff --git a/app/api/admin/branding/[filename]/route.ts b/app/api/admin/branding/[filename]/route.ts index 62b72b92..af8ab53a 100644 --- a/app/api/admin/branding/[filename]/route.ts +++ b/app/api/admin/branding/[filename]/route.ts @@ -14,7 +14,7 @@ const MIME_TYPES: Record = { }; /** - * GET /api/admin/branding/[filename] — Serve uploaded branding images + * GET /api/admin/branding/[filename] - Serve uploaded branding images * * This endpoint is public (no admin auth) so browsers can load images. * Only files in the branding directory are served; directory traversal is prevented. diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts index e97b0ae0..a538e565 100644 --- a/app/api/admin/branding/route.ts +++ b/app/api/admin/branding/route.ts @@ -33,7 +33,7 @@ function sanitizeFilename(name: string): string { } /** - * POST /api/admin/branding — Upload a branding image file + * POST /api/admin/branding - Upload a branding image file * * Expects multipart/form-data with: * - file: the image file @@ -105,7 +105,7 @@ export async function POST(request: NextRequest) { } /** - * DELETE /api/admin/branding — Remove an uploaded branding file + * DELETE /api/admin/branding - Remove an uploaded branding file * * Expects JSON body: { slot: string } */ diff --git a/app/api/admin/change-password/route.ts b/app/api/admin/change-password/route.ts index 2247a6c9..30b8b33e 100644 --- a/app/api/admin/change-password/route.ts +++ b/app/api/admin/change-password/route.ts @@ -5,7 +5,7 @@ import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; /** - * POST /api/admin/change-password — Change admin password + * POST /api/admin/change-password - Change admin password */ export async function POST(request: NextRequest) { try { diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index 16db5135..cd9a58bb 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP } from '@/lib/admin/types'; import { logger } from '@/lib/logger'; /** - * GET /api/admin/config — Get full config with sources (admin-protected) + * GET /api/admin/config - Get full config with sources (admin-protected) */ export async function GET() { try { @@ -26,7 +26,7 @@ export async function GET() { } /** - * PATCH /api/admin/config — Update config overrides (admin-protected) + * PATCH /api/admin/config - Update config overrides (admin-protected) */ export async function PATCH(request: NextRequest) { try { @@ -64,7 +64,7 @@ export async function PATCH(request: NextRequest) { } /** - * DELETE /api/admin/config — Remove admin override for a key (revert to env/default) + * DELETE /api/admin/config - Remove admin override for a key (revert to env/default) */ export async function DELETE(request: NextRequest) { try { diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index e57b5868..341e9627 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -17,7 +17,7 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001'; /** - * GET /api/admin/marketplace — Search/browse the extension directory + * GET /api/admin/marketplace - Search/browse the extension directory * Proxies to the extension directory API */ export async function GET(request: NextRequest) { @@ -75,7 +75,7 @@ export async function GET(request: NextRequest) { } /** - * POST /api/admin/marketplace — Install an extension from the directory + * POST /api/admin/marketplace - Install an extension from the directory * Body: { slug: string, version: string, type: 'plugin' | 'theme' } */ export async function POST(request: NextRequest) { diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts index e5d0c4b9..44923d20 100644 --- a/app/api/admin/plugins/[id]/bundle/route.ts +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; /** - * GET /api/admin/plugins/[id]/bundle — Serve plugin JS bundle + * GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle * * Public endpoint so the client-side plugin loader can fetch bundles. * Only serves plugins that exist in the registry and are enabled. diff --git a/app/api/admin/plugins/[id]/config/route.ts b/app/api/admin/plugins/[id]/config/route.ts index bd6e5ef2..7260956f 100644 --- a/app/api/admin/plugins/[id]/config/route.ts +++ b/app/api/admin/plugins/[id]/config/route.ts @@ -4,7 +4,7 @@ import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/a import { requireAdminAuth } from '@/lib/admin/session'; /** - * GET /api/admin/plugins/[id]/config — Read all config for a plugin + * GET /api/admin/plugins/[id]/config - Read all config for a plugin * * Returns the full config object for admin-configured plugin settings. * This endpoint is accessible from the client-side plugin API. @@ -35,7 +35,7 @@ export async function GET( } /** - * PUT /api/admin/plugins/[id]/config — Set a config key + * PUT /api/admin/plugins/[id]/config - Set a config key * * Body: { key: string, value: unknown } * Requires admin authentication (checked via admin session). @@ -83,7 +83,7 @@ export async function PUT( } /** - * DELETE /api/admin/plugins/[id]/config — Delete a config key + * DELETE /api/admin/plugins/[id]/config - Delete a config key * * Body: { key: string } */ diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index b1137346..9f2f46ed 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -23,7 +23,7 @@ const SUSPICIOUS_JS_PATTERNS = [ ]; /** - * GET /api/admin/plugins — List all admin-managed plugins + * GET /api/admin/plugins - List all admin-managed plugins */ export async function GET() { try { @@ -41,7 +41,7 @@ export async function GET() { } /** - * POST /api/admin/plugins — Upload and install a plugin ZIP + * POST /api/admin/plugins - Upload and install a plugin ZIP */ export async function POST(request: NextRequest) { try { @@ -181,7 +181,7 @@ export async function POST(request: NextRequest) { } /** - * PATCH /api/admin/plugins — Update plugin metadata (enable/disable) + * PATCH /api/admin/plugins - Update plugin metadata (enable/disable) * Body: { id: string, enabled: boolean } */ export async function PATCH(request: NextRequest) { @@ -218,7 +218,7 @@ export async function PATCH(request: NextRequest) { } /** - * DELETE /api/admin/plugins — Remove a plugin + * DELETE /api/admin/plugins - Remove a plugin * Body: { id: string } */ export async function DELETE(request: NextRequest) { diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 4a9513cc..4d2f0529 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -6,7 +6,7 @@ import { logger } from '@/lib/logger'; import type { SettingsPolicy } from '@/lib/admin/types'; /** - * GET /api/admin/policy — Get settings policy (NOT admin-protected — users read this) + * GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this) */ export async function GET() { try { @@ -22,7 +22,7 @@ export async function GET() { } /** - * PUT /api/admin/policy — Update settings policy (admin-protected) + * PUT /api/admin/policy - Update settings policy (admin-protected) */ export async function PUT(request: NextRequest) { try { diff --git a/app/api/admin/stalwart-check/route.ts b/app/api/admin/stalwart-check/route.ts index fd067e26..4c2c3d6a 100644 --- a/app/api/admin/stalwart-check/route.ts +++ b/app/api/admin/stalwart-check/route.ts @@ -5,7 +5,7 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials'; /** * GET /api/admin/stalwart-check * Check if the currently logged-in user is a Stalwart admin. - * Probes the admin-only principal-list endpoint — if the user can access it, they're an admin. + * Probes the admin-only principal-list endpoint - if the user can access it, they're an admin. */ export async function GET(request: NextRequest) { try { diff --git a/app/api/admin/themes/[id]/css/route.ts b/app/api/admin/themes/[id]/css/route.ts index cf435a67..347ccfaa 100644 --- a/app/api/admin/themes/[id]/css/route.ts +++ b/app/api/admin/themes/[id]/css/route.ts @@ -3,7 +3,7 @@ import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry'; import { logger } from '@/lib/logger'; /** - * GET /api/admin/themes/[id]/css — Serve theme CSS to clients + * GET /api/admin/themes/[id]/css - Serve theme CSS to clients */ export async function GET( _request: NextRequest, diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index a0db4731..d585ca89 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -14,7 +14,7 @@ import { MAX_THEME_SIZE } from '@/lib/plugin-types'; import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; /** - * GET /api/admin/themes — List all admin-managed themes + * GET /api/admin/themes - List all admin-managed themes */ export async function GET() { try { @@ -32,7 +32,7 @@ export async function GET() { } /** - * POST /api/admin/themes — Upload and install a theme ZIP + * POST /api/admin/themes - Upload and install a theme ZIP */ export async function POST(request: NextRequest) { try { @@ -151,7 +151,7 @@ export async function POST(request: NextRequest) { } /** - * PATCH /api/admin/themes — Update theme metadata (enable/disable) + * PATCH /api/admin/themes - Update theme metadata (enable/disable) * Body: { id: string, enabled: boolean } */ export async function PATCH(request: NextRequest) { @@ -188,7 +188,7 @@ export async function PATCH(request: NextRequest) { } /** - * DELETE /api/admin/themes — Remove a theme + * DELETE /api/admin/themes - Remove a theme * Body: { id: string } */ export async function DELETE(request: NextRequest) { diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 273afaed..06d11230 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -95,7 +95,7 @@ export async function GET(request: NextRequest) { } /** - * PUT — retrieve full credentials (including password) for session restoration. + * PUT - retrieve full credentials (including password) for session restoration. * Protected by multiple Sec-Fetch-* headers to ensure only same-origin * browser fetch() requests succeed. Non-browser clients cannot forge these. */ diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index b16e6631..39ef5bac 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -66,7 +66,7 @@ async function findTokenEndpoint(serverUrl: string): Promise { return url; } } catch { - // Network error — endpoint not reachable + // Network error - endpoint not reachable } } diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index b55f05d9..4c2dc54e 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; * Enabled only when DEV_MOCK_JMAP=true. Provides realistic dummy data * so the UI can be developed without a real JMAP mail server. * - * Accepts any username/password — no real authentication. + * Accepts any username/password - no real authentication. */ const ACCOUNT_ID = 'dev-account-001'; @@ -114,8 +114,8 @@ const emails: MockEmail[] = [ textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }], htmlBody: [{ partId: 'p2', blobId: 'blob-002', size: 420, type: 'text/html' }], bodyValues: { - p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI — all data here is mock data.\n\nBeste Grüße,\nSophie' }, - p2: { value: '

Hallo!

This is a sample email to help you get started with the Bulwark Webmail development environment.

Feel free to explore the UI — all data here is mock data.

Beste Grüße,
Sophie

' }, + p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' }, + p2: { value: '

Hallo!

This is a sample email to help you get started with the Bulwark Webmail development environment.

Feel free to explore the UI - all data here is mock data.

Beste Grüße,
Sophie

' }, }, }, { @@ -123,7 +123,7 @@ const emails: MockEmail[] = [ from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }], - subject: 'Project Update — Q1 Review', + subject: 'Project Update - Q1 Review', preview: 'Salut team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.', hasAttachment: true, textBody: [{ partId: 'p1', blobId: 'blob-003', size: 640, type: 'text/plain' }], @@ -160,7 +160,7 @@ const emails: MockEmail[] = [ textBody: [{ partId: 'p1', blobId: 'blob-007', size: 350, type: 'text/plain' }], htmlBody: [{ partId: 'p2', blobId: 'blob-008', size: 500, type: 'text/html' }], bodyValues: { - p1: { value: 'A new issue has been opened by @contributor.\n\nTitle: Add dark mode toggle\n\nIt would be great to have a dark mode toggle in the settings panel. Currently users have to rely on system preferences.\n\n—\nReply to this email directly or view it on GitHub.' }, + p1: { value: 'A new issue has been opened by @contributor.\n\nTitle: Add dark mode toggle\n\nIt would be great to have a dark mode toggle in the settings panel. Currently users have to rely on system preferences.\n\n-\nReply to this email directly or view it on GitHub.' }, p2: { value: '

A new issue has been opened by @contributor.

Add dark mode toggle

It would be great to have a dark mode toggle in the settings panel. Currently users have to rely on system preferences.


Reply to this email directly or view it on GitHub.

' }, }, }, @@ -183,13 +183,13 @@ const emails: MockEmail[] = [ id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:purple': true }, size: 18200, receivedAt: daysAgo(0), from: [{ name: 'Launchpad Weekly', email: 'hello@launchpad.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Launchpad Weekly #47 — The future of the open web', + subject: 'Launchpad Weekly #47 - The future of the open web', preview: 'This week: WebAssembly Components hit 1.0, a deep dive into privacy-first analytics, and 5 tools we can\'t stop using.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-020', size: 1200, type: 'text/plain' }], htmlBody: [{ partId: 'p2', blobId: 'blob-021', size: 16000, type: 'text/html' }], bodyValues: { - p1: { value: 'LAUNCHPAD WEEKLY #47\nThe future of the open web\n\nWebAssembly Components hit 1.0\nThe Component Model spec has reached 1.0, unlocking language-agnostic modules that run anywhere.\n\nDeep dive: Privacy-first analytics\nCookie banners are on their way out. We explore the next generation of analytics tools that respect user privacy by design.\n\n5 tools we can\'t stop using\n1. Vite 7 — lightning-fast builds\n2. Biome — unified lint + format\n3. Deno 4 — batteries included runtime\n4. TailwindCSS 4 — zero config styling\n5. Playwright — end-to-end testing\n\nYou received this because you subscribed at launchpad.example.\nUnsubscribe: https://launchpad.example/unsubscribe' }, + p1: { value: 'LAUNCHPAD WEEKLY #47\nThe future of the open web\n\nWebAssembly Components hit 1.0\nThe Component Model spec has reached 1.0, unlocking language-agnostic modules that run anywhere.\n\nDeep dive: Privacy-first analytics\nCookie banners are on their way out. We explore the next generation of analytics tools that respect user privacy by design.\n\n5 tools we can\'t stop using\n1. Vite 7 - lightning-fast builds\n2. Biome - unified lint + format\n3. Deno 4 - batteries included runtime\n4. TailwindCSS 4 - zero config styling\n5. Playwright - end-to-end testing\n\nYou received this because you subscribed at launchpad.example.\nUnsubscribe: https://launchpad.example/unsubscribe' }, p2: { value: '
◆ LAUNCHPAD WEEKLY
ISSUE #47 • MARCH 2026

The future of the open web

WebAssembly Components hit 1.0, privacy-first analytics take center stage, and 5 tools we can’t stop using.

FEATURED

WebAssembly Components hit 1.0

The Component Model specification has officially reached 1.0, unlocking language-agnostic modules that compose and run anywhere — from the browser to the edge. This is a watershed moment for portable computing.

Read the deep dive →
ANALYSIS

Deep dive: Privacy-first analytics

Cookie banners are on their way out. We explore the next generation of analytics platforms that respect user privacy by design — no consent dialogs required. From server-side aggregation to differential privacy, the landscape is shifting fast.

Explore the guide →
TOOLBOX

5 tools we can’t stop using

1Vite 7
Lightning-fast builds with zero-config ESM support.
2Biome
Unified linting and formatting in a single blazing-fast tool.
3Deno 4
Batteries-included runtime with native TypeScript & npm compat.
4TailwindCSS 4
Zero-config utility-first CSS that just works.
5Playwright
Reliable end-to-end testing across every browser.

You received this because you subscribed at launchpad.example

UnsubscribeManage preferencesView in browser

' }, }, }, @@ -199,7 +199,7 @@ const emails: MockEmail[] = [ from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], - subject: 'Sprint planning — next week priorities', + subject: 'Sprint planning - next week priorities', preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-030', size: 450, type: 'text/plain' }], @@ -213,7 +213,7 @@ const emails: MockEmail[] = [ id: 'email-015', threadId: 'thread-014', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 5800, receivedAt: hoursAgo(5), from: [{ name: 'Booking.com', email: 'automated@booking.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Prenotazione Confermata — Lake Como, Mar 28–30', + subject: 'Prenotazione Confermata - Lake Como, Mar 28–30', preview: 'Your reservation has been confirmed. Check-in: March 28, 2026. Check-out: March 30, 2026.', hasAttachment: true, textBody: [{ partId: 'p1', blobId: 'blob-032', size: 500, type: 'text/plain' }], @@ -257,7 +257,7 @@ const emails: MockEmail[] = [ id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 4700, receivedAt: daysAgo(1), from: [{ name: 'Hetzner Cloud', email: 'billing@hetzner.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Your Hetzner invoice is available — February 2026', + subject: 'Your Hetzner invoice is available - February 2026', preview: 'Your Hetzner Cloud invoice for February 2026 is now available. Total: €1.337,42.', hasAttachment: true, textBody: [{ partId: 'p1', blobId: 'blob-037', size: 400, type: 'text/plain' }], @@ -273,7 +273,7 @@ const emails: MockEmail[] = [ id: 'email-019', threadId: 'thread-018', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 3600, receivedAt: daysAgo(2), from: [{ name: 'Astrid van der Berg', email: 'astrid@berglabs.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], cc: [], - subject: 'Meeting notes — API design review', + subject: 'Meeting notes - API design review', preview: 'Here are the notes from today\'s API design review session. Key decisions: REST for public API, gRPC for internal services.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-038', size: 600, type: 'text/plain' }], @@ -287,13 +287,13 @@ const emails: MockEmail[] = [ id: 'email-020', threadId: 'thread-019', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 5200, receivedAt: daysAgo(3), from: [{ name: 'Jacques Lefèvre', email: 'jacques@lefevre-avocats.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Re: Partnership agreement — feedback', + subject: 'Re: Partnership agreement - feedback', preview: 'I reviewed the draft agreement. A few points need clarification around intellectual property clauses.', hasAttachment: true, textBody: [{ partId: 'p1', blobId: 'blob-040', size: 700, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Bonjour,\n\nI reviewed the draft partnership agreement. Overall it looks good, but a few points need clarification:\n\n1. Article 4.2 — IP ownership clause is ambiguous. Should specify that pre-existing IP remains with original owner.\n2. Article 7.1 — Non-compete period of 24 months may be too restrictive under EU law. Suggest 12 months.\n3. Article 9.3 — Liability cap should be tied to contract value, not a fixed amount.\n\nI\'ve marked up the document with detailed comments (attached).\n\nLet me know when you\'d like to discuss.\n\nBien cordialement,\nJacques Lefèvre\nLefèvre & Associés' }, + p1: { value: 'Bonjour,\n\nI reviewed the draft partnership agreement. Overall it looks good, but a few points need clarification:\n\n1. Article 4.2 - IP ownership clause is ambiguous. Should specify that pre-existing IP remains with original owner.\n2. Article 7.1 - Non-compete period of 24 months may be too restrictive under EU law. Suggest 12 months.\n3. Article 9.3 - Liability cap should be tied to contract value, not a fixed amount.\n\nI\'ve marked up the document with detailed comments (attached).\n\nLet me know when you\'d like to discuss.\n\nBien cordialement,\nJacques Lefèvre\nLefèvre & Associés' }, }, attachments: [ { partId: 'att4', blobId: 'blob-att-004', size: 45000, name: 'Contrat-de-Partenariat-Annoté.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, @@ -304,13 +304,13 @@ const emails: MockEmail[] = [ from: [{ name: 'Katrin Bauer', email: 'katrin.bauer@charite.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }, { name: 'Chiara Rossi', email: 'chiara@rossi.example' }], - subject: 'Team outing — voting on activity', + subject: 'Team outing - voting on activity', preview: 'Hey everyone! Time to vote on next month\'s team outing. Options: Biergarten, Eurovision watch party, or cooking class.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-041', size: 300, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hallo zusammen!\n\nTime to vote on next month\'s team outing. Please reply with your preference:\n\nA) Biergarten evening + Bretzel buffet\nB) Eurovision watch party (with scorecards!)\nC) Cooking class (Italian cuisine — pasta fresca)\n\nVoting closes Friday. Most votes wins!\n\nKatrin' }, + p1: { value: 'Hallo zusammen!\n\nTime to vote on next month\'s team outing. Please reply with your preference:\n\nA) Biergarten evening + Bretzel buffet\nB) Eurovision watch party (with scorecards!)\nC) Cooking class (Italian cuisine - pasta fresca)\n\nVoting closes Friday. Most votes wins!\n\nKatrin' }, }, }, { @@ -323,7 +323,7 @@ const emails: MockEmail[] = [ textBody: [{ partId: 'p1', blobId: 'blob-042', size: 280, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Your pull request has been merged.\n\nRepository: vcard-parser\nPR #89: Add support for FBURL property\nMerged by: @maintainer\nBranch: feature/fburl → main\n\nCommits merged:\n- feat: parse FBURL property from vCard 4.0\n- test: add FBURL round-trip tests\n- docs: update README with FBURL example\n\n—\nReply to this email directly or view it on GitHub.' }, + p1: { value: 'Your pull request has been merged.\n\nRepository: vcard-parser\nPR #89: Add support for FBURL property\nMerged by: @maintainer\nBranch: feature/fburl → main\n\nCommits merged:\n- feat: parse FBURL property from vCard 4.0\n- test: add FBURL round-trip tests\n- docs: update README with FBURL example\n\n-\nReply to this email directly or view it on GitHub.' }, }, }, { @@ -343,14 +343,14 @@ const emails: MockEmail[] = [ id: 'email-024', threadId: 'thread-023', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 7200, receivedAt: daysAgo(5), from: [{ name: 'DEV Community', email: 'digest@dev.to.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'DEV Digest — Top posts this week', + subject: 'DEV Digest - Top posts this week', preview: 'This week\'s top posts: "Why I switched from React to Solid", "Building a CLI tool in Rust", and more.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-044', size: 800, type: 'text/plain' }], htmlBody: [{ partId: 'p2', blobId: 'blob-045', size: 1200, type: 'text/html' }], bodyValues: { - p1: { value: 'DEV Digest — Top posts this week\n\n1. "Why I switched from React to Solid" by @webdev — 342 reactions\n2. "Building a CLI tool in Rust from scratch" by @rustacean — 289 reactions\n3. "The state of CSS in 2026" by @cssmaster — 256 reactions\n4. "Microservices are dead, long live modular monoliths" by @architect — 234 reactions\n5. "A beginner\'s guide to WebAssembly Components" by @wasmdev — 198 reactions\n\nHappy coding!\nThe DEV Team' }, - p2: { value: '

DEV Digest

Top posts this week:

  1. "Why I switched from React to Solid" — 342 reactions
  2. "Building a CLI tool in Rust from scratch" — 289 reactions
  3. "The state of CSS in 2026" — 256 reactions
  4. "Microservices are dead, long live modular monoliths" — 234 reactions
  5. "A beginner\'s guide to WebAssembly Components" — 198 reactions

Happy coding!
The DEV Team

' }, + p1: { value: 'DEV Digest - Top posts this week\n\n1. "Why I switched from React to Solid" by @webdev - 342 reactions\n2. "Building a CLI tool in Rust from scratch" by @rustacean - 289 reactions\n3. "The state of CSS in 2026" by @cssmaster - 256 reactions\n4. "Microservices are dead, long live modular monoliths" by @architect - 234 reactions\n5. "A beginner\'s guide to WebAssembly Components" by @wasmdev - 198 reactions\n\nHappy coding!\nThe DEV Team' }, + p2: { value: '

DEV Digest

Top posts this week:

  1. "Why I switched from React to Solid" - 342 reactions
  2. "Building a CLI tool in Rust from scratch" - 289 reactions
  3. "The state of CSS in 2026" - 256 reactions
  4. "Microservices are dead, long live modular monoliths" - 234 reactions
  5. "A beginner\'s guide to WebAssembly Components" - 198 reactions

Happy coding!
The DEV Team

' }, }, }, { @@ -371,7 +371,7 @@ const emails: MockEmail[] = [ from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], - subject: 'Re: Sprint planning — next week priorities', + subject: 'Re: Sprint planning - next week priorities', preview: 'Looks good! I\'d also suggest we add the email signature editor to the list. I can take that one.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-047', size: 200, type: 'text/plain' }], @@ -385,7 +385,7 @@ const emails: MockEmail[] = [ from: [{ name: 'Liam Ó Donaill', email: 'liam.odonaill@finanz.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'Nils Andersson', email: 'nils@digitaal.example' }], - subject: 'Q1 Budget Review — Meeting this Thursday', + subject: 'Q1 Budget Review - Meeting this Thursday', preview: 'Hi, let\'s meet Thursday at 14:00 to review the Q1 engineering budget. Please bring your team\'s actuals.', hasAttachment: true, textBody: [{ partId: 'p1', blobId: 'blob-062', size: 350, type: 'text/plain' }], @@ -423,14 +423,14 @@ const emails: MockEmail[] = [ textBody: [{ partId: 'p1', blobId: 'blob-064', size: 150, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hej!\n\nHaven\'t caught up in a while. Free for a fika next week? Tuesday or Wednesday work best for me.\n\nThere\'s a great new café on the Prinsengracht I\'ve been wanting to try — they do a wonderful kanelbulle.\n\nNils' }, + p1: { value: 'Hej!\n\nHaven\'t caught up in a while. Free for a fika next week? Tuesday or Wednesday work best for me.\n\nThere\'s a great new café on the Prinsengracht I\'ve been wanting to try - they do a wonderful kanelbulle.\n\nNils' }, }, }, { id: 'email-039', threadId: 'thread-034', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(0.25), from: [{ name: 'CI/CD Pipeline', email: 'ci@github.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '❌ Build failed: main — bulwark-webmail #1337', + subject: '❌ Build failed: main - bulwark-webmail #1337', preview: 'Build #1337 on branch main failed. 2 test(s) failed in email-sanitization.test.ts.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-060', size: 450, type: 'text/plain' }], @@ -453,14 +453,14 @@ const emails: MockEmail[] = [ textBody: [{ partId: 'p1', blobId: 'blob-011', size: 80, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Perfetto! Let\'s meet at noon by the Herengracht.\n\n— Dev User' }, + p1: { value: 'Perfetto! Let\'s meet at noon by the Herengracht.\n\n- Dev User' }, }, }, { id: 'email-007', threadId: 'thread-006', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 2200, receivedAt: daysAgo(3), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }], cc: [], - subject: 'Re: Project Update — Q1 Review', + subject: 'Re: Project Update - Q1 Review', preview: 'Merci Pierre, the numbers look great. I\'ll prepare the board presentation.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-012', size: 150, type: 'text/plain' }], @@ -487,13 +487,13 @@ const emails: MockEmail[] = [ from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], - subject: 'Re: Sprint planning — next week priorities', + subject: 'Re: Sprint planning - next week priorities', preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-048', size: 150, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.\n\nLars — let\'s also add a stretch goal for the email template system if we finish early.\n\n— Dev User' }, + p1: { value: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.\n\nLars - let\'s also add a stretch goal for the email template system if we finish early.\n\n- Dev User' }, }, }, { @@ -506,20 +506,20 @@ const emails: MockEmail[] = [ textBody: [{ partId: 'p1', blobId: 'blob-049', size: 280, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Nice work on the vCard parser! Left a few comments on the PR.\n\nMain concern: memory usage for large imports (1000+ contacts). Consider using a streaming parser instead of loading the entire file.\n\nAlso, the duplicate detection logic looks solid. Approved with minor changes.\n\n— Dev User' }, + p1: { value: 'Nice work on the vCard parser! Left a few comments on the PR.\n\nMain concern: memory usage for large imports (1000+ contacts). Consider using a streaming parser instead of loading the entire file.\n\nAlso, the duplicate detection logic looks solid. Approved with minor changes.\n\n- Dev User' }, }, }, { id: 'email-029', threadId: 'thread-018', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1600, receivedAt: daysAgo(2), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Astrid van der Berg', email: 'astrid@berglabs.example' }], cc: [], - subject: 'Re: Meeting notes — API design review', + subject: 'Re: Meeting notes - API design review', preview: 'Thanks for the thorough notes Astrid. I\'ll have the OpenAPI spec draft ready by Friday.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-050', size: 120, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Bedankt for the thorough notes Astrid. I\'ll have the OpenAPI spec draft ready by Friday.\n\n— Dev User' }, + p1: { value: 'Bedankt for the thorough notes Astrid. I\'ll have the OpenAPI spec draft ready by Friday.\n\n- Dev User' }, }, }, { @@ -532,7 +532,7 @@ const emails: MockEmail[] = [ textBody: [{ partId: 'p1', blobId: 'blob-051', size: 900, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hej team,\n\nI\'ve been researching JMAP (RFC 8620/8621) as a replacement for our current REST-based mail backend. Here\'s a summary:\n\nWhy JMAP?\n- Eliminates N+1 query problems with batch requests\n- Built-in push notifications via EventSource\n- Efficient delta sync reduces bandwidth by 60-80%\n- Standardized protocol with growing ecosystem\n\nProposed timeline:\n- Phase 1 (Mar): Proof of concept with mock server\n- Phase 2 (Apr): Core email operations\n- Phase 3 (May): Calendar & contacts integration\n- Phase 4 (Jun): Migration from legacy API\n\nFull proposal document attached.\n\n— Dev User' }, + p1: { value: 'Hej team,\n\nI\'ve been researching JMAP (RFC 8620/8621) as a replacement for our current REST-based mail backend. Here\'s a summary:\n\nWhy JMAP?\n- Eliminates N+1 query problems with batch requests\n- Built-in push notifications via EventSource\n- Efficient delta sync reduces bandwidth by 60-80%\n- Standardized protocol with growing ecosystem\n\nProposed timeline:\n- Phase 1 (Mar): Proof of concept with mock server\n- Phase 2 (Apr): Core email operations\n- Phase 3 (May): Calendar & contacts integration\n- Phase 4 (Jun): Migration from legacy API\n\nFull proposal document attached.\n\n- Dev User' }, }, attachments: [ { partId: 'att5', blobId: 'blob-att-005', size: 67000, name: 'JMAP-Migration-Proposal.pdf', type: 'application/pdf' }, @@ -572,12 +572,12 @@ const emails: MockEmail[] = [ from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'CFP Committee', email: 'cfp@fosdem.example' }], cc: [], subject: 'Talk proposal: Modern email clients with JMAP', - preview: 'Title: Modern Email Clients with JMAP — From Protocol to Production...', + preview: 'Title: Modern Email Clients with JMAP - From Protocol to Production...', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-053', size: 400, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Title: Modern Email Clients with JMAP — From Protocol to Production\n\nAbstract:\nThis talk explores building a full-featured webmail client using the JMAP protocol. We\'ll cover session negotiation, efficient data sync, real-time push notifications, and lessons learned.\n\nConference: FOSDEM 2027\nFormat: 30-minute talk\nLevel: Intermediate\n\n[TODO: Add speaker bio]\n[TODO: Complete outline]' }, + p1: { value: 'Title: Modern Email Clients with JMAP - From Protocol to Production\n\nAbstract:\nThis talk explores building a full-featured webmail client using the JMAP protocol. We\'ll cover session negotiation, efficient data sync, real-time push notifications, and lessons learned.\n\nConference: FOSDEM 2027\nFormat: 30-minute talk\nLevel: Intermediate\n\n[TODO: Add speaker bio]\n[TODO: Complete outline]' }, }, }, // ===================================================================== @@ -600,7 +600,7 @@ const emails: MockEmail[] = [ id: 'email-033', threadId: 'thread-028', mailboxIds: { 'mb-junk': true }, keywords: {}, size: 2200, receivedAt: hoursAgo(8), from: [{ name: 'HTCPCP Service', email: 'noreply@teapot.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '418 I\'m a Teapot — Your coffee request was denied', + subject: '418 I\'m a Teapot - Your coffee request was denied', preview: 'Per RFC 2324, this server is a teapot and cannot brew coffee. Please try a coffee pot instead.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-054', size: 250, type: 'text/plain' }], @@ -629,13 +629,13 @@ const emails: MockEmail[] = [ id: 'email-011', threadId: 'thread-010', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true }, size: 3800, receivedAt: daysAgo(14), from: [{ name: 'HR Department', email: 'hr@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Updated Holiday Policy — EU Directive Compliance', + subject: 'Updated Holiday Policy - EU Directive Compliance', preview: 'Please review the updated paid leave policy effective next month, now with 30 days minimum.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-016', size: 600, type: 'text/plain' }], htmlBody: [], bodyValues: { - p1: { value: 'Hej team,\n\nPlease review the updated paid leave policy effective next month. Key changes include:\n\n- Minimum annual leave: 30 days (EU directive compliance)\n- New flexible Friday policy — Freitags um 14:00 Schluss\n- Simplified approval workflow\n- Fika breaks are now officially protected time\n\nFull details in the employee handbook.\n\nBästa hälsningar,\nHR Department' }, + p1: { value: 'Hej team,\n\nPlease review the updated paid leave policy effective next month. Key changes include:\n\n- Minimum annual leave: 30 days (EU directive compliance)\n- New flexible Friday policy - Freitags um 14:00 Schluss\n- Simplified approval workflow\n- Fika breaks are now officially protected time\n\nFull details in the employee handbook.\n\nBästa hälsningar,\nHR Department' }, }, }, { @@ -671,7 +671,7 @@ const emails: MockEmail[] = [ id: 'email-036', threadId: 'thread-031', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 3100, receivedAt: daysAgo(45), from: [{ name: 'ELSTER Online', email: 'noreply@elster.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: 'Ihre Steuererklärung 2025 — Dokumente bereit', + subject: 'Ihre Steuererklärung 2025 - Dokumente bereit', preview: 'Ihre Lohnsteuerbescheinigung und Steuerbescheid sind zum Download bereit.', hasAttachment: true, textBody: [{ partId: 'p1', blobId: 'blob-057', size: 300, type: 'text/plain' }], @@ -707,7 +707,7 @@ const emails: MockEmail[] = [ id: 'email-038', threadId: 'thread-033', mailboxIds: { 'mb-trash': true }, keywords: { $seen: true }, size: 3500, receivedAt: daysAgo(1), from: [{ name: 'SaaS Product', email: 'marketing@saas-product.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], - subject: '🎉 50% off annual plans — limited time!', + subject: '🎉 50% off annual plans - limited time!', preview: 'Upgrade to our annual plan and save 50%. Offer expires this Sunday.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-059', size: 400, type: 'text/plain' }], @@ -775,7 +775,7 @@ const contacts = [ emails: { e1: { address: 'karel@devries.example' } }, phones: { p1: { number: '+31 20 555 0142' } }, addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } }, - notes: { n1: { note: 'Backend developer. Cycles to work rain or shine — true Dutchman.' } }, + notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } }, }, { id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] }, @@ -970,7 +970,7 @@ function participant(name: string, email: string, kind: string = 'attendee') { } const calendarEvents = [ - // ===== Work calendar (cal-2) — recurring & meetings ===== + // ===== Work calendar (cal-2) - recurring & meetings ===== makeEvent('evt-001', 'cal-2', 'Daily Standup', localDateTime(0, 9, 0), 'PT15M', { recurrence: [{ frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'tu' }, { day: 'we' }, { day: 'th' }, { day: 'fr' }] }], virtualLocations: { vl1: { uri: 'https://meet.example/standup', name: 'Google Meet', description: 'Daily sync' } }, @@ -1000,7 +1000,7 @@ const calendarEvents = [ p1: participant('Dev User', 'dev@localhost', 'owner'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), }, - description: 'Weekly catch-up. Duration: exactly 42 minutes — the answer to everything.', + description: 'Weekly catch-up. Duration: exactly 42 minutes - the answer to everything.', }), makeEvent('evt-004', 'cal-2', 'Code Review Session', localDateTime(0, 16, 0), 'PT1H', { location: 'Konferenzsaal B', @@ -1020,8 +1020,8 @@ const calendarEvents = [ }, description: 'Review microservices → JMAP migration architecture.', }), - // Overlapping event — deliberate conflict with Architecture Review - makeEvent('evt-006', 'cal-2', 'Customer Call — EuroTech', localDateTime(2, 11, 30), 'PT45M', { + // Overlapping event - deliberate conflict with Architecture Review + makeEvent('evt-006', 'cal-2', 'Customer Call - EuroTech', localDateTime(2, 11, 30), 'PT45M', { virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } }, participants: { p1: participant('Dev User', 'dev@localhost', 'owner'), @@ -1076,7 +1076,7 @@ const calendarEvents = [ color: '#d50000', }), - // ===== Team calendar (cal-3) — social & team ===== + // ===== Team calendar (cal-3) - social & team ===== makeEvent('evt-012', 'cal-3', 'Biergarten Abend 🍺', localDateTime(5, 18, 0), 'PT3H', { location: 'Biergarten am Prinsengracht, Amsterdam', description: 'Monthly team social. Bretzel buffet included.\nVegetarian options: Käsespätzle, Kartoffelsalat.\nBring your own Dirndl/Lederhosen (optional but encouraged).', @@ -1118,7 +1118,7 @@ const calendarEvents = [ p6: participant('Nils Andersson', 'nils@digitaal.example'), }, }), - makeEvent('evt-016', 'cal-3', 'Cooking Class — Pasta Fresca', localDateTime(12, 18, 30), 'PT2H30M', { + makeEvent('evt-016', 'cal-3', 'Cooking Class - Pasta Fresca', localDateTime(12, 18, 30), 'PT2H30M', { location: 'La Cucina Cooking School, Jordaan, Amsterdam', description: 'Team cooking class: fresh pasta from scratch.\nMenu: tagliatelle al ragù, ravioli ricotta e spinaci.\nChef: Chiara Rossi (guest instructor)', participants: { @@ -1160,7 +1160,7 @@ const calendarEvents = [ makeEvent('evt-023', 'cal-1', 'Dutch Language Lesson', localDateTime(1, 19, 0), 'PT1H30M', { location: 'Taleninstituut, Plantage Middenlaan, Amsterdam', recurrence: [{ frequency: 'weekly', byDay: [{ day: 'tu' }] }], - description: 'Semester 3 — past tense and separable verbs. Ik heb geprobeerd...', + description: 'Semester 3 - past tense and separable verbs. Ik heb geprobeerd...', }), makeEvent('evt-024', 'cal-1', 'Call with Mum', localDateTime(0, 18, 30), 'PT30M', { recurrence: [{ frequency: 'weekly', byDay: [{ day: 'su' }] }], @@ -1169,18 +1169,18 @@ const calendarEvents = [ // Overlapping personal events makeEvent('evt-025', 'cal-1', 'Haircut', localDateTime(6, 14, 30), 'PT45M', { location: 'Kapper de Luxe, Utrechtsestraat, Amsterdam', - description: 'Overlaps with Vondelpark bike ride — need to reschedule one!', + description: 'Overlaps with Vondelpark bike ride - need to reschedule one!', }), - // ===== Holiday calendar (cal-4) — all-day events ===== + // ===== Holiday calendar (cal-4) - all-day events ===== makeEvent('evt-026', 'cal-4', 'Koningsdag 🧡', localDateTime(42, 0, 0), 'P1D', { showWithoutTime: true, - description: 'King\'s Day — national holiday in the Netherlands.\nWear orange! Visit a vrijmarkt. Eat tompouce.', + description: 'King\'s Day - national holiday in the Netherlands.\nWear orange! Visit a vrijmarkt. Eat tompouce.', color: '#ff6d00', }), makeEvent('evt-027', 'cal-4', 'Tag der Arbeit', localDateTime(48, 0, 0), 'P1D', { showWithoutTime: true, - description: 'Labour Day — public holiday in most EU countries.', + description: 'Labour Day - public holiday in most EU countries.', }), makeEvent('evt-028', 'cal-4', 'Europe Day 🇪🇺', localDateTime(55, 0, 0), 'P1D', { showWithoutTime: true, @@ -1189,7 +1189,7 @@ const calendarEvents = [ }), makeEvent('evt-029', 'cal-4', 'Bevrijdingsdag', localDateTime(52, 0, 0), 'P1D', { showWithoutTime: true, - description: 'Liberation Day — Dutch national holiday commemorating the end of WWII occupation.', + description: 'Liberation Day - Dutch national holiday commemorating the end of WWII occupation.', }), // ===== Birthday calendar (cal-5) ===== @@ -1218,7 +1218,7 @@ const calendarEvents = [ makeEvent('evt-034', 'cal-2', 'JMAP Conf Amsterdam', localDateTime(28, 9, 0), 'P2D', { location: 'RAI Amsterdam Convention Centre', showWithoutTime: true, - description: 'Your talk: "Building Modern Webmail with JMAP" — Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!', + description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!', participants: { p1: participant('Dev User', 'dev@localhost'), p2: participant('Sophie Müller', 'sophie@eurotech.example'), @@ -1227,7 +1227,7 @@ const calendarEvents = [ }), makeEvent('evt-035', 'cal-2', 'FOSDEM Talk Prep', localDateTime(10, 13, 0), 'PT2H', { virtualLocations: { vl1: { uri: 'https://meet.example/fosdem-prep', name: 'Meet' } }, - description: 'Rehearse FOSDEM 2027 talk proposal.\nTitle: "Modern Email Clients with JMAP — From Protocol to Production"', + description: 'Rehearse FOSDEM 2027 talk proposal.\nTitle: "Modern Email Clients with JMAP - From Protocol to Production"', }), ]; diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index e1a4d424..f0c96d9b 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -3,10 +3,10 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry import { logger } from '@/lib/logger'; /** - * GET /api/plugins — Public endpoint for clients to discover server-managed plugins & themes + * GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes * * Returns all enabled plugins and themes so the client can sync them to IndexedDB. - * No admin auth required — this is how regular users receive plugins/themes. + * No admin auth required - this is how regular users receive plugins/themes. */ export async function GET() { try { diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 5636e439..d03a1780 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -135,7 +135,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); } - // Enforce admin policy — strip locked settings so users can't override them + // Enforce admin policy - strip locked settings so users can't override them await configManager.ensureLoaded(); const policy = configManager.getPolicy(); const filteredSettings = { ...settings }; diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 6ac6b3cb..ffdd8e28 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -87,7 +87,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } try { let derBytes: ArrayBuffer | string | null = null; if (key.uri.startsWith('data:')) { - // data URI — extract base64 content + // data URI - extract base64 content const commaIdx = key.uri.indexOf(','); if (commaIdx === -1) continue; const b64 = key.uri.substring(commaIdx + 1); @@ -178,7 +178,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
-

{name || "—"}

+

{name || "-"}

{hasNickname && (

“{nicknames.map(n => n.name).join(", ")}”

)} @@ -278,7 +278,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
{o.name} {o.units && o.units.length > 0 && ( - — {o.units.map(u => u.name).join(", ")} + - {o.units.map(u => u.name).join(", ")} )}
))} @@ -371,7 +371,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } {contact.speakToAs.pronouns && (() => { const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns; return firstPronoun ? ( - {contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun} + {contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun} ) : null; })()}
diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index a49f23c9..005c4a79 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -507,7 +507,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
)} - {/* Name & Identity — full width */} + {/* Name & Identity - full width */}
@@ -575,7 +575,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc setEmails(next); }} > - + @@ -639,7 +639,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc setPhones(next); }} > - + @@ -677,7 +677,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
- {/* Addresses — full width */} + {/* Addresses - full width */}
@@ -698,7 +698,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc value={addr.context} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }} > - + @@ -833,7 +833,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc