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.
+
+
+
+
## Contents
- [Screenshots](#screenshots)
@@ -33,37 +37,19 @@ Bulwark Webmail is a self-hosted webmail client for [Stalwart Mail Server](https
## Screenshots
-
-
-
-
+
+
+
+
+ Calendar week view · Contact details with recent mail and upcoming events
+
-
-
-
-
-
-
-
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.
-
-
-
-
-
-
-
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 mode: HTML email colors are remapped by luminance, so text stays readable in either theme.
-
Settings: appearance, identities, filters, templates and security.
-
-
+
+
+
+
+ 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