chore: update version to 1.6.2

This commit is contained in:
Linus Rath
2026-05-06 20:07:36 +02:00
parent 91cf125a5d
commit 178922323d
30 changed files with 89 additions and 43 deletions
+46
View File
@@ -1,5 +1,51 @@
# Changelog
## 1.6.2 (2026-05-06)
### Features
- **Plugins**: Hot-reload and dev-folder loading for live plugin development
- **Plugins**: On-demand `src/` bundling via esbuild
- **Plugins**: New `http:fetch` permission and `httpOrigins` manifest field
- **Plugins**: `onBeforeEmailSend` hook with `fromEmail` exposed on `OutgoingEmail`
- **Plugins**: Project `EmailReadView` for the email-banner slot and expose auth results
- **Plugins**: Ingest icon, banner, and screenshots from the source repo
- **Plugins**: Restrict plugin and theme install/uninstall to the admin dashboard
- **Mail**: Multi-server JMAP support
- **Settings**: Fulltext search across the settings sidebar
- **Settings**: Sub-result rows with highlight in settings search
- **Settings**: Surface plugin settings as search sub-results
- **Settings**: Remove experimental tags from themes, plugins, and sender favicons
- **Viewer**: Redesigned external-mail banner above attachments
- **Calendar**: Calendar invitation banner expands on row click
- **Calendar**: Calendar invitation banner is now collapsible
### Fixes
- **Admin**: Collapse admin panel into a single tabbed page
- **Plugins**: Inline plugin configure panel to avoid dev-mode hang
- **Plugins**: Resolve `PLUGIN_DEV_DIR` plugins in admin config route
- **Plugins**: Add missing body type assertion in `createPluginAPI` fetch options
- **Plugins**: Propagate `settingsSchema`
- **Settings**: Highlight plugin and theme cards in search results
- **Settings**: Open plugin card on first click of a setting sub-result
- **Settings**: Drop ghost sub-results from account and language search
- **Settings**: Improve search highlight styling
- **Viewer**: Show notification banners above attachments
- **Viewer**: Rework S/MIME banner to match calendar invitation
- **Viewer**: Close PDF preview on Escape before email viewer
- **Viewer**: Render PDF previews via `<object>` with `blob:` in object-src CSP (#253)
- **Calendar**: Align invitation icon with sender avatar column
- **Calendar**: Fix invitation picker clipping (#250)
- **Auth**: Read `activeAccountId` from authStore in account selectors
- **UI**: Adjust toast item border radius and progress bar styles
- **UI**: Remove fly-in animation from context menu submenus
- **i18n**: Add missing Czech flag icon
### i18n
- Add missing translation keys across 15 locales
## 1.6.1 (2026-05-04)
### Features
+1 -1
View File
@@ -9,7 +9,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
# `git rev-parse` inside the build can't find it CI must pass it in.
# `git rev-parse` inside the build can't find it - CI must pass it in.
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
RUN npx next build --webpack
+1 -1
View File
@@ -543,7 +543,7 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Server-list entries always win `allowCustomJmapEndpoint` is only honored
// Server-list entries always win - `allowCustomJmapEndpoint` is only honored
// when the admin hasn't configured a server list.
const effectiveServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
+3 -3
View File
@@ -740,7 +740,7 @@ export default function Home() {
// System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or
// `?openLatestUnread=1` (generic "New mail" toast happens when the
// `?openLatestUnread=1` (generic "New mail" toast - happens when the
// preview API failed). We resolve those params once after the inbox has
// finished loading and open the right message, then strip the params so a
// refresh doesn't re-open it.
@@ -1610,7 +1610,7 @@ export default function Home() {
const originalEmailId = selectedEmail.id;
// RFC 5322 §3.6.4 threading keep the conversation stitched together (#234).
// RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234).
const threading = computeReplyThreadingHeaders({
messageId: selectedEmail.messageId,
references: selectedEmail.references,
@@ -1674,7 +1674,7 @@ export default function Home() {
}
// Show the list stub immediately so subject/sender render without
// waiting for the body fetch avoids the loading flicker.
// waiting for the body fetch - avoids the loading flicker.
const listEmail = emails.find(e => e.id === email.id);
if (listEmail) {
selectEmail(listEmail);
+2 -2
View File
@@ -491,7 +491,7 @@ export default function SettingsPage() {
// For plugin-setting sub-results, ask the plugins tab to expand the
// matching card so the field becomes part of the DOM. Dispatched here
// (not in the click handler) because PluginsSettings only mounts after
// the tab switches, and its listener registers in its own useEffect
// the tab switches, and its listener registers in its own useEffect -
// child effects run before parent effects, so by the time we get here
// the listener is guaranteed to be in place.
if (pendingHighlight.pluginId) {
@@ -534,7 +534,7 @@ export default function SettingsPage() {
// First attempt next frame so the freshly-mounted tab content is in DOM.
const raf = window.requestAnimationFrame(tryHighlight);
// Do NOT reset pendingHighlight here that would retrigger this effect
// Do NOT reset pendingHighlight here - that would retrigger this effect
// and the cleanup below would strip the class right after we added it.
return () => {
cancelled = true;
+1 -1
View File
@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation';
// Inline panel handles plugin config now see _tabs/plugin-config-panel.tsx.
// Inline panel handles plugin config now - see _tabs/plugin-config-panel.tsx.
// Old deep links land on the plugins tab; the user clicks the gear again.
export default function Page() {
redirect('/admin?tab=plugins');
+1 -1
View File
@@ -43,7 +43,7 @@ export async function GET() {
/**
* POST /api/admin/version
* { action: 'check-now' } force a fresh upstream fetch.
* { action: 'check-now' } - force a fresh upstream fetch.
*/
export async function POST(req: NextRequest) {
try {
+1 -1
View File
@@ -44,7 +44,7 @@ export async function POST(request: NextRequest) {
// caller cannot point this route at internal hosts. We accept the global
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
// we fall back to the request URL only if `allowCustomJmapEndpoint` is on
// and even then the URL must resolve to a public address.
// - and even then the URL must resolve to a public address.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
+1 -1
View File
@@ -91,7 +91,7 @@ export async function POST(request: NextRequest) {
// Pin the upstream URL to a configured JMAP server. The list of allowed
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
// no server is configured (and the deployment explicitly allows custom
// JMAP endpoints) do we fall back to the user-supplied URL and even then
// JMAP 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
@@ -163,7 +163,7 @@ export async function GET(request: NextRequest) {
},
});
} catch (error) {
// `fetch failed` from undici is too generic to debug the real reason
// `fetch failed` from undici is too generic to debug - the real reason
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } };
logger.error('push preview failed', {
+1 -1
View File
@@ -963,7 +963,7 @@ export function EmailComposer({
return '';
};
// RFC 5322 §3.6.4 threading only continues the chain on a reply, not a forward.
// RFC 5322 §3.6.4 threading - only continues the chain on a reply, not a forward.
const threadingHeaders = (mode === 'reply' || mode === 'replyAll')
? computeReplyThreadingHeaders(replyTo)
: null;
+4 -4
View File
@@ -2697,7 +2697,7 @@ export function EmailViewer({
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
// Bare HTML emails (no <style>) tend to be plain prose without their own
// layout give them the same padding as plain-text mails (.email-content-text).
// layout - give them the same padding as plain-text mails (.email-content-text).
const bodyPadding = effectiveEmailContent.hasStyleTag ? '0' : '1rem 1.25rem';
return `<!DOCTYPE html>
@@ -2759,7 +2759,7 @@ export function EmailViewer({
}, []);
// Whenever permission is granted (allow toggled, or sender becomes trusted),
// restore blocked content in the existing iframe no srcDoc rebuild.
// restore blocked content in the existing iframe - no srcDoc rebuild.
const senderEmailLower = email?.from?.[0]?.email?.toLowerCase();
const senderIsTrustedNow = senderEmailLower
? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower))
@@ -2771,7 +2771,7 @@ export function EmailViewer({
}, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]);
// Tracks the last rendered body height so the loading skeleton can hold
// the same size avoids the body shrink/expand flash when switching emails.
// the same size - avoids the body shrink/expand flash when switching emails.
const lastBodyHeightRef = useRef<number>(300);
// True while the new email's body is still being fetched. Catches the
@@ -4022,7 +4022,7 @@ export function EmailViewer({
};
const fullDate = (iso?: string) => iso
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
: '';
: '-';
const auth = email.authenticationResults;
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
const topMimeType = email.bodyStructure?.type;
+1 -1
View File
@@ -185,7 +185,7 @@ export function NavigationRail({
// Account list for rail
const accounts = useAccountStore((s) => s.accounts);
// Read activeAccountId from authStore so the rail's account row matches the actually-loaded
// session accountStore has its own persisted copy that can drift out of sync.
// session - accountStore has its own persisted copy that can drift out of sync.
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout);
+1 -1
View File
@@ -64,7 +64,7 @@ export function TourProvider({ children }: { children: ReactNode }) {
// If the resume step is beyond the current steps, start from 0
if (resumeStep >= steps.length) resumeStep = 0;
// Most steps live on the mailbox navigate there (or to the step's specific page)
// Most steps live on the mailbox - navigate there (or to the step's specific page)
// so we don't start the tour on a page where the targets don't exist.
const targetPage = steps[resumeStep]?.page ?? "/";
if (pathname !== targetPage) {
+1 -1
View File
@@ -103,7 +103,7 @@ describe('JMAPClient.sendEmail threading headers', () => {
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
// Bare msg-ids per RFC 8621 angle brackets stripped.
// Bare msg-ids per RFC 8621 - angle brackets stripped.
expect(draft.inReplyTo).toEqual(['parent@example.com']);
expect(draft.references).toEqual(['root@example.com', 'parent@example.com']);
});
+1 -1
View File
@@ -162,7 +162,7 @@ export function resolveTrustedJmapUrl(
}
const matched = servers.find((s) => normalizeUrl(s.url) === target);
if (matched) return matched.url;
// No match caller decides whether to honor the request anyway (e.g. when
// No match - caller decides whether to honor the request anyway (e.g. when
// allowCustomJmapEndpoint is enabled).
return null;
}
+3 -3
View File
@@ -80,7 +80,7 @@ function resolveBundlePath(pluginDir: string, entrypoint: string): ResolvedBundl
/**
* Load and bundle a dev plugin's code. For `src/` sources this runs esbuild
* on every call so saves are reflected immediately. Errors are surfaced as
* a JS module that throws at activation time that way the dev sees the
* a JS module that throws at activation time - that way the dev sees the
* failure in the browser console instead of a silent 404.
*/
export async function readDevBundle(entry: DevPluginEntry): Promise<string> {
@@ -98,7 +98,7 @@ export async function readDevBundle(entry: DevPluginEntry): Promise<string> {
sourcemap: 'inline',
target: ['es2020'],
// React/ReactDOM are exposed on globalThis.__PLUGIN_EXTERNALS__ by the
// host, so we mark them external the bundle won't try to ship them.
// host, so we mark them external - the bundle won't try to ship them.
external: ['react', 'react-dom', 'react/jsx-runtime'],
});
const out = result.outputFiles?.[0]?.text;
@@ -143,7 +143,7 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
}
// Hash from the on-disk source so any edit propagates. For src/ sources
// we hash the source close enough for dev-time change detection (we
// we hash the source - close enough for dev-time change detection (we
// don't need to re-hash transitive imports).
let bundleHash: string;
try {
+1 -1
View File
@@ -7,7 +7,7 @@
* In-Reply-To = parent.Message-ID
* References = parent.References (if any) + parent.Message-ID
*
* Bare msg-ids only angle brackets are stripped because JMAP RFC 8621
* Bare msg-ids only - angle brackets are stripped because JMAP RFC 8621
* §4.1.2.3 stores Message-IDs without them.
*/
+1 -1
View File
@@ -304,7 +304,7 @@ function stripMessageIdBrackets(id: string): string {
// form: `Display Name <addr@example.com>`. Re-emitting that as the JMAP
// from.name field produces a doubled From header (`"Name <addr>" <addr>`)
// whose display-name is invalid per RFC 5322 §3.4 and gets rejected by the
// submission validator the email then sits forever in Drafts.
// submission validator - the email then sits forever in Drafts.
function sanitizeIdentityDisplayName(name: string | undefined | null): string {
if (!name) return '';
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
+3 -3
View File
@@ -115,7 +115,7 @@ function createPluginLogger(pluginId: string) {
* declared `httpOrigins` patterns. Patterns are either a literal origin
* (`https://host[:port]`) or a wildcard subdomain form (`https://*.host`).
*
* Wildcards match exactly one subdomain layer above `host` e.g.
* Wildcards match exactly one subdomain layer above `host` - e.g.
* `https://*.example.com` matches `https://a.example.com` but NOT
* `https://example.com` and NOT `https://a.b.example.com`. This mirrors how
* the CSP frame-src handles wildcards and avoids accidentally widening
@@ -153,7 +153,7 @@ function originMatchesAllowlist(url: URL, allowlist: string[]): boolean {
export interface PluginFetchInit {
/** HTTP method. Defaults to GET. */
method?: string;
/** Request headers. Plain object only no Headers / cookies forwarded. */
/** Request headers. Plain object only - no Headers / cookies forwarded. */
headers?: Record<string, string>;
/** Body. Plain string, ArrayBuffer, Uint8Array, Blob, or FormData. */
body?: string | ArrayBuffer | ArrayBufferView | Blob | FormData | null;
@@ -210,7 +210,7 @@ export interface PluginAPI {
* Cross-origin fetch against an origin declared in the manifest's
* `httpOrigins` allowlist. Requires `http:fetch` permission.
*
* No webmail credentials are forwarded the plugin must supply its own
* No webmail credentials are forwarded - the plugin must supply its own
* `Authorization` (or other auth) header. Each call is gated on origin
* even when the URL came from plugin settings, so a user-pasted URL
* outside the allowlist is rejected at the boundary.
+1 -1
View File
@@ -1,4 +1,4 @@
// Projection helpers convert host-internal types into the read-only views
// Projection helpers - convert host-internal types into the read-only views
// that plugins consume. Keeping this in one place ensures every slot/hook
// hands plugins the same shape declared in plugin-types.ts.
+1 -1
View File
@@ -678,7 +678,7 @@ export interface SelectionContext {
export interface ConflictWarning {
/** Stable unique key per warning, used as React key */
key: string;
/** Short message e.g. "Conflicts with: Team Standup" */
/** Short message - e.g. "Conflicts with: Team Standup" */
message: string;
severity?: 'info' | 'warning' | 'error';
}
+1 -1
View File
@@ -19,7 +19,7 @@ export function isSupportedSubAddressDelimiter(value: string): value is SubAddre
}
// RFC 5321 atext "special" characters, minus alphanumerics and "@". A custom
// delimiter must be exactly one of these they're safe to embed in a local
// delimiter must be exactly one of these - they're safe to embed in a local
// part and unambiguously separate the user from the tag.
const VALID_DELIMITER_REGEX = /^[!#$%&'*+\-./=?^_`{|}~]$/;
+1 -1
View File
@@ -51,7 +51,7 @@ export async function fetchStatus(
if (!endpoint) return { ok: false, error: 'endpoint blank' };
if (!currentVersion) return { ok: false, error: 'current version blank' };
// Build the URL safely never inject the version as a raw path component.
// Build the URL safely - never inject the version as a raw path component.
let url: URL;
try {
url = new URL(endpoint);
+1 -1
View File
@@ -71,7 +71,7 @@ async function tick(): Promise<void> {
await scheduleNext(delay);
}
// Idempotent safe to call from instrumentation hot-reload in dev.
// Idempotent - safe to call from instrumentation hot-reload in dev.
export async function startScheduler(): Promise<void> {
if (disabledByEnv()) {
logger.info('version-check: scheduler not started (disabled by env)');
+1 -1
View File
@@ -25,7 +25,7 @@ const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
// Only `EmailDelivery` state-changes when new mail is actually delivered.
// `Email` fires for any mutation (sending, drafting, moving, marking read,
// deleting) and `Mailbox` fires for mailbox edits both produced spurious
// deleting) and `Mailbox` fires for mailbox edits - both produced spurious
// system notifications, so we keep them out of the push subscription.
// In-app sync uses a separate StateChange channel and is unaffected.
const PUSH_TYPES = ['EmailDelivery'] as const;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.6.1",
"version": "1.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.6.1",
"version": "1.6.2",
"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.6.1",
"version": "1.6.2",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+4 -4
View File
@@ -408,7 +408,7 @@ export const useAuthStore = create<AuthState>()(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
// Note: server_id isn't passed here the route looks up the
// Note: server_id isn't passed here - the route looks up the
// server entry by serverUrl, so per-server OAuth still applies
// for password+TOTP logins through the dropdown.
});
@@ -607,7 +607,7 @@ export const useAuthStore = create<AuthState>()(
// Determine slot for this account (use slot from sessionStorage if re-adding).
// Note: `parseInt(getItem(...) || '0')` collapses "no value set" and
// "value is 0" into the same case, so the fallback to getNextCookieSlot()
// never fired for the common "+ Add Account" path every OAuth account
// never fired for the common "+ Add Account" path - every OAuth account
// ended up on slot 0 and overwrote earlier accounts' refresh-token cookies.
// Distinguishing rawSlot === null from a parsed 0 fixes that. The page
// also writes oauth_cookie_slot before redirecting to the IdP.
@@ -809,7 +809,7 @@ export const useAuthStore = create<AuthState>()(
isDefault: accountStore.accounts.length === 0,
});
// The refresh-token cookie was written to `slot` by /api/auth/sso/complete.
// Force the stored cookieSlot to match see loginWithOAuth above for the
// Force the stored cookieSlot to match - see loginWithOAuth above for the
// re-add and concurrent-tab cases this guards against.
accountStore.updateAccount(accountId, { cookieSlot: slot });
accountStore.setActiveAccount(accountId);
@@ -1241,7 +1241,7 @@ export const useAuthStore = create<AuthState>()(
for (const account of accounts) {
if (clients.has(account.id)) continue; // Already connected
// Basic auth without rememberMe leaves nothing to restore the
// Basic auth without rememberMe leaves nothing to restore - the
// user logged in without persisting credentials. Evict silently
// so the login screen is shown without flagging a fake error.
if (account.authMode === 'basic' && !account.rememberMe) {
+1 -1
View File
@@ -41,7 +41,7 @@ export const useUpdateStore = create<UpdateState>()((set, get) => ({
lastFetchedAt: Date.now(),
});
} catch {
// Silent banner just won't appear, no need to disrupt the UI.
// Silent - banner just won't appear, no need to disrupt the UI.
} finally {
set({ loading: false });
inFlight = null;