chore: update version to 1.5.3
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](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:
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user