diff --git a/README.md b/README.md index b589e0862..b1c2c7f17 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ Bulwark Webmail is a self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/). It talks to Stalwart over JMAP and puts mail, calendar, contacts and files behind one login, with one set of settings and one admin dashboard. +

+ Bulwark inbox on a laptop, with the same message open on a phone +

+ ## Contents - [Screenshots](#screenshots) @@ -33,37 +37,19 @@ Bulwark Webmail is a self-hosted webmail client for [Stalwart Mail Server](https ## Screenshots - - - Mail view - +

+ Calendar week view with events from several calendars + Contact details with recent mail and upcoming events +
+ Calendar week view  ·  Contact details with recent mail and upcoming events +

- - - - - - - - - - - - - - - - - - - - - - - - - -
CalendarContacts
Calendar: month, week, day and agenda views. Drag events to reschedule them, send iMIP invitations, subscribe to CalDAV calendars.Contacts: several address books, groups, vCard import and export, and address completion in the composer.
ThemesPlugins
Themes: pick a bundled theme or upload your own as a ZIP. Admins can enforce one.Plugins: add features with bundled or third-party plugins, installed from a ZIP.
Light modeSettings
Light mode: HTML email colors are remapped by luminance, so text stays readable in either theme.Settings: appearance, identities, filters, templates and security.
+

+ Rich-text composer next to the message list + The inbox split diagonally between the light and dark theme +
+ Composer with identities and formatting  ·  Light and dark themes +

## Features @@ -99,7 +85,7 @@ Then open `http://localhost:3000`. A setup wizard asks for your Stalwart server ## Configuration -Most installs are set up in the wizard on first launch and changed later in the [admin dashboard](https://bulwarkmail.org/docs/guides/admin). You can also use environment variables, which fit read-only or immutable deployments better. When both are set, the environment variable wins. +Most installs are set up in the wizard on first launch and changed later in the [admin dashboard](https://bulwarkmail.org/docs/guides/admin). You can also use environment variables, which fit read-only or immutable deployments better. When both set the same key, the value saved in the admin config wins, so an environment variable only fills in what the admin config leaves unset. ```env JMAP_SERVER_URL=https://mail.example.com diff --git a/lib/__tests__/proxy-security-headers.test.ts b/lib/__tests__/proxy-security-headers.test.ts index b6e482c3d..0e7d6b654 100644 --- a/lib/__tests__/proxy-security-headers.test.ts +++ b/lib/__tests__/proxy-security-headers.test.ts @@ -57,6 +57,7 @@ const STATIC_ASSETS = [ '/icon-192x192.png', '/branding/logo.png', '/notification/cheerful-527.mp3', + '/demo/prototype-v3.png', ]; beforeEach(() => { diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 9858bcee9..2d2eec3ed 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -4,10 +4,26 @@ import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types'; import { getDemoData, type DemoData } from './demo-data'; import { generateDemoId } from './demo-utils'; import { compareEmails, type SortLevel } from '@/lib/message-list-order'; +import { withBasePath } from '@/lib/browser-navigation'; + +// Fixture blobs with real bytes behind them, served from public/demo/. The +// photos are CC0 / public domain from Wikimedia Commons: "Wedding couple +// (1294004)", "Wedding couple (1293575)", "Men drinking", "Lake Mountain +// Landscape" and "Team members of startup". prototype-v3.png is drawn for the +// demo. Every other fixture blob falls back to a text placeholder. +const DEMO_BLOB_ASSETS: Record = { + 'demo-blob-att-2': '/demo/prototype-v3.png', + 'demo-blob-att-3': '/demo/wedding-001.jpg', + 'demo-blob-att-4': '/demo/wedding-014-mom-dad.jpg', + 'demo-blob-att-5': '/demo/wedding-038-the-toast.jpg', + 'demo-blob-file-4': '/demo/vacation.jpg', + 'demo-blob-file-5': '/demo/team-photo.jpg', +}; /** * In-memory JMAP client for demo mode. - * All data lives in memory - no network calls, no cookies. + * All data lives in memory - no server calls, no cookies. Only the demo + * images under public/demo are fetched, from the app's own origin. */ export class DemoJMAPClient implements IJMAPClient { private data: DemoData; @@ -695,7 +711,22 @@ export class DemoJMAPClient implements IJMAPClient { } async fetchBlob(blobId: string): Promise { - return this.blobStore.get(blobId) ?? new Blob(['[Demo placeholder content]'], { type: 'text/plain' }); + const stored = this.blobStore.get(blobId); + if (stored) return stored; + const asset = DEMO_BLOB_ASSETS[blobId]; + if (asset) { + try { + const res = await fetch(withBasePath(asset)); + if (res.ok) { + const blob = await res.blob(); + this.blobStore.set(blobId, blob); + return blob; + } + } catch { + // Fall through to the placeholder. + } + } + return new Blob(['[Demo placeholder content]'], { type: 'text/plain' }); } async fetchBlobAsObjectUrl(blobId: string): Promise { diff --git a/lib/demo/fixtures/emails.ts b/lib/demo/fixtures/emails.ts index fd5b7cfe6..84fda911b 100644 --- a/lib/demo/fixtures/emails.ts +++ b/lib/demo/fixtures/emails.ts @@ -217,7 +217,7 @@ export function createDemoEmails(): Email[] { ), attachments: [ { partId: 'att-1', blobId: 'demo-blob-att-1', size: 145000, name: 'Invoice-2024-089.pdf', type: 'application/pdf' }, - { partId: 'att-2', blobId: 'demo-blob-att-2', size: 89000, name: 'prototype-v3.png', type: 'image/png' }, + { partId: 'att-2', blobId: 'demo-blob-att-2', size: 21864, name: 'prototype-v3.png', type: 'image/png' }, ], messageId: '', }, @@ -281,9 +281,9 @@ export function createDemoEmails(): Email[] { "ok finally got around to going through these. there are like 600 more on the drive but here's the highlights - the ones I'd actually want to print.\n\nmom looked SO happy. dad cried during the speech btw, did you see?\n\nlet me know which ones you want full-res of\n\na", ), attachments: [ - { partId: 'att-3', blobId: 'demo-blob-att-3', size: 1800000, name: 'wedding-001.jpg', type: 'image/jpeg' }, - { partId: 'att-4', blobId: 'demo-blob-att-4', size: 1600000, name: 'wedding-014-mom-dad.jpg', type: 'image/jpeg' }, - { partId: 'att-5', blobId: 'demo-blob-att-5', size: 1400000, name: 'wedding-038-the-toast.jpg', type: 'image/jpeg' }, + { partId: 'att-3', blobId: 'demo-blob-att-3', size: 170392, name: 'wedding-001.jpg', type: 'image/jpeg' }, + { partId: 'att-4', blobId: 'demo-blob-att-4', size: 154740, name: 'wedding-014-mom-dad.jpg', type: 'image/jpeg' }, + { partId: 'att-5', blobId: 'demo-blob-att-5', size: 133897, name: 'wedding-038-the-toast.jpg', type: 'image/jpeg' }, ], messageId: '', }, diff --git a/lib/demo/fixtures/files.ts b/lib/demo/fixtures/files.ts index 63a285224..28778634f 100644 --- a/lib/demo/fixtures/files.ts +++ b/lib/demo/fixtures/files.ts @@ -106,17 +106,17 @@ export function createDemoFileNodes(): FileNode[] { name: 'vacation.jpg', type: 'image/jpeg', blobId: 'demo-blob-file-4', - size: 1258291, + size: 91949, created: demoDate(-10), modified: demoDate(-10), }, { id: 'demo-file-team-photo', parentId: 'demo-file-photos', - name: 'team-photo.png', - type: 'image/png', + name: 'team-photo.jpg', + type: 'image/jpeg', blobId: 'demo-blob-file-5', - size: 911360, + size: 71066, created: demoDate(-21), modified: demoDate(-21), }, diff --git a/proxy.ts b/proxy.ts index 88cc67601..9009046fe 100644 --- a/proxy.ts +++ b/proxy.ts @@ -54,14 +54,14 @@ function withMatchedChineseAcceptLanguage(request: NextRequest): NextRequest { const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)/; // What Next serves from public/: a single dotted segment at the root -// (/sw.js, /favicon.ico, /manifest.webmanifest) and the branding/ and -// notification/ asset folders. The upstream matcher treated EVERY path whose +// (/sw.js, /favicon.ico, /manifest.webmanifest) and the branding/, +// notification/ and demo/ asset folders. The upstream matcher treated EVERY path whose // last segment has a dot as a static file, but the [[...segments]] catch-alls // under //mail, /calendar, /contacts and /files make // /en/mail/folder/inbox/statement.pdf a real signed-in page, and it rendered // without a CSP or any other security header (GHSA-xvjh-v9c6-qcvc). Only what // is genuinely static may skip locale routing and the headers. -const STATIC_ASSET_PATTERN = /^\/[^/]+\.[^/]+$|^\/(?:branding|notification)\//; +const STATIC_ASSET_PATTERN = /^\/[^/]+\.[^/]+$|^\/(?:branding|notification|demo)\//; export function isStaticAssetPath(pathname: string): boolean { return STATIC_ASSET_PATTERN.test(pathname); diff --git a/public/demo/prototype-v3.png b/public/demo/prototype-v3.png new file mode 100644 index 000000000..92478518d Binary files /dev/null and b/public/demo/prototype-v3.png differ diff --git a/public/demo/team-photo.jpg b/public/demo/team-photo.jpg new file mode 100644 index 000000000..dad6c2650 Binary files /dev/null and b/public/demo/team-photo.jpg differ diff --git a/public/demo/vacation.jpg b/public/demo/vacation.jpg new file mode 100644 index 000000000..d4a9bbd97 Binary files /dev/null and b/public/demo/vacation.jpg differ diff --git a/public/demo/wedding-001.jpg b/public/demo/wedding-001.jpg new file mode 100644 index 000000000..30f7c1e5c Binary files /dev/null and b/public/demo/wedding-001.jpg differ diff --git a/public/demo/wedding-014-mom-dad.jpg b/public/demo/wedding-014-mom-dad.jpg new file mode 100644 index 000000000..f6f2f988f Binary files /dev/null and b/public/demo/wedding-014-mom-dad.jpg differ diff --git a/public/demo/wedding-038-the-toast.jpg b/public/demo/wedding-038-the-toast.jpg new file mode 100644 index 000000000..2ad7d1b3a Binary files /dev/null and b/public/demo/wedding-038-the-toast.jpg differ diff --git a/screenshots/dark-calendar-week.webp b/screenshots/dark-calendar-week.webp new file mode 100644 index 000000000..65fe59e5f Binary files /dev/null and b/screenshots/dark-calendar-week.webp differ diff --git a/screenshots/dark-composer.webp b/screenshots/dark-composer.webp new file mode 100644 index 000000000..54cf1101b Binary files /dev/null and b/screenshots/dark-composer.webp differ diff --git a/screenshots/dark-contact.webp b/screenshots/dark-contact.webp new file mode 100644 index 000000000..6a37d2ffe Binary files /dev/null and b/screenshots/dark-contact.webp differ diff --git a/screenshots/dark-laptop-phone.webp b/screenshots/dark-laptop-phone.webp new file mode 100644 index 000000000..34b4e9156 Binary files /dev/null and b/screenshots/dark-laptop-phone.webp differ diff --git a/screenshots/light-calendar-week.webp b/screenshots/light-calendar-week.webp new file mode 100644 index 000000000..84eaec349 Binary files /dev/null and b/screenshots/light-calendar-week.webp differ diff --git a/screenshots/light-composer.webp b/screenshots/light-composer.webp new file mode 100644 index 000000000..59101bd8f Binary files /dev/null and b/screenshots/light-composer.webp differ diff --git a/screenshots/light-contact.webp b/screenshots/light-contact.webp new file mode 100644 index 000000000..01589c4e9 Binary files /dev/null and b/screenshots/light-contact.webp differ diff --git a/screenshots/light-laptop-phone.webp b/screenshots/light-laptop-phone.webp new file mode 100644 index 000000000..70f536e99 Binary files /dev/null and b/screenshots/light-laptop-phone.webp differ diff --git a/screenshots/split-inbox.webp b/screenshots/split-inbox.webp new file mode 100644 index 000000000..be80f59b4 Binary files /dev/null and b/screenshots/split-inbox.webp differ