docs: use framed standard-theme screenshots in the readme

This commit is contained in:
Linus Rath
2026-09-22 23:04:27 +02:00
parent e38326c3d0
commit f086d21d84
21 changed files with 62 additions and 44 deletions
+17 -31
View File
@@ -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.
<p align="center">
<a href="https://bulwarkmail.org/docs/features/overview"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-laptop-phone.webp" /><img src="screenshots/light-laptop-phone.webp" alt="Bulwark inbox on a laptop, with the same message open on a phone" width="100%" /></picture></a>
</p>
## Contents
- [Screenshots](#screenshots)
@@ -33,37 +37,19 @@ Bulwark Webmail is a self-hosted webmail client for [Stalwart Mail Server](https
## Screenshots
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
</picture>
<p align="center">
<a href="https://bulwarkmail.org/docs/features/calendar"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-calendar-week.webp" /><img src="screenshots/light-calendar-week.webp" alt="Calendar week view with events from several calendars" width="49%" /></picture></a>
<a href="https://bulwarkmail.org/docs/features/contacts"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-contact.webp" /><img src="screenshots/light-contact.webp" alt="Contact details with recent mail and upcoming events" width="49%" /></picture></a>
<br />
<sub>Calendar week view&nbsp;&nbsp;·&nbsp;&nbsp;Contact details with recent mail and upcoming events</sub>
</p>
<table>
<tr>
<td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
</tr>
<tr>
<td><sub><b>Calendar:</b> month, week, day and agenda views. Drag events to reschedule them, send iMIP invitations, subscribe to CalDAV calendars.</sub></td>
<td><sub><b>Contacts:</b> several address books, groups, vCard import and export, and address completion in the composer.</sub></td>
</tr>
<tr>
<td><img src="screenshots/theme.png" alt="Themes" /></td>
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
</tr>
<tr>
<td><sub><b>Themes:</b> pick a bundled theme or upload your own as a ZIP. Admins can enforce one.</sub></td>
<td><sub><b>Plugins:</b> add features with bundled or third-party plugins, installed from a ZIP.</sub></td>
</tr>
<tr>
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
<td><img src="screenshots/settings.png" alt="Settings" /></td>
</tr>
<tr>
<td><sub><b>Light mode:</b> HTML email colors are remapped by luminance, so text stays readable in either theme.</sub></td>
<td><sub><b>Settings:</b> appearance, identities, filters, templates and security.</sub></td>
</tr>
</table>
<p align="center">
<a href="https://bulwarkmail.org/docs/features/email/composing"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-composer.webp" /><img src="screenshots/light-composer.webp" alt="Rich-text composer next to the message list" width="49%" /></picture></a>
<a href="https://bulwarkmail.org/docs/guides/customization"><img src="screenshots/split-inbox.webp" alt="The inbox split diagonally between the light and dark theme" width="49%" /></a>
<br />
<sub>Composer with identities and formatting&nbsp;&nbsp;·&nbsp;&nbsp;Light and dark themes</sub>
</p>
## 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
@@ -57,6 +57,7 @@ const STATIC_ASSETS = [
'/icon-192x192.png',
'/branding/logo.png',
'/notification/cheerful-527.mp3',
'/demo/prototype-v3.png',
];
beforeEach(() => {
+33 -2
View File
@@ -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<string, string> = {
'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<Blob> {
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<string> {
+4 -4
View File
@@ -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: '<invoice-089@example.com>',
},
@@ -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: '<wedding-photos@example.com>',
},
+4 -4
View File
@@ -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),
},
+3 -3
View File
@@ -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 /<locale>/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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB