Rewrite the small helpers and their tests #121

Merged
jcoffey-dev merged 1 commits from phase2/libs into main 2026-10-19 00:54:21 +00:00
20 changed files with 728 additions and 776 deletions

No files matched your search

+18 -11
View File
@@ -1,28 +1,35 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useState, type Dispatch, type SetStateAction } from 'react';
/**
* A local copy of `value` that a field edits freely (half-typed text, say),
* replaced whenever `value` itself changes. `transform` turns the value into
* what the field edits. The copy is replaced during render, not in an
* effect, so the field never shows the old value for a frame.
*/
export function useBufferedValue<T, S = T>(
value: T,
transform: (v: T) => S = (v) => v as unknown as S,
): [S, Dispatch<SetStateAction<S>>] {
const [prev, setPrev] = useState(value);
const [local, setLocal] = useState<S>(() => transform(value));
if (!Object.is(value, prev)) {
setPrev(value);
setLocal(transform(value));
const [source, setSource] = useState(value);
const [draft, setDraft] = useState<S>(() => transform(value));
if (!Object.is(source, value)) {
setSource(value);
setDraft(transform(value));
}
return [local, setLocal];
return [draft, setDraft];
}
/** Calls `reset` during render whenever `key` changes, to drop state tied to the old key. */
export function useResetOnChange<K>(key: K, reset: () => void): void {
const [prev, setPrev] = useState(key);
if (!Object.is(key, prev)) {
setPrev(key);
const [seen, setSeen] = useState(key);
if (!Object.is(seen, key)) {
setSeen(key);
reset();
}
}
+6 -8
View File
@@ -1,21 +1,19 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect } from 'react';
const APP_NAME = 'inbuxa Admin';
const APP = 'inbuxa Admin';
export function useDocumentTitle(title?: string | null) {
/** Titles the browser tab "Page · inbuxa Admin" while the page is shown. */
export function useDocumentTitle(title?: string | null): void {
useEffect(() => {
document.title = title ? `${title} · ${APP_NAME}` : APP_NAME;
document.title = title ? `${title} · ${APP}` : APP;
return () => {
document.title = APP_NAME;
document.title = APP;
};
}, [title]);
}
+9 -8
View File
@@ -1,15 +1,16 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
* SPDX-License-Identifier: AGPL-3.0-only
*/
let cached: string | undefined;
let basePath: string | undefined;
/**
* Where the console is served, from the page's `<base href>`, without the
* trailing slash: '' at the root, '/admin' under a path. Read once.
*/
export function getBasePath(): string {
if (cached !== undefined) return cached;
const base = document.querySelector('base')?.getAttribute('href') ?? '/';
cached = base.replace(/\/+$/, '');
return cached;
basePath ??= (document.querySelector('base')?.getAttribute('href') ?? '/').replace(/\/+$/, '');
return basePath;
}
+44
View File
@@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import { describeInvocations, flagOn } from './debug';
describe('flagOn', () => {
it('turns on for any value but empty, 0 or false, in development only', () => {
expect(flagOn('1', true)).toBe(true);
expect(flagOn('yes', true)).toBe(true);
expect(flagOn('FALSE', true)).toBe(false);
expect(flagOn('0', true)).toBe(false);
expect(flagOn('', true)).toBe(false);
expect(flagOn(undefined, true)).toBe(false);
expect(flagOn('1', false)).toBe(false);
});
});
describe('describeInvocations', () => {
it('sums up calls and responses', () => {
expect(
describeInvocations([
['x:Domain/get', { accountId: 'a', ids: null }, 'c0'],
['x:Domain/set', { update: { d1: {}, d2: {} }, destroy: ['d3'] }, 'c1'],
['x:Domain/query', { ids: ['a', 'b'], total: 2 }, 'c2'],
['error', { type: 'forbidden' }, 'c3'],
]),
).toEqual([
{ method: 'x:Domain/get', callId: 'c0', ids: 'all' },
{ method: 'x:Domain/set', callId: 'c1', update: 2, destroy: 1 },
{ method: 'x:Domain/query', callId: 'c2', ids: 2, total: 2 },
{ method: 'error', callId: 'c3', error: 'forbidden' },
]);
});
it('copes with anything that is not a list of invocations', () => {
expect(describeInvocations(undefined)).toEqual([]);
expect(describeInvocations(['oops'])).toEqual([{ method: '?', callId: '?' }]);
});
});
+56 -84
View File
@@ -1,108 +1,80 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
* SPDX-License-Identifier: AGPL-3.0-only
*/
const isDev = import.meta.env.DEV;
/**
* Console logging for development builds, switched on in `.env.local`:
*
* VITE_DEBUG_JMAP=1 every JMAP request and its responses
* VITE_DEBUG_FORMS=1 every form change, as the data that would be saved
*
* Production builds never log, whatever the variables say.
*/
function isEnabled(envVar: string | undefined): boolean {
if (!isDev || !envVar) return false;
const v = envVar.toLowerCase();
return v !== '' && v !== 'false' && v !== '0';
/** Whether a debug variable turns logging on: set, and not "0" or "false". */
export function flagOn(value: string | undefined, dev = import.meta.env.DEV): boolean {
return dev && !!value && !['0', 'false'].includes(value.toLowerCase());
}
export const debugJmap = isEnabled(import.meta.env.VITE_DEBUG_JMAP);
export const debugForms = isEnabled(import.meta.env.VITE_DEBUG_FORMS);
export const debugJmap = flagOn(import.meta.env.VITE_DEBUG_JMAP);
export const debugForms = flagOn(import.meta.env.VITE_DEBUG_FORMS);
type JmapCall = [string, Record<string, unknown>, string];
const count = (v: unknown) => (Array.isArray(v) ? v.length : v && typeof v === 'object' ? Object.keys(v).length : 0);
function summarizeArgs(args: Record<string, unknown>): string {
const parts: string[] = [];
if (typeof args.accountId === 'string') {
parts.push(`account=${truncate(args.accountId, 12)}`);
}
if (Array.isArray(args.ids)) {
parts.push(`ids=${args.ids.length}`);
} else if (args.ids === null) {
parts.push('ids=all');
}
if (args.create && typeof args.create === 'object') {
parts.push(`create=${Object.keys(args.create).length}`);
}
if (args.update && typeof args.update === 'object') {
parts.push(`update=${Object.keys(args.update).length}`);
}
if (Array.isArray(args.destroy)) {
parts.push(`destroy=${args.destroy.length}`);
}
if (Array.isArray(args.list)) {
parts.push(`list=${args.list.length}`);
}
if (typeof args.total === 'number') {
parts.push(`total=${args.total}`);
}
return parts.join(' ');
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
function buildSummaryRows(calls: unknown): Array<Record<string, string>> {
if (!Array.isArray(calls)) return [];
return calls.map((call, idx) => {
if (!Array.isArray(call) || call.length < 3) {
return { '#': String(idx), method: '?', callId: '?', summary: '' };
/** One row per method call or response: what it is and how much it touches. */
export function describeInvocations(invocations: unknown): Record<string, string | number>[] {
if (!Array.isArray(invocations)) return [];
return invocations.map((inv) => {
const [name, args, callId] = Array.isArray(inv) ? inv : [];
const a: Record<string, unknown> = args && typeof args === 'object' ? args : {};
const row: Record<string, string | number> = { method: String(name ?? '?'), callId: String(callId ?? '?') };
if (a.ids === null) row.ids = 'all';
else if (Array.isArray(a.ids)) row.ids = a.ids.length;
for (const key of [
'create',
'update',
'destroy',
'list',
'created',
'updated',
'destroyed',
'notCreated',
'notUpdated',
'notDestroyed',
]) {
if (a[key] != null && count(a[key])) row[key] = count(a[key]);
}
const [method, args, callId] = call as JmapCall;
return {
'#': String(idx),
method,
callId,
summary: args && typeof args === 'object' ? summarizeArgs(args) : '',
};
if (typeof a.total === 'number') row.total = a.total;
if (name === 'error' && typeof a.type === 'string') row.error = a.type;
return row;
});
}
export function logJmapExchange(methodCalls: unknown, methodResponses: unknown, durationMs?: number): void {
if (!debugJmap) return;
const hasError = Array.isArray(methodResponses) && methodResponses.some((r) => Array.isArray(r) && r[0] === 'error');
const methodNames = Array.isArray(methodCalls)
? methodCalls.map((c) => (Array.isArray(c) && typeof c[0] === 'string' ? c[0] : '?')).join(' · ')
: '?';
const durationLabel = typeof durationMs === 'number' ? ` (${Math.round(durationMs)}ms)` : '';
const headerColor = hasError ? '#dc2626' : '#2563eb';
const responses = describeInvocations(methodResponses);
const failed = responses.some((r) => r.method === 'error');
const names = describeInvocations(methodCalls)
.map((r) => r.method)
.join(', ');
const took = durationMs === undefined ? '' : ` ${Math.round(durationMs)} ms`;
console.groupCollapsed(
`%c[JMAP]%c ${methodNames}${durationLabel}${hasError ? ' (with errors)' : ''}`,
`color: ${headerColor}; font-weight: bold`,
'color: inherit',
`%cJMAP%c ${names}${took}${failed ? ' — error' : ''}`,
`color:${failed ? '#dc2626' : '#0d9488'};font-weight:600`,
'',
);
console.groupCollapsed('%c→ request', 'color: #2563eb; font-weight: bold');
console.table(buildSummaryRows(methodCalls));
console.log(JSON.stringify(methodCalls, null, 2));
console.groupEnd();
console.groupCollapsed(
`%c← response${hasError ? ' (with errors)' : ''}`,
`color: ${hasError ? '#dc2626' : '#16a34a'}; font-weight: bold`,
);
console.table(buildSummaryRows(methodResponses));
console.log(JSON.stringify(methodResponses, null, 2));
console.groupEnd();
console.table(describeInvocations(methodCalls));
console.table(responses);
console.debug('request', methodCalls);
console.debug('responses', methodResponses);
console.groupEnd();
}
export function logFormChange(label: string, data: unknown): void {
if (!debugForms) return;
console.groupCollapsed(`%c[Form]%c ${label}`, 'color: #9333ea; font-weight: bold', 'color: inherit');
console.log(JSON.stringify(data, null, 2));
console.groupCollapsed(`%cForm%c ${label}`, 'color:#7c3aed;font-weight:600', '');
console.debug(data);
console.groupEnd();
}
+77 -207
View File
@@ -1,229 +1,99 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
bytesToHuman,
humanToBytes,
formatSize,
msToHuman,
humanToMs,
formatDuration,
SIZE_UNITS,
DURATION_UNITS,
SIZE_UNITS,
bytesToHuman,
formatDuration,
formatSize,
humanToBytes,
humanToMs,
msToHuman,
} from './durationFormat';
describe('SIZE_UNITS', () => {
it('should contain the expected units in order', () => {
describe('sizes', () => {
it('lists the units a size field offers, smallest first', () => {
expect(SIZE_UNITS).toEqual(['B', 'KB', 'MB', 'GB', 'TB']);
});
it.each([
[0, 0, 'B'],
[1, 1, 'B'],
[1023, 1023, 'B'],
[1024, 1, 'KB'],
[1500, 1.46, 'KB'],
[1536, 1.5, 'KB'],
[5 * 1024 ** 2, 5, 'MB'],
[1024 ** 3 + 1, 1, 'GB'],
[3 * 1024 ** 4, 3, 'TB'],
[2048 * 1024 ** 4, 2048, 'TB'],
])('shows %d bytes as %d %s', (bytes, value, unit) => {
expect(bytesToHuman(bytes)).toEqual({ value, unit });
});
it('leaves values under one byte as bytes', () => {
expect(bytesToHuman(0.5)).toEqual({ value: 0.5, unit: 'B' });
expect(bytesToHuman(-10)).toEqual({ value: -10, unit: 'B' });
});
it('turns a value and unit back into whole bytes', () => {
expect(humanToBytes(1.5, 'KB')).toBe(1536);
expect(humanToBytes(1.46, 'KB')).toBe(1495);
expect(humanToBytes(2, 'GB')).toBe(2 * 1024 ** 3);
expect(humanToBytes(7, 'B')).toBe(7);
});
it('passes the value through for a unit it does not know', () => {
expect(humanToBytes(12, 'PB')).toBe(12);
});
it('formats as "value unit"', () => {
expect(formatSize(0)).toBe('0 B');
expect(formatSize(1536)).toBe('1.5 KB');
expect(formatSize(50 * 1024 ** 2)).toBe('50 MB');
});
});
describe('DURATION_UNITS', () => {
it('should contain the expected units in order', () => {
describe('durations', () => {
it('lists the units a duration field offers, smallest first', () => {
expect(DURATION_UNITS).toEqual(['ms', 's', 'min', 'h', 'd']);
});
});
describe('bytesToHuman', () => {
it('should return 0 B for 0 bytes', () => {
expect(bytesToHuman(0)).toEqual({ value: 0, unit: 'B' });
it.each([
[0, 0, 'ms'],
[999, 999, 'ms'],
[1000, 1, 's'],
[1500, 1.5, 's'],
[90_000, 1.5, 'min'],
[3_600_000, 1, 'h'],
[5_400_000, 1.5, 'h'],
[86_400_000, 1, 'd'],
[30 * 86_400_000, 30, 'd'],
[100_000, 1.67, 'min'],
])('shows %d ms as %d %s', (ms, value, unit) => {
expect(msToHuman(ms)).toEqual({ value, unit });
});
it('should keep small values in bytes', () => {
expect(bytesToHuman(500)).toEqual({ value: 500, unit: 'B' });
it('turns a value and unit back into whole milliseconds', () => {
expect(humanToMs(1.5, 's')).toBe(1500);
expect(humanToMs(2, 'min')).toBe(120_000);
expect(humanToMs(1, 'd')).toBe(86_400_000);
expect(humanToMs(1.67, 'min')).toBe(100_200);
expect(humanToMs(9, 'weeks')).toBe(9);
});
it('should keep 512 bytes as B', () => {
expect(bytesToHuman(512)).toEqual({ value: 512, unit: 'B' });
});
it('should convert 1024 bytes to 1 KB', () => {
expect(bytesToHuman(1024)).toEqual({ value: 1, unit: 'KB' });
});
it('should convert 1536 bytes to 1.5 KB', () => {
expect(bytesToHuman(1536)).toEqual({ value: 1.5, unit: 'KB' });
});
it('should convert 1500 bytes to 1.46 KB', () => {
expect(bytesToHuman(1500)).toEqual({ value: 1.46, unit: 'KB' });
});
it('should convert 1048576 bytes to 1 MB', () => {
expect(bytesToHuman(1048576)).toEqual({ value: 1, unit: 'MB' });
});
it('should convert 10485760 bytes to 10 MB', () => {
expect(bytesToHuman(10485760)).toEqual({ value: 10, unit: 'MB' });
});
it('should convert 1073741824 bytes to 1 GB', () => {
expect(bytesToHuman(1073741824)).toEqual({ value: 1, unit: 'GB' });
});
it('should convert 1099511627776 bytes to 1 TB', () => {
expect(bytesToHuman(1099511627776)).toEqual({ value: 1, unit: 'TB' });
});
});
describe('humanToBytes', () => {
it('should convert 1 KB to 1024 bytes', () => {
expect(humanToBytes(1, 'KB')).toBe(1024);
});
it('should convert 10 MB to 10485760 bytes', () => {
expect(humanToBytes(10, 'MB')).toBe(10485760);
});
it('should convert 1.5 GB to 1610612736 bytes', () => {
expect(humanToBytes(1.5, 'GB')).toBe(1610612736);
});
it('should convert 0 B to 0', () => {
expect(humanToBytes(0, 'B')).toBe(0);
});
it('should return the raw value for an unknown unit', () => {
expect(humanToBytes(42, 'XYZ')).toBe(42);
});
});
describe('formatSize', () => {
it('should format 0 bytes as "0 B"', () => {
expect(formatSize(0)).toBe('0 B');
});
it('should format 1024 bytes as "1 KB"', () => {
expect(formatSize(1024)).toBe('1 KB');
});
it('should format 1572864 bytes as "1.5 MB"', () => {
expect(formatSize(1572864)).toBe('1.5 MB');
});
it('should format large values in TB', () => {
expect(formatSize(1099511627776)).toBe('1 TB');
});
});
describe('msToHuman', () => {
it('should return 0 ms for 0', () => {
expect(msToHuman(0)).toEqual({ value: 0, unit: 'ms' });
});
it('should keep 500 as ms', () => {
expect(msToHuman(500)).toEqual({ value: 500, unit: 'ms' });
});
it('should convert 1000 ms to 1 s', () => {
expect(msToHuman(1000)).toEqual({ value: 1, unit: 's' });
});
it('should convert 1500 ms to 1.5 s', () => {
expect(msToHuman(1500)).toEqual({ value: 1.5, unit: 's' });
});
it('should convert 60000 ms to 1 min', () => {
expect(msToHuman(60000)).toEqual({ value: 1, unit: 'min' });
});
it('should convert 300000 ms to 5 min', () => {
expect(msToHuman(300000)).toEqual({ value: 5, unit: 'min' });
});
it('should convert 90000 ms to 1.5 min', () => {
expect(msToHuman(90000)).toEqual({ value: 1.5, unit: 'min' });
});
it('should convert 3600000 ms to 1 h', () => {
expect(msToHuman(3600000)).toEqual({ value: 1, unit: 'h' });
});
it('should convert 86400000 ms to 1 d', () => {
expect(msToHuman(86400000)).toEqual({ value: 1, unit: 'd' });
});
});
describe('humanToMs', () => {
it('should convert 5 s to 5000 ms', () => {
expect(humanToMs(5, 's')).toBe(5000);
});
it('should convert 1 min to 60000 ms', () => {
expect(humanToMs(1, 'min')).toBe(60000);
});
it('should convert 2 h to 7200000 ms', () => {
expect(humanToMs(2, 'h')).toBe(7200000);
});
it('should convert 1 d to 86400000 ms', () => {
expect(humanToMs(1, 'd')).toBe(86400000);
});
it('should return the raw value for an unknown unit', () => {
expect(humanToMs(99, 'eons')).toBe(99);
});
});
describe('formatDuration', () => {
it('should format 0 as "0ms"', () => {
it('spells a duration out by its parts', () => {
expect(formatDuration(0)).toBe('0ms');
});
it('should format 500 ms as "500ms"', () => {
expect(formatDuration(500)).toBe('500ms');
});
it('should format 5000 ms as "5s"', () => {
expect(formatDuration(5000)).toBe('5s');
});
it('should format 65000 ms as "1m 5s"', () => {
expect(formatDuration(65000)).toBe('1m 5s');
});
it('should format 3661000 ms as "1h 1m 1s"', () => {
expect(formatDuration(3661000)).toBe('1h 1m 1s');
});
it('should format 86400000 ms as "1d"', () => {
expect(formatDuration(86400000)).toBe('1d');
});
it('should format 90061500 ms as "1d 1h 1m 1s 500ms"', () => {
expect(formatDuration(90061500)).toBe('1d 1h 1m 1s 500ms');
});
});
describe('round-trip: bytes', () => {
it.each([1024, 1048576, 1073741824, 1099511627776])('bytesToHuman -> humanToBytes should recover %d', (original) => {
const { value, unit } = bytesToHuman(original);
expect(humanToBytes(value, unit)).toBe(original);
});
it('should be close for non-exact values', () => {
const original = 123456789;
const { value, unit } = bytesToHuman(original);
const recovered = humanToBytes(value, unit);
expect(Math.abs(recovered - original) / original).toBeLessThan(0.01);
});
});
describe('round-trip: duration', () => {
it.each([1000, 60000, 3600000, 86400000])('msToHuman -> humanToMs should recover %d', (original) => {
const { value, unit } = msToHuman(original);
expect(humanToMs(value, unit)).toBe(original);
});
it('should be close for non-exact values', () => {
const original = 123456;
const { value, unit } = msToHuman(original);
const recovered = humanToMs(value, unit);
expect(Math.abs(recovered - original) / original).toBeLessThan(0.01);
expect(formatDuration(250)).toBe('250ms');
expect(formatDuration(61_000)).toBe('1m 1s');
expect(formatDuration(3_600_000)).toBe('1h');
expect(formatDuration(90_061_001)).toBe('1d 1h 1m 1s 1ms');
expect(formatDuration(2 * 86_400_000 + 30_000)).toBe('2d 30s');
});
});
+44 -76
View File
@@ -1,96 +1,64 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Sizes and durations as people read them: the field widgets show a number
* and a unit, and store bytes or milliseconds.
*/
type Scale = Record<string, number>;
const SIZE_SCALE = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 } satisfies Scale;
const DURATION_SCALE = { ms: 1, s: 1000, min: 60_000, h: 3_600_000, d: 86_400_000 } satisfies Scale;
export const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
export const DURATION_UNITS = ['ms', 's', 'min', 'h', 'd'] as const;
const SIZE_FACTORS: Record<string, number> = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4,
};
export function bytesToHuman(bytes: number): { value: number; unit: string } {
if (bytes === 0) return { value: 0, unit: 'B' };
for (let i = SIZE_UNITS.length - 1; i >= 0; i--) {
const unit = SIZE_UNITS[i];
const factor = SIZE_FACTORS[unit];
const v = bytes / factor;
if (v >= 1) {
const rounded = Math.round(v * 100) / 100;
return { value: rounded, unit };
}
}
return { value: bytes, unit: 'B' };
/** The largest unit that keeps the number at 1 or more, to two decimals. */
function largestUnit(amount: number, scale: Scale, units: readonly string[]) {
const unit = [...units].reverse().find((u) => amount / scale[u] >= 1);
if (!unit) return { value: amount, unit: units[0] };
return { value: Math.round((amount / scale[unit]) * 100) / 100, unit };
}
export function humanToBytes(value: number, unit: string): number {
const factor = SIZE_FACTORS[unit];
if (factor === undefined) return value;
return Math.round(value * factor);
/** A number in a unit back to the base amount; an unknown unit leaves it as is. */
function fromUnit(value: number, unit: string, scale: Scale) {
const factor = scale[unit];
return factor === undefined ? value : Math.round(value * factor);
}
export const bytesToHuman = (bytes: number) => largestUnit(bytes, SIZE_SCALE, SIZE_UNITS);
export const humanToBytes = (value: number, unit: string) => fromUnit(value, unit, SIZE_SCALE);
/** "1.5 KB". */
export function formatSize(bytes: number): string {
const { value, unit } = bytesToHuman(bytes);
return `${value} ${unit}`;
}
export const DURATION_UNITS = ['ms', 's', 'min', 'h', 'd'] as const;
export const msToHuman = (ms: number) => largestUnit(ms, DURATION_SCALE, DURATION_UNITS);
export const humanToMs = (value: number, unit: string) => fromUnit(value, unit, DURATION_SCALE);
const DURATION_FACTORS: Record<string, number> = {
ms: 1,
s: 1_000,
min: 60_000,
h: 3_600_000,
d: 86_400_000,
};
export function msToHuman(ms: number): { value: number; unit: string } {
if (ms === 0) return { value: 0, unit: 'ms' };
for (let i = DURATION_UNITS.length - 1; i >= 0; i--) {
const unit = DURATION_UNITS[i];
const factor = DURATION_FACTORS[unit];
const v = ms / factor;
if (v >= 1) {
const rounded = Math.round(v * 100) / 100;
return { value: rounded, unit };
}
}
return { value: ms, unit: 'ms' };
}
export function humanToMs(value: number, unit: string): number {
const factor = DURATION_FACTORS[unit];
if (factor === undefined) return value;
return Math.round(value * factor);
}
const PARTS: [string, number][] = [
['d', DURATION_SCALE.d],
['h', DURATION_SCALE.h],
['m', DURATION_SCALE.min],
['s', DURATION_SCALE.s],
['ms', 1],
];
/** "1d 2h 3m", leaving out the parts that are zero; "0ms" for nothing. */
export function formatDuration(ms: number): string {
if (ms === 0) return '0ms';
const d = Math.floor(ms / 86_400_000);
ms %= 86_400_000;
const h = Math.floor(ms / 3_600_000);
ms %= 3_600_000;
const m = Math.floor(ms / 60_000);
ms %= 60_000;
const s = Math.floor(ms / 1_000);
ms %= 1_000;
const parts: string[] = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
if (s > 0) parts.push(`${s}s`);
if (ms > 0) parts.push(`${ms}ms`);
return parts.join(' ');
let rest = ms;
const out: string[] = [];
for (const [label, size] of PARTS) {
const n = size === 1 ? rest : Math.floor(rest / size);
rest -= size === 1 ? n : n * size;
if (n > 0) out.push(`${n}${label}`);
}
return out.join(' ');
}
+45
View File
@@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import { friendlySetError, validationErrorMessage } from './jmapErrors';
describe('validationErrorMessage', () => {
it('says what the field needs', () => {
expect(validationErrorMessage({ type: 'Required', property: 'name' })).toBe('This field is required.');
expect(validationErrorMessage({ type: 'MaxLength', property: 'name', required: 64 })).toBe('Maximum length is 64.');
expect(validationErrorMessage({ type: 'MinValue', property: 'port', required: 1 })).toBe('Minimum value is 1.');
});
it("gives the server's reason for an invalid value, when there is one", () => {
expect(validationErrorMessage({ type: 'Invalid', property: 'host', value: 'Not a hostname' })).toBe(
'Not a hostname',
);
expect(validationErrorMessage({ type: 'Invalid', property: 'host', value: '' })).toBe('Invalid value.');
expect(validationErrorMessage({ type: 'Invalid', property: 'host' })).toBe('Invalid value.');
});
});
describe('friendlySetError', () => {
it("prefers the server's own description", () => {
expect(friendlySetError({ type: 'forbidden', description: 'Only tenant admins may do this.' })).toBe(
'Only tenant admins may do this.',
);
});
it('explains the known error types', () => {
expect(friendlySetError({ type: 'objectIsLinked' })).toBe(
'This item is referenced by other items and cannot be deleted.',
);
expect(friendlySetError({ type: 'singleton' })).toBe('This item is a singleton and cannot be deleted.');
});
it('names a type it does not know, including ones that look like object keys', () => {
expect(friendlySetError({ type: 'somethingNew' })).toBe('Unexpected error (somethingNew).');
expect(friendlySetError({ type: 'toString' })).toBe('Unexpected error (toString).');
});
});
+38 -55
View File
@@ -1,66 +1,49 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { JmapSetError, ValidationError } from '@/types/jmap';
/** The server's JMAP errors in words an admin can act on. */
import i18n from '@/i18n';
import type { JmapSetError, ValidationError } from '@/types/jmap';
export function validationErrorMessage(ve: ValidationError): string {
switch (ve.type) {
case 'Required':
return i18n.t('form.required', 'This field is required.');
case 'MaxLength':
return i18n.t('form.maxLengthIs', 'Maximum length is {{max}}.', { max: ve.required });
case 'MinLength':
return i18n.t('form.minLengthIs', 'Minimum length is {{min}}.', { min: ve.required });
case 'MaxValue':
return i18n.t('form.maxValueIs', 'Maximum value is {{max}}.', { max: ve.required });
case 'MinValue':
return i18n.t('form.minValueIs', 'Minimum value is {{min}}.', { min: ve.required });
default:
if (ve.value && ve.value.length > 0) return ve.value;
return i18n.t('form.invalidValue', 'Invalid value.');
}
const t = i18n.t.bind(i18n);
/** The message under a field the server refused. */
export function validationErrorMessage({ type, required, value }: ValidationError): string {
if (type === 'Required') return t('form.required', 'This field is required.');
if (type === 'MaxLength') return t('form.maxLengthIs', 'Maximum length is {{max}}.', { max: required });
if (type === 'MinLength') return t('form.minLengthIs', 'Minimum length is {{min}}.', { min: required });
if (type === 'MaxValue') return t('form.maxValueIs', 'Maximum value is {{max}}.', { max: required });
if (type === 'MinValue') return t('form.minValueIs', 'Minimum value is {{min}}.', { min: required });
// 'Invalid': the server's own reason, when it gave one.
return value || t('form.invalidValue', 'Invalid value.');
}
const SET_ERRORS: Record<string, () => string> = {
forbidden: () => t('jmapErrors.forbidden', 'You do not have permission to perform this action.'),
notFound: () => t('jmapErrors.notFound', 'The item was not found. It may have been already deleted.'),
objectIsLinked: () => t('jmapErrors.objectIsLinked', 'This item is referenced by other items and cannot be deleted.'),
invalidForeignKey: () => t('jmapErrors.invalidForeignKey', 'This item references another item that does not exist.'),
primaryKeyViolation: () => t('jmapErrors.primaryKeyViolation', 'An item with the same key already exists.'),
alreadyExists: () => t('jmapErrors.alreadyExists', 'An item with the same identifier already exists.'),
overQuota: () => t('jmapErrors.overQuota', 'The operation exceeds your storage quota.'),
tooLarge: () => t('jmapErrors.tooLarge', 'The item is too large.'),
rateLimit: () => t('jmapErrors.rateLimit', 'Too many requests. Please try again later.'),
singleton: () => t('jmapErrors.singleton', 'This item is a singleton and cannot be deleted.'),
mailboxHasChild: () => t('jmapErrors.mailboxHasChild', 'This mailbox has child mailboxes. Delete them first.'),
mailboxHasEmail: () => t('jmapErrors.mailboxHasEmail', 'This mailbox contains messages. Remove them first.'),
calendarHasEvent: () => t('jmapErrors.calendarHasEvent', 'This calendar contains events. Remove them first.'),
addressBookHasContents: () =>
t('jmapErrors.addressBookHasContents', 'This address book has contacts. Remove them first.'),
nodeHasChildren: () => t('jmapErrors.nodeHasChildren', 'This item has children. Delete them first.'),
validationFailed: () => t('jmapErrors.validationFailed', 'The data did not pass validation.'),
};
/** Why a create, update or delete failed: the server's description if it sent one, else ours for the type. */
export function friendlySetError(err: JmapSetError): string {
if (err.description) return err.description;
switch (err.type) {
case 'forbidden':
return i18n.t('jmapErrors.forbidden', 'You do not have permission to perform this action.');
case 'notFound':
return i18n.t('jmapErrors.notFound', 'The item was not found. It may have been already deleted.');
case 'objectIsLinked':
return i18n.t('jmapErrors.objectIsLinked', 'This item is referenced by other items and cannot be deleted.');
case 'invalidForeignKey':
return i18n.t('jmapErrors.invalidForeignKey', 'This item references another item that does not exist.');
case 'primaryKeyViolation':
return i18n.t('jmapErrors.primaryKeyViolation', 'An item with the same key already exists.');
case 'alreadyExists':
return i18n.t('jmapErrors.alreadyExists', 'An item with the same identifier already exists.');
case 'overQuota':
return i18n.t('jmapErrors.overQuota', 'The operation exceeds your storage quota.');
case 'tooLarge':
return i18n.t('jmapErrors.tooLarge', 'The item is too large.');
case 'rateLimit':
return i18n.t('jmapErrors.rateLimit', 'Too many requests. Please try again later.');
case 'singleton':
return i18n.t('jmapErrors.singleton', 'This item is a singleton and cannot be deleted.');
case 'mailboxHasChild':
return i18n.t('jmapErrors.mailboxHasChild', 'This mailbox has child mailboxes. Delete them first.');
case 'mailboxHasEmail':
return i18n.t('jmapErrors.mailboxHasEmail', 'This mailbox contains messages. Remove them first.');
case 'calendarHasEvent':
return i18n.t('jmapErrors.calendarHasEvent', 'This calendar contains events. Remove them first.');
case 'addressBookHasContents':
return i18n.t('jmapErrors.addressBookHasContents', 'This address book has contacts. Remove them first.');
case 'nodeHasChildren':
return i18n.t('jmapErrors.nodeHasChildren', 'This item has children. Delete them first.');
case 'validationFailed':
return i18n.t('jmapErrors.validationFailed', 'The data did not pass validation.');
default:
return i18n.t('jmapErrors.unexpected', 'Unexpected error ({{type}}).', { type: err.type });
}
const known = Object.hasOwn(SET_ERRORS, err.type) ? SET_ERRORS[err.type] : undefined;
return known ? known() : t('jmapErrors.unexpected', 'Unexpected error ({{type}}).', { type: err.type });
}
+26
View File
@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import { jmapMapToArray } from './jmapUtils';
describe('jmapMapToArray', () => {
it('keeps an array', () => {
const list = ['a', 'b'];
expect(jmapMapToArray(list)).toBe(list);
});
it('orders an object by its numeric keys', () => {
expect(jmapMapToArray({ '10': 'k', '2': 'c', '0': 'a', '1': 'b' })).toEqual(['a', 'b', 'c', 'k']);
});
it('reads anything else as an empty list', () => {
expect(jmapMapToArray(null)).toEqual([]);
expect(jmapMapToArray(undefined)).toEqual([]);
expect(jmapMapToArray('abc')).toEqual([]);
});
});
+13 -10
View File
@@ -1,17 +1,20 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** What the server sends in place of a secret's value. */
export const SECRET_MASK = '****';
export function jmapMapToArray<T>(val: unknown): T[] {
if (Array.isArray(val)) return val as T[];
if (val && typeof val === 'object') {
return Object.entries(val as Record<string, T>)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, v]) => v);
}
return [];
/**
* A list the server may send as an array or as an object keyed by position
* ("0", "1", …), as an array in that order. Anything else is an empty list.
*/
export function jmapMapToArray<T>(value: unknown): T[] {
if (Array.isArray(value)) return value as T[];
if (value === null || typeof value !== 'object') return [];
const byIndex = Object.entries(value as Record<string, T>);
byIndex.sort((a, b) => Number(a[0]) - Number(b[0]));
return byIndex.map((entry) => entry[1]);
}
+74
View File
@@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Layout, Schema } from '@/types/schema';
const layoutMock = vi.hoisted(() => ({
accessible: new Set<string>(),
firstAccessible: null as string | null,
firstVisible: null as string | null,
}));
vi.mock('@/lib/layout', () => ({
isLinkAccessible: (_s: unknown, view: string) => layoutMock.accessible.has(view),
findFirstAccessibleLinkInLayout: () => layoutMock.firstAccessible,
findFirstVisibleLinkInLayout: () => layoutMock.firstVisible,
}));
import { rememberLastVisited, sectionLandingLink } from './lastVisited';
const schema = {} as Schema;
const settings = { name: 'Settings' } as Layout;
const land = () => sectionLandingLink(schema, settings, 'community', () => true);
describe('last visited page per section', () => {
// In memory: Node's own `localStorage` global, empty without a storage file, hides happy-dom's.
beforeAll(() => {
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
clear: () => store.clear(),
});
});
beforeEach(() => {
localStorage.clear();
layoutMock.accessible = new Set(['x:Domain', 'x:Account']);
layoutMock.firstAccessible = 'x:Overview';
layoutMock.firstVisible = 'x:Locked';
});
it('opens a section on the page last used there', () => {
rememberLastVisited('Settings', 'x:Domain');
rememberLastVisited('Management', 'x:Account');
expect(land()).toBe('x:Domain');
expect(JSON.parse(localStorage.getItem('inbuxa-last-visited')!)).toEqual({
Settings: 'x:Domain',
Management: 'x:Account',
});
});
it('skips a remembered page the admin can no longer open', () => {
rememberLastVisited('Settings', 'x:Gone');
expect(land()).toBe('x:Overview');
});
it('falls back to the first page shown when none may be opened', () => {
layoutMock.firstAccessible = null;
expect(land()).toBe('x:Locked');
});
it('ignores garbled storage', () => {
localStorage.setItem('inbuxa-last-visited', '["x:Domain"]');
expect(land()).toBe('x:Overview');
localStorage.setItem('inbuxa-last-visited', '{not json');
expect(land()).toBe('x:Overview');
rememberLastVisited('Settings', 'x:Domain');
expect(land()).toBe('x:Domain');
});
});
+18 -17
View File
@@ -1,12 +1,14 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Each section (Management, Settings, Account) opens on the page the admin
* last used in it, kept in this browser. A remembered page the admin can no
* longer open is skipped.
*/
import {
findFirstAccessibleLinkInLayout,
findFirstVisibleLinkInLayout,
@@ -16,28 +18,29 @@ import {
} from '@/lib/layout';
import type { Layout, Schema } from '@/types/schema';
const STORAGE_KEY = 'inbuxa-last-visited';
const KEY = 'inbuxa-last-visited';
function readAll(): Record<string, unknown> {
/** Section name → view name; empty when storage is missing, blocked or garbled. */
function remembered(): Record<string, unknown> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
const value: unknown = JSON.parse(localStorage.getItem(KEY) ?? '{}');
return value !== null && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
} catch {
return {};
}
}
export function rememberLastVisited(section: string, viewName: string): void {
const all = remembered();
if (all[section] === viewName) return;
try {
const all = readAll();
if (all[section] === viewName) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...all, [section]: viewName }));
localStorage.setItem(KEY, JSON.stringify({ ...all, [section]: viewName }));
} catch {
return;
// Storage full or blocked: the section opens on its first page instead.
}
}
/** Where a section opens: the page last used there, else its first page the admin may open, else its first page. */
export function sectionLandingLink(
schema: Schema,
layout: Layout,
@@ -45,10 +48,8 @@ export function sectionLandingLink(
canGet: CanGet,
hasPerm?: HasPermission,
): string | null {
const last = readAll()[layout.name];
if (typeof last === 'string' && isLinkAccessible(schema, last, edition, canGet, hasPerm)) {
return last;
}
const last = remembered()[layout.name];
if (typeof last === 'string' && isLinkAccessible(schema, last, edition, canGet, hasPerm)) return last;
return (
findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPerm) ??
findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPerm)
+68 -137
View File
@@ -1,155 +1,86 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect } from 'vitest';
import { buildJmapFilter } from './listFilter';
import type { Filter } from '@/types/schema';
import { describe, expect, it } from 'vitest';
const dateFilter: Filter = { type: 'date', field: 'timestamp', label: 'Date' };
const textFilter: Filter = { type: 'text', field: 'text', label: 'Text' };
const intFilter: Filter = { type: 'integer', field: 'size', label: 'Size' };
import type { Filter } from '@/types/schema';
import { buildJmapFilter } from './listFilter';
const dateFilter = [{ field: 'created', type: 'date' }] as Filter[];
const x = (appliedFilters: Record<string, string>) =>
buildJmapFilter({ appliedFilters, filters: dateFilter, isXPrefixed: true });
describe('buildJmapFilter', () => {
it('returns an empty object when nothing is applied', () => {
expect(buildJmapFilter({ appliedFilters: {}, isXPrefixed: true })).toEqual({});
it('starts from the static filters and adds what the reader picked', () => {
expect(
buildJmapFilter({ appliedFilters: { name: 'ann' }, filtersStatic: { kind: 'user' }, isXPrefixed: true }),
).toEqual({ kind: 'user', name: 'ann' });
expect(buildJmapFilter({ appliedFilters: {}, isXPrefixed: false })).toEqual({});
});
it('merges filtersStatic', () => {
const filter = buildJmapFilter({
appliedFilters: {},
filtersStatic: { hasErrors: true },
isXPrefixed: true,
});
expect(filter).toEqual({ hasErrors: true });
it('leaves out empty values, and operators on their own', () => {
expect(
buildJmapFilter({ appliedFilters: { name: '', size: '5', sizeOp: 'gt', quotaOp: 'lt' }, isXPrefixed: true }),
).toEqual({ sizeIsGreaterThan: '5' });
});
it('skips empty and Op-only keys', () => {
const filter = buildJmapFilter({
appliedFilters: { text: '', textOp: 'eq' },
filters: [textFilter],
isXPrefixed: true,
});
expect(filter).toEqual({});
});
it('passes text filters through verbatim', () => {
const filter = buildJmapFilter({
appliedFilters: { text: 'hello' },
filters: [textFilter],
isXPrefixed: true,
});
expect(filter).toEqual({ text: 'hello' });
});
it('applies the operator suffix for integer filters', () => {
const filter = buildJmapFilter({
appliedFilters: { size: '1024', sizeOp: 'gt' },
filters: [intFilter],
isXPrefixed: true,
});
expect(filter).toEqual({ sizeIsGreaterThan: '1024' });
});
describe('date filters (x-prefixed, with operator UI)', () => {
it('expands a default date-only equality into a full-day range', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-17' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({
timestampIsGreaterThanOrEqual: '2026-06-17T00:00:00Z',
timestampIsLessThan: '2026-06-18T00:00:00Z',
});
});
it('treats an explicit eq operator the same as the default', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-17', timestampOp: 'eq' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({
timestampIsGreaterThanOrEqual: '2026-06-17T00:00:00Z',
timestampIsLessThan: '2026-06-18T00:00:00Z',
});
});
it('appends midnight UTC for the After (gt) operator', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-17', timestampOp: 'gt' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({ timestampIsGreaterThan: '2026-06-17T00:00:00Z' });
});
it('appends midnight UTC for the Before (lt) operator', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-17', timestampOp: 'lt' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({ timestampIsLessThan: '2026-06-17T00:00:00Z' });
});
it('rolls over month boundaries', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-30' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({
timestampIsGreaterThanOrEqual: '2026-06-30T00:00:00Z',
timestampIsLessThan: '2026-07-01T00:00:00Z',
});
});
it('rolls over year boundaries', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-12-31' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({
timestampIsGreaterThanOrEqual: '2026-12-31T00:00:00Z',
timestampIsLessThan: '2027-01-01T00:00:00Z',
});
});
it('handles leap-year February', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2028-02-28' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({
timestampIsGreaterThanOrEqual: '2028-02-28T00:00:00Z',
timestampIsLessThan: '2028-02-29T00:00:00Z',
});
});
it('passes a full datetime through with only the operator suffix applied', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-17T08:30:00Z', timestampOp: 'gt' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({ timestampIsGreaterThan: '2026-06-17T08:30:00Z' });
it.each([
['eq', 'size'],
['gt', 'sizeIsGreaterThan'],
['gte', 'sizeIsGreaterThanOrEqual'],
['lt', 'sizeIsLessThan'],
['lte', 'sizeIsLessThanOrEqual'],
])('names the property for the %s operator: %s', (op, key) => {
expect(buildJmapFilter({ appliedFilters: { size: '10', sizeOp: op }, isXPrefixed: true })).toEqual({
[key]: '10',
});
});
describe('date filters (non-x-prefixed, no operator UI)', () => {
it('appends midnight UTC as an exact match without range expansion', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-17' },
filters: [dateFilter],
isXPrefixed: false,
describe('a date on an x: object', () => {
it('covers the whole UTC day when no operator, or eq, is picked', () => {
const day = {
createdIsGreaterThanOrEqual: '2026-03-14T00:00:00Z',
createdIsLessThan: '2026-03-15T00:00:00Z',
};
expect(x({ created: '2026-03-14' })).toEqual(day);
expect(x({ created: '2026-03-14', createdOp: 'eq' })).toEqual(day);
});
it('runs the day over into the next month, year and leap day', () => {
expect(x({ created: '2026-01-31' }).createdIsLessThan).toBe('2026-02-01T00:00:00Z');
expect(x({ created: '2025-12-31' }).createdIsLessThan).toBe('2026-01-01T00:00:00Z');
expect(x({ created: '2028-02-28' }).createdIsLessThan).toBe('2028-02-29T00:00:00Z');
expect(x({ created: '2027-02-28' }).createdIsLessThan).toBe('2027-03-01T00:00:00Z');
});
it('compares against midnight UTC for before and after', () => {
expect(x({ created: '2026-03-14', createdOp: 'gt' })).toEqual({ createdIsGreaterThan: '2026-03-14T00:00:00Z' });
expect(x({ created: '2026-03-14', createdOp: 'lt' })).toEqual({ createdIsLessThan: '2026-03-14T00:00:00Z' });
});
it('sends a full date and time as it is, with the operator', () => {
expect(x({ created: '2026-03-14T08:30:00Z', createdOp: 'gte' })).toEqual({
createdIsGreaterThanOrEqual: '2026-03-14T08:30:00Z',
});
expect(x({ created: '2026-03-14T08:30:00Z' })).toEqual({ created: '2026-03-14T08:30:00Z' });
});
});
describe('a date on an object without operators', () => {
const plain = (appliedFilters: Record<string, string>) =>
buildJmapFilter({ appliedFilters, filters: dateFilter, isXPrefixed: false });
it('matches midnight UTC of that day exactly', () => {
expect(plain({ created: '2026-03-14' })).toEqual({ created: '2026-03-14T00:00:00Z' });
});
it('sends a full date and time as it is, ignoring any operator', () => {
expect(plain({ created: '2026-03-14T08:30:00Z', createdOp: 'gt' })).toEqual({
created: '2026-03-14T08:30:00Z',
});
expect(filter).toEqual({ timestamp: '2026-06-17T00:00:00Z' });
});
});
});
+42 -65
View File
@@ -1,97 +1,74 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Turns the filters a reader picked above a list into a JMAP `/query`
* filter. A filter `size` with operator `sizeOp: 'gt'` becomes
* `sizeIsGreaterThan`. Dates picked as a day become midnight UTC, and on x:
* objects a plain day means the whole day.
*/
import type { Filter } from '@/types/schema';
const OP_SUFFIX: Record<string, string> = {
eq: '',
const OPERATOR_SUFFIX: Record<string, string> = {
gt: 'IsGreaterThan',
gte: 'IsGreaterThanOrEqual',
lt: 'IsLessThan',
lte: 'IsLessThanOrEqual',
};
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
const suffixFor = (op: string | undefined) => (op ? (OPERATOR_SUFFIX[op] ?? '') : '');
function utcDayStart(date: string): string {
return `${date}T00:00:00Z`;
const isDay = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
const midnight = (day: string) => `${day}T00:00:00Z`;
function nextMidnight(day: string) {
const next = new Date(Date.parse(midnight(day)) + 86_400_000);
return `${next.toISOString().slice(0, 10)}T00:00:00Z`;
}
function utcNextDayStart(date: string): string {
const dt = new Date(`${date}T00:00:00Z`);
dt.setUTCDate(dt.getUTCDate() + 1);
const y = dt.getUTCFullYear();
const m = String(dt.getUTCMonth() + 1).padStart(2, '0');
const d = String(dt.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${d}T00:00:00Z`;
}
function applyDateFilter(
filter: Record<string, unknown>,
field: string,
value: string,
op: string | undefined,
isXPrefixed: boolean,
): void {
if (!DATE_ONLY.test(value)) {
const suffix = isXPrefixed && op ? (OP_SUFFIX[op] ?? '') : '';
filter[`${field}${suffix}`] = value;
return;
/** The filter entries for one date field. */
function dateEntries(field: string, value: string, op: string | undefined, withOps: boolean): [string, string][] {
if (!isDay(value)) return [[field + (withOps ? suffixFor(op) : ''), value]];
if (!withOps) return [[field, midnight(value)]];
if (!op || op === 'eq') {
return [
[`${field}IsGreaterThanOrEqual`, midnight(value)],
[`${field}IsLessThan`, nextMidnight(value)],
];
}
if (!isXPrefixed) {
filter[field] = utcDayStart(value);
return;
}
const effectiveOp = op ?? 'eq';
if (effectiveOp === 'eq') {
filter[`${field}IsGreaterThanOrEqual`] = utcDayStart(value);
filter[`${field}IsLessThan`] = utcNextDayStart(value);
return;
}
const suffix = OP_SUFFIX[effectiveOp] ?? '';
filter[`${field}${suffix}`] = utcDayStart(value);
return [[field + suffixFor(op), midnight(value)]];
}
export interface BuildJmapFilterArgs {
/** What the reader picked: `field: value`, and `fieldOp: operator`. */
appliedFilters: Record<string, string>;
/** The list's filter definitions; they say which fields are dates. */
filters?: Filter[];
/** Always part of the query. */
filtersStatic?: Record<string, unknown>;
/** x: objects take operators and date ranges; others match exactly. */
isXPrefixed: boolean;
}
export function buildJmapFilter({
appliedFilters,
filters,
filters = [],
filtersStatic,
isXPrefixed,
}: BuildJmapFilterArgs): Record<string, unknown> {
const filter: Record<string, unknown> = {};
if (filtersStatic) {
Object.assign(filter, filtersStatic);
const dates = new Set(filters.filter((f) => f.type === 'date').map((f) => f.field));
const out: Record<string, unknown> = { ...filtersStatic };
for (const [field, value] of Object.entries(appliedFilters)) {
if (!value || field.endsWith('Op')) continue;
const op = appliedFilters[`${field}Op`];
const entries: [string, string][] = dates.has(field)
? dateEntries(field, value, op, isXPrefixed)
: [[field + suffixFor(op), value]];
for (const [key, v] of entries) out[key] = v;
}
const dateFields = new Set((filters ?? []).filter((f) => f.type === 'date').map((f) => f.field));
for (const [key, val] of Object.entries(appliedFilters)) {
if (val === '' || val == null) continue;
if (key.endsWith('Op')) continue;
const op = appliedFilters[`${key}Op`];
if (dateFields.has(key)) {
applyDateFilter(filter, key, val, op, isXPrefixed);
continue;
}
const suffix = op ? (OP_SUFFIX[op] ?? '') : '';
filter[`${key}${suffix}`] = val;
}
return filter;
return out;
}
+45
View File
@@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/services/api', () => ({ getApiBaseUrl: () => 'https://mail.example' }));
/** A fresh copy of the module, since it fetches once per page load. */
async function load(response: Response | Error) {
vi.resetModules();
const fetchMock = vi.fn(() => (response instanceof Error ? Promise.reject(response) : Promise.resolve(response)));
vi.stubGlobal('fetch', fetchMock);
const mod = await import('./logoCache');
const changed = vi.fn();
mod.subscribeToLogo(changed);
mod.loadLogoOnce();
mod.loadLogoOnce();
await vi.waitFor(() => expect(changed).toHaveBeenCalled());
return { state: mod.getLogoState(), fetchMock };
}
describe('logo cache', () => {
afterEach(() => vi.unstubAllGlobals());
it('uses an image the server sends, fetching it only once', async () => {
URL.createObjectURL = vi.fn(() => 'blob:logo');
const { state, fetchMock } = await load(
new Response(new Blob(['png']), { headers: { 'content-type': 'image/png' } }),
);
expect(state).toEqual({ status: 'custom', url: 'blob:logo' });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('https://mail.example/logo');
});
it('falls back to the inbuxa logo for anything else', async () => {
expect((await load(new Response('nope', { status: 404 }))).state).toEqual({ status: 'default' });
expect((await load(new Response('<html>', { headers: { 'content-type': 'text/html' } }))).state).toEqual({
status: 'default',
});
expect((await load(new Error('offline'))).state).toEqual({ status: 'default' });
});
});
+30 -25
View File
@@ -1,40 +1,45 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* The server's own logo, if an admin set one: fetched once per page load
* from `/logo` and kept as an object URL. Anything but an image (no logo, an
* error page, no answer) means the inbuxa logo.
*/
import { getApiBaseUrl } from '@/services/api';
export type LogoState = { status: 'loading' } | { status: 'custom'; url: string } | { status: 'default' };
let state: LogoState = { status: 'loading' };
let started = false;
const listeners = new Set<() => void>();
let logo: LogoState = { status: 'loading' };
let requested = false;
const watchers = new Set<() => void>();
export function getLogoState(): LogoState {
return state;
export const getLogoState = (): LogoState => logo;
export function subscribeToLogo(watch: () => void): () => void {
watchers.add(watch);
return () => void watchers.delete(watch);
}
export function subscribeToLogo(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
async function fetchLogo(): Promise<string | null> {
try {
const res = await fetch(`${getApiBaseUrl()}/logo`);
if (!res.ok || !res.headers.get('content-type')?.startsWith('image/')) return null;
return URL.createObjectURL(await res.blob());
} catch {
return null;
}
}
/** Starts the fetch; later calls do nothing. */
export function loadLogoOnce(): void {
if (started) return;
started = true;
fetch(`${getApiBaseUrl()}/logo`)
.then(async (response) => {
const contentType = response.headers.get('content-type') ?? '';
if (!response.ok || !contentType.startsWith('image/')) return null;
return URL.createObjectURL(await response.blob());
})
.catch(() => null)
.then((url) => {
state = url ? { status: 'custom', url } : { status: 'default' };
listeners.forEach((notify) => notify());
});
if (requested) return;
requested = true;
void fetchLogo().then((url) => {
logo = url ? { status: 'custom', url } : { status: 'default' };
for (const watch of watchers) watch();
});
}
+32 -31
View File
@@ -1,10 +1,7 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { inflateRawSync } from 'node:zlib';
@@ -12,52 +9,56 @@ import { describe, expect, it } from 'vitest';
import { isSieveScriptField, playgroundUrl, sievepadLink } from './sievepad';
function decode(link: string): unknown {
const token = new URLSearchParams(new URL(link).hash.slice(1)).get('w') ?? '';
return JSON.parse(inflateRawSync(Buffer.from(token, 'base64url')).toString('utf8'));
/** What the playground reads back out of a link. */
function workspaceIn(link: string) {
const w = new URL(link).hash.match(/^#w=([A-Za-z0-9_-]+)$/)?.[1];
if (!w) throw new Error(`no workspace in ${link}`);
return JSON.parse(inflateRawSync(Buffer.from(w, 'base64url')).toString('utf8'));
}
describe('playgroundUrl', () => {
it('names the bundled playground, by content hash, beside the console', () => {
describe('the playground link', () => {
it('points at the playground shipped beside the console', () => {
const url = new URL(playgroundUrl());
expect(url.origin).toBe(new URL(document.baseURI).origin);
expect(url.pathname).toMatch(/\/sieve-playground\/[0-9a-f]{16}\/index\.html$/);
});
});
describe('sievepadLink', () => {
it('encodes the script as a single main entry', async () => {
const source = 'require "imap4flags";\r\naddflag "\\\\Seen";\r\n';
const link = await sievepadLink('Filters é', source);
it('carries the script as the main entry, with Unix line ends', async () => {
const link = await sievepadLink('Vacation ✈', 'if true {\r\n keep;\r\n}\r\n');
expect(link.startsWith(`${playgroundUrl()}#w=`)).toBe(true);
expect(link.slice(`${playgroundUrl()}#w=`.length)).toMatch(/^[A-Za-z0-9_-]+$/);
expect(decode(link)).toEqual({
expect(workspaceIn(link)).toEqual({
v: 1,
name: 'Filters é',
scripts: [{ name: 'main', source: 'require "imap4flags";\naddflag "\\\\Seen";\n' }],
name: 'Vacation ✈',
scripts: [{ name: 'main', source: 'if true {\n keep;\n}\n' }],
messages: [],
settings: {},
});
});
it('carries the interpreter settings it is given', async () => {
const link = await sievepadLink('Filters', 'keep;', { cpuLimit: 5000, capabilities: ['fileinto'] });
expect((decode(link) as { settings: unknown }).settings).toEqual({ cpuLimit: 5000, capabilities: ['fileinto'] });
it("carries the interpreter's settings", async () => {
const settings = { maxInstructions: 1000, extensions: ['vacation'] };
expect(workspaceIn(await sievepadLink('s', 'keep;', settings)).settings).toEqual(settings);
});
it('truncates long workspace names', async () => {
const link = await sievepadLink('x'.repeat(200), 'keep;');
expect((decode(link) as { name: string }).name).toHaveLength(80);
it('cuts the name to 80 characters', async () => {
expect(workspaceIn(await sievepadLink('n'.repeat(81), 'keep;')).name).toBe('n'.repeat(80));
});
it('holds a large script', async () => {
const source = Array.from({ length: 5000 }, (_, i) => `# rule ${i} ${Math.random()}`).join('\n');
expect(workspaceIn(await sievepadLink('big', source)).scripts[0].source).toBe(source);
});
});
describe('isSieveScriptField', () => {
it('matches only the known script fields', () => {
expect(isSieveScriptField('x:SieveUserScript', 'contents')).toBe(true);
expect(isSieveScriptField('x:SieveSystemScript', 'contents')).toBe(true);
expect(isSieveScriptField('SieveScript', 'blobId')).toBe(true);
expect(isSieveScriptField('SieveScript', 'name')).toBe(false);
expect(isSieveScriptField('x:Domain', 'contents')).toBe(false);
it.each([
['x:SieveUserScript', 'contents', true],
['x:SieveSystemScript', 'contents', true],
['SieveScript', 'blobId', true],
['SieveScript', 'contents', false],
['x:SieveUserScript', 'name', false],
['x:Domain', 'contents', false],
])('%s.%s → %s', (object, field, expected) => {
expect(isSieveScriptField(object, field)).toBe(expected);
});
});
+39 -37
View File
@@ -1,64 +1,69 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Opening a Sieve script in the playground that ships with the console
* (`tools/sieve-playground`). The script travels in the link's fragment, so
* it never leaves the browser, and runs under the limits of the interpreter
* that runs it on the server (sieveLimits.ts).
*/
import { fetchPlaygroundSettings, type SieveInterpreter } from './sieveLimits';
// inbuxa: the Sieve playground ships with the console (sieve-playground.ts)
// and runs the server's own interpreter, so the script never leaves the
// console's origin.
export function playgroundUrl(): string {
return new URL(__SIEVE_PLAYGROUND__, document.baseURI).href;
}
/** The playground beside the console, at its content-hashed path. */
export const playgroundUrl = (): string => new URL(__SIEVE_PLAYGROUND__, document.baseURI).href;
const SIEVEPAD_FORMAT_VERSION = 1;
const SIEVEPAD_MAX_NAME_LENGTH = 80;
const SIEVEPAD_MAIN_SCRIPT = 'main';
const BASE64_CHUNK_SIZE = 0x8000;
const SIEVE_SCRIPT_FIELDS: Record<string, string> = {
/** Which field holds the script, for each object that has one. */
const SCRIPT_FIELD: Record<string, string> = {
'x:SieveSystemScript': 'contents',
'x:SieveUserScript': 'contents',
SieveScript: 'blobId',
};
export function isSieveScriptField(objectName: string, fieldName: string): boolean {
return SIEVE_SCRIPT_FIELDS[objectName] === fieldName;
export const isSieveScriptField = (objectName: string, fieldName: string): boolean =>
SCRIPT_FIELD[objectName] === fieldName;
async function deflateRaw(text: string): Promise<Uint8Array> {
const compressed = new Blob([text]).stream().pipeThrough(new CompressionStream('deflate-raw'));
return new Uint8Array(await new Response(compressed).arrayBuffer());
}
function toBase64Url(bytes: Uint8Array): string {
function base64Url(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i += BASE64_CHUNK_SIZE) {
binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_SIZE));
// In slices, so a large script doesn't overflow the argument list.
for (let at = 0; at < bytes.length; at += 32_768) {
binary += String.fromCharCode(...bytes.subarray(at, at + 32_768));
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
}
/**
* A playground link holding one workspace: the script as its `main` entry,
* no test messages, and the interpreter settings. This is the playground's
* format version 1, deflated and base64url-encoded into `#w=`.
*/
export async function sievepadLink(
name: string,
source: string,
settings: Record<string, unknown> = {},
base = playgroundUrl(),
): Promise<string> {
const json = JSON.stringify({
v: SIEVEPAD_FORMAT_VERSION,
name: name.slice(0, SIEVEPAD_MAX_NAME_LENGTH),
scripts: [{ name: SIEVEPAD_MAIN_SCRIPT, source: source.replace(/\r\n/g, '\n') }],
const workspace = {
v: 1,
name: name.slice(0, 80),
scripts: [{ name: 'main', source: source.replaceAll('\r\n', '\n') }],
messages: [],
settings,
});
const stream = new Blob([new TextEncoder().encode(json)]).stream().pipeThrough(new CompressionStream('deflate-raw'));
const packed = new Uint8Array(await new Response(stream).arrayBuffer());
return `${base}#w=${toBase64Url(packed)}`;
};
return `${base}#w=${base64Url(await deflateRaw(JSON.stringify(workspace)))}`;
}
// inbuxa: the script runs under the limits of the interpreter that runs it
// on the server (sieveLimits.ts).
/**
* Opens the script in a new tab. The tab opens first, while the click still
* counts as the reader's, and is pointed at the link once it is built.
*/
export async function openInSievepad(name: string, source: string, interpreter: SieveInterpreter): Promise<void> {
const tab = window.open('about:blank', '_blank');
if (tab) tab.opener = null;
@@ -69,9 +74,6 @@ export async function openInSievepad(name: string, source: string, interpreter:
tab?.close();
throw err;
}
if (tab) {
tab.location.replace(link);
} else {
window.open(link, '_blank', 'noopener,noreferrer');
}
if (tab) tab.location.replace(link);
else window.open(link, '_blank', 'noopener,noreferrer');
}
+4 -5
View File
@@ -1,12 +1,11 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Joins class names; where two Tailwind classes set the same thing, the later one wins. */
export const cn = (...classes: ClassValue[]): string => twMerge(clsx(classes));