chore: update version to 1.5.3

This commit is contained in:
Linus Rath
2026-04-28 17:34:06 +02:00
parent ec581ce53e
commit 8935b81f12
15 changed files with 52 additions and 31 deletions
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## 1.5.3 (2026-04-28)
> **New:** Bulwark Webmail now sends an anonymous instance heartbeat once per day (version, platform, bucketed account counts, feature toggles — no message data, no PII). Disable any time from **Admin → Telemetry** or by setting `BULWARK_TELEMETRY=off`. See the [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry) for the full schema.
### Features
- **Telemetry**: Anonymous instance telemetry, on by default. Reports schema version, platform, bucketed account counts, and feature toggles only — disable from the admin UI, with `BULWARK_TELEMETRY=off`, or by clearing the endpoint
- **Telemetry**: Track unique logins (HMAC'd per instance, 90-day retention) so the heartbeat can report bucketed account totals without storing usernames
- **Plugins**: Theme API v2 with token compiler and skin slot
- **Plugins**: Extension preview page and detailed extension info API
- **Calendar**: Right-click context menu on empty calendar space
- **Docker**: Persistent named volume for telemetry data so the instance id and admin's consent choice survive container upgrades
### Fixes
- **Security**: Block telemetry endpoint from pointing at internal/loopback hosts (validation + DNS-rebind re-check at fetch time)
- **Security**: Harden plugin config, TOTP token exchange, and branding file serving
- **Mail**: Batch shortcuts now act on the multi-selection when one is present (#228)
## 1.5.2 (2026-04-27)
### Features
+3 -1
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.5.2-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.5.3-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div>
@@ -53,6 +53,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
</tr>
</table>
> **Anonymous telemetry is on by default** since 1.5.3. Each instance sends a daily heartbeat (version, platform, bucketed account counts, feature toggles — no message data, no PII). Disable from **Admin → Telemetry**, by setting `BULWARK_TELEMETRY=off`, or by clearing the endpoint. Full schema: [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
## Overview
Bulwark is a full webmail suite not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
+1 -1
View File
@@ -1 +1 @@
1.5.2
1.5.3
+1 -1
View File
@@ -137,7 +137,7 @@ export default function AdminTelemetryPage() {
<div>
<div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising no heartbeats sent yet.'}
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
{status.consent === 'off' && 'Heartbeats are off.'}
{envOverridden && (
+1 -1
View File
@@ -89,7 +89,7 @@ export async function POST(request: NextRequest) {
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server
// URL is configured (and the deployment explicitly allows custom JMAP
// endpoints) do we fall back to the user-supplied URL and even then
// endpoints) do we fall back to the user-supplied URL - and even then
// it must resolve to a public address.
await configManager.ensureLoaded();
const configuredServerUrl =
+1 -1
View File
@@ -88,7 +88,7 @@ export const pluginStorage = {
await deleteItem(STORE_THEMES, themeId);
},
// Theme skin CSS separate store so it can be present/absent independently
// Theme skin CSS - separate store so it can be present/absent independently
// of the colour-token CSS (e.g. some v2 themes ship colours only).
async saveThemeSkin(themeId: string, skin: string): Promise<void> {
await putItem(STORE_THEME_SKINS, themeId, skin);
+5 -5
View File
@@ -12,7 +12,7 @@ export type ThemeVariant = 'light' | 'dark';
// ─── Manifests ───────────────────────────────────────────────
/**
* Advanced theme fields ("Theme API v2"). All optional and additive a
* Advanced theme fields ("Theme API v2"). All optional and additive - a
* legacy theme that ships only `:root`/`.dark` CSS continues to work.
*
* When `apiVersion >= 2` (or any of `tokens`/`extends`/`derive`/`density`/
@@ -62,7 +62,7 @@ export interface ThemeManifest {
apiVersion?: 1 | 2;
/** Inherit tokens/CSS from another installed (or built-in) theme by id. */
extends?: string;
/** Structured colour tokens compiled into CSS at install time. */
/** Structured colour tokens - compiled into CSS at install time. */
tokens?: ThemeTokenSet;
/** When true, missing standard tokens are derived (e.g. *-foreground from contrast). */
derive?: boolean;
@@ -121,10 +121,10 @@ export interface InstalledTheme {
author: string;
description: string;
preview?: string; // data: URI or blob URL
css: string; // compiled CSS text what gets injected
css: string; // compiled CSS text - what gets injected
/**
* Optional "skin" CSS shipped by Theme API v2 themes that need to restyle
* actual UI components (toolbars, lists, buttons, etc.) not just colour
* actual UI components (toolbars, lists, buttons, etc.) - not just colour
* tokens. Injected into a separate `<style>` tag so it can be stripped
* cleanly when the theme is deactivated. Stored in IndexedDB with the same
* lifecycle as `css` to keep localStorage small.
@@ -598,7 +598,7 @@ export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
export const MAX_THEME_SIZE = 2 * 1024 * 1024; // 2 MB (was 1 MB; v2 themes may ship a skin.css)
/**
* Maximum size of an individual `skin.css` payload after extraction.
* Skins are component-level CSS, not images anything bigger than this is
* Skins are component-level CSS, not images - anything bigger than this is
* almost certainly bundling assets the validator will refuse anyway.
*/
export const MAX_THEME_SKIN_BYTES = 256 * 1024; // 256 KB
+4 -4
View File
@@ -24,7 +24,7 @@ export interface ThemeExtractionResult extends ValidationResult {
manifest: ThemeManifest | null;
css: string;
/**
* Optional skin CSS component-level overrides extracted from `skin.css`.
* Optional skin CSS - component-level overrides extracted from `skin.css`.
* Only populated for Theme API v2 manifests; v1 themes ignore the file.
*/
skin: string | null;
@@ -225,7 +225,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
return { valid: false, errors, warnings, manifest: null, css: '', skin: null, preview: null };
}
// Read theme.css required for v1 themes, optional when the manifest
// Read theme.css - required for v1 themes, optional when the manifest
// declares Theme API v2 fields (tokens/extends/derive/density/radii/typography),
// since the compiler can produce CSS purely from the manifest.
const cssFile = zip.file(root + 'theme.css');
@@ -279,13 +279,13 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
}
// Read skin.css if present (Theme API v2 only). Skins target real
// component selectors and bypass the strict :root/.dark selector check
// component selectors and bypass the strict :root/.dark selector check -
// they still go through the dangerous-pattern sanitizer.
let skin: string | null = null;
const skinFile = zip.file(root + 'skin.css');
if (skinFile) {
if (!isAdvanced) {
warnings.push('skin.css ignored only Theme API v2 manifests can ship a skin');
warnings.push('skin.css ignored - only Theme API v2 manifests can ship a skin');
} else {
const rawSkin = await skinFile.async('string');
if (rawSkin.length > MAX_THEME_SKIN_BYTES) {
+2 -2
View File
@@ -6,7 +6,7 @@ import { isIP } from 'node:net';
// URL; without this an attacker with a session (or a hostile admin in a
// multi-tenant deploy) could redirect heartbeats at internal hosts.
//
// Set BULWARK_TELEMETRY_ALLOW_PRIVATE=1 to bypass useful only for local
// Set BULWARK_TELEMETRY_ALLOW_PRIVATE=1 to bypass - useful only for local
// dev where the collector is on the loopback.
const PRIVATE_V4: RegExp[] = [
@@ -106,7 +106,7 @@ export async function resolveEndpointAllowed(raw: string): Promise<EndpointCheck
}
return { ok: true };
} catch {
// Don't block on transient DNS failures fetch will fail loudly anyway,
// Don't block on transient DNS failures - fetch will fail loudly anyway,
// and we don't want to lock admins out of their config when the resolver
// is flaky. The literal-IP check above already covers the direct-attack
// case.
+1 -1
View File
@@ -41,7 +41,7 @@ export async function getInstanceId(): Promise<string> {
return fresh;
}
// Default consent is 'on' telemetry is anonymous and enabled by default.
// Default consent is 'on' - telemetry is anonymous and enabled by default.
// Admins can disable via the UI, the BULWARK_TELEMETRY env var, or by clearing
// the endpoint. See https://bulwarkmail.org/docs/legal/privacy/telemetry.
const DEFAULTS: TelemetryStateFile = {
+8 -8
View File
@@ -1,4 +1,4 @@
// Advanced Theme API v2 compiles structured manifest fields (tokens,
// Advanced Theme API v2 - compiles structured manifest fields (tokens,
// radii, typography, density, extends) into a single CSS string that the
// existing `injectThemeCSS` pipeline can apply unchanged.
@@ -79,11 +79,11 @@ function emitTokens(
for (const [rawKey, value] of Object.entries(expanded)) {
if (typeof value !== 'string' || !value.trim()) continue;
if (!isSafeTokenKey(rawKey)) {
warnings.push(`Token "${rawKey}" dropped invalid key (only [a-z0-9-] allowed)`);
warnings.push(`Token "${rawKey}" dropped - invalid key (only [a-z0-9-] allowed)`);
continue;
}
if (!isSafeTokenValue(value)) {
warnings.push(`Token "${rawKey}" dropped value contains unsafe characters`);
warnings.push(`Token "${rawKey}" dropped - value contains unsafe characters`);
continue;
}
lines.push(` ${tokenName(rawKey)}: ${value.trim()};`);
@@ -170,7 +170,7 @@ export interface CompileOptions {
/**
* Resolves a `extends: <id>` chain to that base theme's compiled CSS.
* Implementations should return null for unknown ids; circular refs are
* the caller's problem (we don't recurse just one level of inheritance).
* the caller's problem (we don't recurse - just one level of inheritance).
*/
resolveExtends?: (id: string) => string | null;
/**
@@ -211,16 +211,16 @@ export function compileAdvancedTheme(
const sections: string[] = [];
// 1. extends prepend parent CSS verbatim
// 1. extends - prepend parent CSS verbatim
if (manifest.extends && opts.resolveExtends) {
const parentCSS = opts.resolveExtends(manifest.extends);
if (parentCSS == null) {
warnings.push(`extends: parent theme "${manifest.extends}" not found skipping`);
warnings.push(`extends: parent theme "${manifest.extends}" not found - skipping`);
} else {
sections.push(`/* inherited from ${manifest.extends} */\n${parentCSS}`);
}
} else if (manifest.extends) {
warnings.push(`extends: no resolver provided "${manifest.extends}" ignored`);
warnings.push(`extends: no resolver provided - "${manifest.extends}" ignored`);
}
// 2. :root block (light + common + structural)
@@ -268,7 +268,7 @@ export function compileAdvancedTheme(
}
if (sections.length === 0) {
errors.push('Compiled theme is empty no tokens, radii, typography, or density supplied');
errors.push('Compiled theme is empty - no tokens, radii, typography, or density supplied');
}
return {
+2 -2
View File
@@ -91,7 +91,7 @@ export function removeThemeCSS(): void {
}
/**
* Inject a theme's *skin* CSS component-level overrides shipped by Theme
* Inject a theme's *skin* CSS - component-level overrides shipped by Theme
* API v2 themes via `skin.css`. Lives in a separate `<style>` tag so it can
* be removed cleanly without touching the colour-token block, and is placed
* AFTER the colour block so component rules win specificity.
@@ -124,7 +124,7 @@ export function removeThemeSkinCSS(): void {
}
/**
* Sanitize a theme *skin* looser than `sanitizeThemeCSS` because skins
* Sanitize a theme *skin* - looser than `sanitizeThemeCSS` because skins
* intentionally target real component selectors (toolbars, lists, buttons),
* not just `:root`/`.dark`. The same script-injection / external-resource
* prohibitions still apply.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.5.2",
"version": "1.5.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.5.2",
"version": "1.5.3",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.5.2",
"version": "1.5.3",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+1 -1
View File
@@ -473,7 +473,7 @@ export const useThemeStore = create<ThemeState>()(
*
* Fires the `themeHooks.onThemeBeforeApply` transform hook so plugins can
* post-process the CSS (e.g. inject extra `@font-face` rules or override
* specific tokens). The hook is fire-and-forget we inject the original
* specific tokens). The hook is fire-and-forget - we inject the original
* CSS synchronously first to avoid a flash, then re-inject the transformed
* version once handlers settle.
*