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 # 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) ## 1.6.1 (2026-05-04)
### Features ### Features
+1 -1
View File
@@ -9,7 +9,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_BASE_PATH= ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
# Commit SHA shown in the About screen. .dockerignore excludes .git, so # 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 ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT ENV GIT_COMMIT=$GIT_COMMIT
RUN npx next build --webpack RUN npx next build --webpack
+1 -1
View File
@@ -543,7 +543,7 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); 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. // when the admin hasn't configured a server list.
const effectiveServerUrl = selectedServer?.url const effectiveServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl); || (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 // System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or // 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 // 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 // finished loading and open the right message, then strip the params so a
// refresh doesn't re-open it. // refresh doesn't re-open it.
@@ -1610,7 +1610,7 @@ export default function Home() {
const originalEmailId = selectedEmail.id; 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({ const threading = computeReplyThreadingHeaders({
messageId: selectedEmail.messageId, messageId: selectedEmail.messageId,
references: selectedEmail.references, references: selectedEmail.references,
@@ -1674,7 +1674,7 @@ export default function Home() {
} }
// Show the list stub immediately so subject/sender render without // 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); const listEmail = emails.find(e => e.id === email.id);
if (listEmail) { if (listEmail) {
selectEmail(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 // For plugin-setting sub-results, ask the plugins tab to expand the
// matching card so the field becomes part of the DOM. Dispatched here // matching card so the field becomes part of the DOM. Dispatched here
// (not in the click handler) because PluginsSettings only mounts after // (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 // child effects run before parent effects, so by the time we get here
// the listener is guaranteed to be in place. // the listener is guaranteed to be in place.
if (pendingHighlight.pluginId) { if (pendingHighlight.pluginId) {
@@ -534,7 +534,7 @@ export default function SettingsPage() {
// First attempt next frame so the freshly-mounted tab content is in DOM. // First attempt next frame so the freshly-mounted tab content is in DOM.
const raf = window.requestAnimationFrame(tryHighlight); 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. // and the cleanup below would strip the class right after we added it.
return () => { return () => {
cancelled = true; cancelled = true;
+1 -1
View File
@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation'; 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. // Old deep links land on the plugins tab; the user clicks the gear again.
export default function Page() { export default function Page() {
redirect('/admin?tab=plugins'); redirect('/admin?tab=plugins');
+1 -1
View File
@@ -43,7 +43,7 @@ export async function GET() {
/** /**
* POST /api/admin/version * 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) { export async function POST(req: NextRequest) {
try { 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 // caller cannot point this route at internal hosts. We accept the global
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches, // `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
// we fall back to the request URL only if `allowCustomJmapEndpoint` is on // 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(); await configManager.ensureLoaded();
const configuredServerUrl = const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') || 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 // Pin the upstream URL to a configured JMAP server. The list of allowed
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when // servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
// no server is configured (and the deployment explicitly allows custom // 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. // it must resolve to a public address.
await configManager.ensureLoaded(); await configManager.ensureLoaded();
const configuredServerUrl = const configuredServerUrl =
+1 -1
View File
@@ -163,7 +163,7 @@ export async function GET(request: NextRequest) {
}, },
}); });
} catch (error) { } 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`. // (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } }; const err = error as Error & { cause?: { code?: string; message?: string } };
logger.error('push preview failed', { logger.error('push preview failed', {
+1 -1
View File
@@ -963,7 +963,7 @@ export function EmailComposer({
return ''; 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') const threadingHeaders = (mode === 'reply' || mode === 'replyAll')
? computeReplyThreadingHeaders(replyTo) ? computeReplyThreadingHeaders(replyTo)
: null; : null;
+4 -4
View File
@@ -2697,7 +2697,7 @@ export function EmailViewer({
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light'; const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
// Bare HTML emails (no <style>) tend to be plain prose without their own // 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'; const bodyPadding = effectiveEmailContent.hasStyleTag ? '0' : '1rem 1.25rem';
return `<!DOCTYPE html> return `<!DOCTYPE html>
@@ -2759,7 +2759,7 @@ export function EmailViewer({
}, []); }, []);
// Whenever permission is granted (allow toggled, or sender becomes trusted), // 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 senderEmailLower = email?.from?.[0]?.email?.toLowerCase();
const senderIsTrustedNow = senderEmailLower const senderIsTrustedNow = senderEmailLower
? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower)) ? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower))
@@ -2771,7 +2771,7 @@ export function EmailViewer({
}, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]); }, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]);
// Tracks the last rendered body height so the loading skeleton can hold // 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); const lastBodyHeightRef = useRef<number>(300);
// True while the new email's body is still being fetched. Catches the // 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 const fullDate = (iso?: string) => iso
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' }) ? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
: ''; : '-';
const auth = email.authenticationResults; const auth = email.authenticationResults;
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0); const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
const topMimeType = email.bodyStructure?.type; const topMimeType = email.bodyStructure?.type;
+1 -1
View File
@@ -185,7 +185,7 @@ export function NavigationRail({
// Account list for rail // Account list for rail
const accounts = useAccountStore((s) => s.accounts); const accounts = useAccountStore((s) => s.accounts);
// Read activeAccountId from authStore so the rail's account row matches the actually-loaded // 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 activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount); const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout); 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 the resume step is beyond the current steps, start from 0
if (resumeStep >= steps.length) resumeStep = 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. // so we don't start the tour on a page where the targets don't exist.
const targetPage = steps[resumeStep]?.page ?? "/"; const targetPage = steps[resumeStep]?.page ?? "/";
if (pathname !== targetPage) { 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 create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0]; 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.inReplyTo).toEqual(['parent@example.com']);
expect(draft.references).toEqual(['root@example.com', '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); const matched = servers.find((s) => normalizeUrl(s.url) === target);
if (matched) return matched.url; 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). // allowCustomJmapEndpoint is enabled).
return null; 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 * 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 * 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. * failure in the browser console instead of a silent 404.
*/ */
export async function readDevBundle(entry: DevPluginEntry): Promise<string> { export async function readDevBundle(entry: DevPluginEntry): Promise<string> {
@@ -98,7 +98,7 @@ export async function readDevBundle(entry: DevPluginEntry): Promise<string> {
sourcemap: 'inline', sourcemap: 'inline',
target: ['es2020'], target: ['es2020'],
// React/ReactDOM are exposed on globalThis.__PLUGIN_EXTERNALS__ by the // 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'], external: ['react', 'react-dom', 'react/jsx-runtime'],
}); });
const out = result.outputFiles?.[0]?.text; 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 // 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). // don't need to re-hash transitive imports).
let bundleHash: string; let bundleHash: string;
try { try {
+1 -1
View File
@@ -7,7 +7,7 @@
* In-Reply-To = parent.Message-ID * In-Reply-To = parent.Message-ID
* References = parent.References (if any) + 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. * §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 // form: `Display Name <addr@example.com>`. Re-emitting that as the JMAP
// from.name field produces a doubled From header (`"Name <addr>" <addr>`) // 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 // 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 { function sanitizeIdentityDisplayName(name: string | undefined | null): string {
if (!name) return ''; if (!name) return '';
return name.replace(/\s*<[^>]*>\s*$/, '').trim(); 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 * declared `httpOrigins` patterns. Patterns are either a literal origin
* (`https://host[:port]`) or a wildcard subdomain form (`https://*.host`). * (`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` matches `https://a.example.com` but NOT
* `https://example.com` and NOT `https://a.b.example.com`. This mirrors how * `https://example.com` and NOT `https://a.b.example.com`. This mirrors how
* the CSP frame-src handles wildcards and avoids accidentally widening * the CSP frame-src handles wildcards and avoids accidentally widening
@@ -153,7 +153,7 @@ function originMatchesAllowlist(url: URL, allowlist: string[]): boolean {
export interface PluginFetchInit { export interface PluginFetchInit {
/** HTTP method. Defaults to GET. */ /** HTTP method. Defaults to GET. */
method?: string; method?: string;
/** Request headers. Plain object only no Headers / cookies forwarded. */ /** Request headers. Plain object only - no Headers / cookies forwarded. */
headers?: Record<string, string>; headers?: Record<string, string>;
/** Body. Plain string, ArrayBuffer, Uint8Array, Blob, or FormData. */ /** Body. Plain string, ArrayBuffer, Uint8Array, Blob, or FormData. */
body?: string | ArrayBuffer | ArrayBufferView | Blob | FormData | null; 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 * Cross-origin fetch against an origin declared in the manifest's
* `httpOrigins` allowlist. Requires `http:fetch` permission. * `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 * `Authorization` (or other auth) header. Each call is gated on origin
* even when the URL came from plugin settings, so a user-pasted URL * even when the URL came from plugin settings, so a user-pasted URL
* outside the allowlist is rejected at the boundary. * 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 // that plugins consume. Keeping this in one place ensures every slot/hook
// hands plugins the same shape declared in plugin-types.ts. // hands plugins the same shape declared in plugin-types.ts.
+1 -1
View File
@@ -678,7 +678,7 @@ export interface SelectionContext {
export interface ConflictWarning { export interface ConflictWarning {
/** Stable unique key per warning, used as React key */ /** Stable unique key per warning, used as React key */
key: string; key: string;
/** Short message e.g. "Conflicts with: Team Standup" */ /** Short message - e.g. "Conflicts with: Team Standup" */
message: string; message: string;
severity?: 'info' | 'warning' | 'error'; 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 // 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. // part and unambiguously separate the user from the tag.
const VALID_DELIMITER_REGEX = /^[!#$%&'*+\-./=?^_`{|}~]$/; 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 (!endpoint) return { ok: false, error: 'endpoint blank' };
if (!currentVersion) return { ok: false, error: 'current version 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; let url: URL;
try { try {
url = new URL(endpoint); url = new URL(endpoint);
+1 -1
View File
@@ -71,7 +71,7 @@ async function tick(): Promise<void> {
await scheduleNext(delay); 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> { export async function startScheduler(): Promise<void> {
if (disabledByEnv()) { if (disabledByEnv()) {
logger.info('version-check: scheduler not started (disabled by env)'); 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. // Only `EmailDelivery` state-changes when new mail is actually delivered.
// `Email` fires for any mutation (sending, drafting, moving, marking read, // `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. // system notifications, so we keep them out of the push subscription.
// In-app sync uses a separate StateChange channel and is unaffected. // In-app sync uses a separate StateChange channel and is unaffected.
const PUSH_TYPES = ['EmailDelivery'] as const; const PUSH_TYPES = ['EmailDelivery'] as const;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.6.1", "version": "1.6.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.6.1", "version": "1.6.2",
"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
View File
@@ -1,6 +1,6 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.6.1", "version": "1.6.2",
"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",
+4 -4
View File
@@ -408,7 +408,7 @@ export const useAuthStore = create<AuthState>()(
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }), 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 // server entry by serverUrl, so per-server OAuth still applies
// for password+TOTP logins through the dropdown. // 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). // Determine slot for this account (use slot from sessionStorage if re-adding).
// Note: `parseInt(getItem(...) || '0')` collapses "no value set" and // Note: `parseInt(getItem(...) || '0')` collapses "no value set" and
// "value is 0" into the same case, so the fallback to getNextCookieSlot() // "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. // ended up on slot 0 and overwrote earlier accounts' refresh-token cookies.
// Distinguishing rawSlot === null from a parsed 0 fixes that. The page // Distinguishing rawSlot === null from a parsed 0 fixes that. The page
// also writes oauth_cookie_slot before redirecting to the IdP. // also writes oauth_cookie_slot before redirecting to the IdP.
@@ -809,7 +809,7 @@ export const useAuthStore = create<AuthState>()(
isDefault: accountStore.accounts.length === 0, isDefault: accountStore.accounts.length === 0,
}); });
// The refresh-token cookie was written to `slot` by /api/auth/sso/complete. // 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. // re-add and concurrent-tab cases this guards against.
accountStore.updateAccount(accountId, { cookieSlot: slot }); accountStore.updateAccount(accountId, { cookieSlot: slot });
accountStore.setActiveAccount(accountId); accountStore.setActiveAccount(accountId);
@@ -1241,7 +1241,7 @@ export const useAuthStore = create<AuthState>()(
for (const account of accounts) { for (const account of accounts) {
if (clients.has(account.id)) continue; // Already connected 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 // user logged in without persisting credentials. Evict silently
// so the login screen is shown without flagging a fake error. // so the login screen is shown without flagging a fake error.
if (account.authMode === 'basic' && !account.rememberMe) { if (account.authMode === 'basic' && !account.rememberMe) {
+1 -1
View File
@@ -41,7 +41,7 @@ export const useUpdateStore = create<UpdateState>()((set, get) => ({
lastFetchedAt: Date.now(), lastFetchedAt: Date.now(),
}); });
} catch { } 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 { } finally {
set({ loading: false }); set({ loading: false });
inFlight = null; inFlight = null;