chore: update version to 1.6.2
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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', '') ||
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = /^[!#$%&'*+\-./=?^_`{|}~]$/;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user