chore: update version to 1.5.3
This commit is contained in:
@@ -1,5 +1,24 @@
|
|||||||
# Changelog
|
# 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)
|
## 1.5.2 (2026-04-27)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +53,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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
|
## 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:
|
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:
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export default function AdminTelemetryPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Status</div>
|
<div className="font-medium">Status</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<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 === 'on' && 'Heartbeats are enabled (default).'}
|
||||||
{status.consent === 'off' && 'Heartbeats are off.'}
|
{status.consent === 'off' && 'Heartbeats are off.'}
|
||||||
{envOverridden && (
|
{envOverridden && (
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||||
// caller cannot point this route at internal hosts. Only when no server
|
// caller cannot point this route at internal hosts. Only when no server
|
||||||
// URL is configured (and the deployment explicitly allows custom JMAP
|
// 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.
|
// it must resolve to a public address.
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const configuredServerUrl =
|
const configuredServerUrl =
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const pluginStorage = {
|
|||||||
await deleteItem(STORE_THEMES, themeId);
|
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).
|
// of the colour-token CSS (e.g. some v2 themes ship colours only).
|
||||||
async saveThemeSkin(themeId: string, skin: string): Promise<void> {
|
async saveThemeSkin(themeId: string, skin: string): Promise<void> {
|
||||||
await putItem(STORE_THEME_SKINS, themeId, skin);
|
await putItem(STORE_THEME_SKINS, themeId, skin);
|
||||||
|
|||||||
+5
-5
@@ -12,7 +12,7 @@ export type ThemeVariant = 'light' | 'dark';
|
|||||||
// ─── Manifests ───────────────────────────────────────────────
|
// ─── 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.
|
* legacy theme that ships only `:root`/`.dark` CSS continues to work.
|
||||||
*
|
*
|
||||||
* When `apiVersion >= 2` (or any of `tokens`/`extends`/`derive`/`density`/
|
* When `apiVersion >= 2` (or any of `tokens`/`extends`/`derive`/`density`/
|
||||||
@@ -62,7 +62,7 @@ export interface ThemeManifest {
|
|||||||
apiVersion?: 1 | 2;
|
apiVersion?: 1 | 2;
|
||||||
/** Inherit tokens/CSS from another installed (or built-in) theme by id. */
|
/** Inherit tokens/CSS from another installed (or built-in) theme by id. */
|
||||||
extends?: string;
|
extends?: string;
|
||||||
/** Structured colour tokens — compiled into CSS at install time. */
|
/** Structured colour tokens - compiled into CSS at install time. */
|
||||||
tokens?: ThemeTokenSet;
|
tokens?: ThemeTokenSet;
|
||||||
/** When true, missing standard tokens are derived (e.g. *-foreground from contrast). */
|
/** When true, missing standard tokens are derived (e.g. *-foreground from contrast). */
|
||||||
derive?: boolean;
|
derive?: boolean;
|
||||||
@@ -121,10 +121,10 @@ export interface InstalledTheme {
|
|||||||
author: string;
|
author: string;
|
||||||
description: string;
|
description: string;
|
||||||
preview?: string; // data: URI or blob URL
|
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
|
* 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
|
* tokens. Injected into a separate `<style>` tag so it can be stripped
|
||||||
* cleanly when the theme is deactivated. Stored in IndexedDB with the same
|
* cleanly when the theme is deactivated. Stored in IndexedDB with the same
|
||||||
* lifecycle as `css` to keep localStorage small.
|
* 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)
|
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.
|
* 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.
|
* almost certainly bundling assets the validator will refuse anyway.
|
||||||
*/
|
*/
|
||||||
export const MAX_THEME_SKIN_BYTES = 256 * 1024; // 256 KB
|
export const MAX_THEME_SKIN_BYTES = 256 * 1024; // 256 KB
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface ThemeExtractionResult extends ValidationResult {
|
|||||||
manifest: ThemeManifest | null;
|
manifest: ThemeManifest | null;
|
||||||
css: string;
|
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.
|
* Only populated for Theme API v2 manifests; v1 themes ignore the file.
|
||||||
*/
|
*/
|
||||||
skin: string | null;
|
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 };
|
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),
|
// declares Theme API v2 fields (tokens/extends/derive/density/radii/typography),
|
||||||
// since the compiler can produce CSS purely from the manifest.
|
// since the compiler can produce CSS purely from the manifest.
|
||||||
const cssFile = zip.file(root + 'theme.css');
|
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
|
// 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.
|
// they still go through the dangerous-pattern sanitizer.
|
||||||
let skin: string | null = null;
|
let skin: string | null = null;
|
||||||
const skinFile = zip.file(root + 'skin.css');
|
const skinFile = zip.file(root + 'skin.css');
|
||||||
if (skinFile) {
|
if (skinFile) {
|
||||||
if (!isAdvanced) {
|
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 {
|
} else {
|
||||||
const rawSkin = await skinFile.async('string');
|
const rawSkin = await skinFile.async('string');
|
||||||
if (rawSkin.length > MAX_THEME_SKIN_BYTES) {
|
if (rawSkin.length > MAX_THEME_SKIN_BYTES) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { isIP } from 'node:net';
|
|||||||
// URL; without this an attacker with a session (or a hostile admin in a
|
// URL; without this an attacker with a session (or a hostile admin in a
|
||||||
// multi-tenant deploy) could redirect heartbeats at internal hosts.
|
// 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.
|
// dev where the collector is on the loopback.
|
||||||
|
|
||||||
const PRIVATE_V4: RegExp[] = [
|
const PRIVATE_V4: RegExp[] = [
|
||||||
@@ -106,7 +106,7 @@ export async function resolveEndpointAllowed(raw: string): Promise<EndpointCheck
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch {
|
} 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
|
// 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
|
// is flaky. The literal-IP check above already covers the direct-attack
|
||||||
// case.
|
// case.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export async function getInstanceId(): Promise<string> {
|
|||||||
return fresh;
|
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
|
// Admins can disable via the UI, the BULWARK_TELEMETRY env var, or by clearing
|
||||||
// the endpoint. See https://bulwarkmail.org/docs/legal/privacy/telemetry.
|
// the endpoint. See https://bulwarkmail.org/docs/legal/privacy/telemetry.
|
||||||
const DEFAULTS: TelemetryStateFile = {
|
const DEFAULTS: TelemetryStateFile = {
|
||||||
|
|||||||
@@ -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
|
// radii, typography, density, extends) into a single CSS string that the
|
||||||
// existing `injectThemeCSS` pipeline can apply unchanged.
|
// existing `injectThemeCSS` pipeline can apply unchanged.
|
||||||
|
|
||||||
@@ -79,11 +79,11 @@ function emitTokens(
|
|||||||
for (const [rawKey, value] of Object.entries(expanded)) {
|
for (const [rawKey, value] of Object.entries(expanded)) {
|
||||||
if (typeof value !== 'string' || !value.trim()) continue;
|
if (typeof value !== 'string' || !value.trim()) continue;
|
||||||
if (!isSafeTokenKey(rawKey)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isSafeTokenValue(value)) {
|
if (!isSafeTokenValue(value)) {
|
||||||
warnings.push(`Token "${rawKey}" dropped — value contains unsafe characters`);
|
warnings.push(`Token "${rawKey}" dropped - value contains unsafe characters`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
lines.push(` ${tokenName(rawKey)}: ${value.trim()};`);
|
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.
|
* Resolves a `extends: <id>` chain to that base theme's compiled CSS.
|
||||||
* Implementations should return null for unknown ids; circular refs are
|
* 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;
|
resolveExtends?: (id: string) => string | null;
|
||||||
/**
|
/**
|
||||||
@@ -211,16 +211,16 @@ export function compileAdvancedTheme(
|
|||||||
|
|
||||||
const sections: string[] = [];
|
const sections: string[] = [];
|
||||||
|
|
||||||
// 1. extends — prepend parent CSS verbatim
|
// 1. extends - prepend parent CSS verbatim
|
||||||
if (manifest.extends && opts.resolveExtends) {
|
if (manifest.extends && opts.resolveExtends) {
|
||||||
const parentCSS = opts.resolveExtends(manifest.extends);
|
const parentCSS = opts.resolveExtends(manifest.extends);
|
||||||
if (parentCSS == null) {
|
if (parentCSS == null) {
|
||||||
warnings.push(`extends: parent theme "${manifest.extends}" not found — skipping`);
|
warnings.push(`extends: parent theme "${manifest.extends}" not found - skipping`);
|
||||||
} else {
|
} else {
|
||||||
sections.push(`/* inherited from ${manifest.extends} */\n${parentCSS}`);
|
sections.push(`/* inherited from ${manifest.extends} */\n${parentCSS}`);
|
||||||
}
|
}
|
||||||
} else if (manifest.extends) {
|
} 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)
|
// 2. :root block (light + common + structural)
|
||||||
@@ -268,7 +268,7 @@ export function compileAdvancedTheme(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sections.length === 0) {
|
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 {
|
return {
|
||||||
|
|||||||
+2
-2
@@ -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
|
* 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
|
* be removed cleanly without touching the colour-token block, and is placed
|
||||||
* AFTER the colour block so component rules win specificity.
|
* 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),
|
* intentionally target real component selectors (toolbars, lists, buttons),
|
||||||
* not just `:root`/`.dark`. The same script-injection / external-resource
|
* not just `:root`/`.dark`. The same script-injection / external-resource
|
||||||
* prohibitions still apply.
|
* prohibitions still apply.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.2",
|
"version": "1.5.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.2",
|
"version": "1.5.3",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.2",
|
"version": "1.5.3",
|
||||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
*
|
*
|
||||||
* Fires the `themeHooks.onThemeBeforeApply` transform hook so plugins can
|
* Fires the `themeHooks.onThemeBeforeApply` transform hook so plugins can
|
||||||
* post-process the CSS (e.g. inject extra `@font-face` rules or override
|
* 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
|
* CSS synchronously first to avoid a flash, then re-inject the transformed
|
||||||
* version once handlers settle.
|
* version once handlers settle.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user