diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index 5df4bd4..872f189 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -11,6 +11,7 @@ import { humanize } from '@/lib/humanize'; import { PageHeader } from '@/components/common/PageHeader'; import { HelpPanel } from '@/help/HelpPanel'; import { fieldPlaceholder, formNotices, variantPrefill } from '@/features/ai/formExtras'; +import { FailedRecipients } from '@/features/ai/explain/FailedRecipients'; import { iconForView } from '@/lib/viewIcon'; import { useState, useEffect, useCallback, useMemo } from 'react'; import { flushSync } from 'react-dom'; @@ -809,6 +810,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { ))} + {/* inbuxa: "Explain this" for each failed recipient (ai-explain EX-17) */} + {resolved.obj.objectName === 'x:QueuedMessage' && objectId && ( + + )} + {sectionsToRender.map((section, sectionIdx) => ( {section.title && ( @@ -883,6 +889,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined } helpScope={helpScope} + explainTarget={ + isCreate + ? undefined + : { object: resolved.obj.objectName, id: isSingleton ? 'singleton' : String(objectId) } + } /> ); diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index c3fe186..87e32fb 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -12,6 +12,7 @@ import { useState, useEffect, useMemo, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue'; import { HelpTip } from '@/help/HelpTip'; +import { ExplainButton } from '@/features/ai/explain/ExplainButton'; import { fieldHelp } from '@/help/texts'; import { describeDefault, differsFromDefault } from '@/help/defaults'; import { Input } from '@/components/ui/input'; @@ -69,6 +70,8 @@ export interface FieldWidgetProps { sieveScriptName?: string; /** INBUXA: the object or schema that owns this field, for its help id (`scope.field`). */ helpScope?: string; + /** inbuxa: the saved record this field belongs to, for "Explain this" (ai-explain EX-17). */ + explainTarget?: { object: string; id: string }; } function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optional' | null { @@ -89,7 +92,8 @@ function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optio export function FieldWidget(props: FieldWidgetProps) { const { t } = useTranslation(); - const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName, helpScope } = props; + const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName, helpScope, explainTarget } = + props; const helpId = helpScope ? `${helpScope}.${formField.name}` : undefined; // INBUXA: the option's default, for its tooltip, and whether it has been changed. const defaultValue = helpScope ? schema.fields[helpScope]?.defaults?.[formField.name] : undefined; @@ -250,6 +254,19 @@ export function FieldWidget(props: FieldWidgetProps) { )} + {/* inbuxa: the server refuses any setting that holds a secret (EX-9); a plain secret isn't offered */} + {explainTarget && !(ft.type === 'string' && ft.format === 'secret') && ( + + )} {changed && ( s.viewToSection); const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); const edition = useAccountStore((s) => s.edition); + const explainAvailable = useExplainStore((s) => s.available); const [upsellOpen, setUpsellOpen] = useState(false); const resolved = useMemo(() => { @@ -856,6 +859,9 @@ export function DynamicList({ viewName }: DynamicListProps) { const hasMassActions = effectiveMassActions.length > 0; const hasItemActions = (list.itemActions?.length ?? 0) > 0; + // inbuxa: "Explain this" on each log entry (ai-explain EX-17) + const explainLogs = explainAvailable && obj.objectName === 'x:Log'; + const hasRowCell = hasItemActions || explainLogs; const pageStart = anchorStack.length * PAGE_SIZE; const rangeStart = pageStart + 1; @@ -1265,7 +1271,7 @@ export function DynamicList({ viewName }: DynamicListProps) { ))} - {hasItemActions && ( + {hasRowCell && ( {t('list.actions', 'Actions')} @@ -1276,7 +1282,7 @@ export function DynamicList({ viewName }: DynamicListProps) { {loading && items.length === 0 ? ( @@ -1284,10 +1290,7 @@ export function DynamicList({ viewName }: DynamicListProps) { ) : items.length === 0 ? ( - + ); })} - {hasItemActions && {renderItemActions(item)}} + {hasRowCell && ( + +
+ {explainLogs && item.id != null && ( + + )} + {renderItemActions(item)} +
+ + )} ); }) diff --git a/src/features/actions/ActionPage.tsx b/src/features/actions/ActionPage.tsx index 425cdbc..012f845 100644 --- a/src/features/actions/ActionPage.tsx +++ b/src/features/actions/ActionPage.tsx @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import { useState, useMemo, useCallback } from 'react'; @@ -19,6 +21,8 @@ import { FieldWidget } from '@/components/forms/FieldWidget'; import { DynamicView } from '@/components/views/DynamicView'; import type { Schema, ObjectVariant } from '@/types/schema'; import type { JmapSetResponse } from '@/types/jmap'; +import { ExplainButton } from '@/features/ai/explain/ExplainButton'; +import { spamVerdictSubject } from '@/features/ai/explain/explain'; type ActionState = | { kind: 'pick' } @@ -467,6 +471,14 @@ function ActionResultView({ /> )} + {/* inbuxa: "Explain this" for a spam verdict (ai-explain EX-17) */} + {variant.name === 'ClassifySpam' && props && ( + spamVerdictSubject(props)} + title={t('explain.spamVerdictTitle', 'Spam verdict: {{result}}', { result: String(props.result ?? '') })} + /> + )} + {!hasData && ( diff --git a/src/features/ai/LocalAiPage.tsx b/src/features/ai/LocalAiPage.tsx index 00feab3..37d56ed 100644 --- a/src/features/ai/LocalAiPage.tsx +++ b/src/features/ai/LocalAiPage.tsx @@ -44,6 +44,7 @@ import { type Status, } from './localAi'; import { formNotices } from './formExtras'; +import { ExplanationsCard } from './explain/ExplanationsCard'; export const LOCAL_AI_VIEW = 'CustomComponent/LocalAi'; @@ -148,6 +149,7 @@ export function LocalAiPage() { )} + ); } diff --git a/src/features/ai/explain/ExplainButton.tsx b/src/features/ai/explain/ExplainButton.tsx new file mode 100644 index 0000000..cb1efc3 --- /dev/null +++ b/src/features/ai/explain/ExplainButton.tsx @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useTranslation } from 'react-i18next'; +import { Sparkles } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { useExplainStore } from './explainStore'; +import type { ExplainSubject } from './explain'; + +/** + * inbuxa: asks the local model to explain `subject` in the side panel + * (EX-17). Drawn only when the session says Explain is available; `icon` + * is the small form for help tips and table rows. + */ +export function ExplainButton({ + subject, + title, + icon = false, + className, +}: { + subject: ExplainSubject | (() => ExplainSubject); + /** What the panel is about, e.g. the recipient or the setting's label. */ + title: string; + icon?: boolean; + className?: string; +}) { + const { t } = useTranslation(); + const available = useExplainStore((s) => s.available); + const ask = useExplainStore((s) => s.ask); + if (!available) return null; + const label = t('explain.button', 'Explain'); + const onClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + ask(typeof subject === 'function' ? subject() : subject, title); + }; + if (icon) { + return ( + + ); + } + return ( + + ); +} diff --git a/src/features/ai/explain/ExplainPanel.tsx b/src/features/ai/explain/ExplainPanel.tsx new file mode 100644 index 0000000..8453369 --- /dev/null +++ b/src/features/ai/explain/ExplainPanel.tsx @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; +import { Check, Copy, Loader2, Sparkles, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useExplainStore } from './explainStore'; +import type { ExplainFailure } from './explain'; + +/** + * inbuxa: the explanation, beside the page rather than over it, so the + * details it explains stay in view (EX-18 to EX-20). Mounted once. + */ +export function ExplainPanel() { + const { t } = useTranslation(); + const panel = useExplainStore((s) => s.panel); + const close = useExplainStore((s) => s.close); + const retry = useExplainStore((s) => s.retry); + const [copiedText, setCopiedText] = useState(null); + + useEffect(() => { + if (panel.status === 'closed') return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') close(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [panel.status, close]); + + if (panel.status === 'closed') return null; + const copied = panel.status === 'done' && copiedText === panel.explanation.text; + + return ( + + ); +} + +function failureText(t: TFunction, failure: ExplainFailure): string { + switch (failure.kind) { + case 'busy': + return t('explain.busy', 'The model is busy with incoming mail. Try again in a moment.'); + case 'timeout': + return t('explain.timeout', "The model didn't answer in time."); + case 'paused': + return t('explain.paused', 'The model is paused after repeated failures. It will be tried again shortly.'); + case 'rateLimit': + return t('explain.rateLimit', "You've asked for as many explanations as this hour allows."); + case 'unavailable': + return t('explain.unavailable', 'No local model is available to explain this right now.'); + case 'refused': + return failure.message; + } +} diff --git a/src/features/ai/explain/ExplanationsCard.tsx b/src/features/ai/explain/ExplanationsCard.tsx new file mode 100644 index 0000000..e9c7e44 --- /dev/null +++ b/src/features/ai/explain/ExplanationsCard.tsx @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { toast } from '@/hooks/use-toast'; +import { fetchSession } from '@/services/jmap/client'; +import { useAuthStore } from '@/stores/authStore'; +import type { AiModelSummary } from '../localAi'; +import { useExplainStore } from './explainStore'; +import { + DEFAULT_EXPLAIN, + explainAvailable, + fetchExplainSettings, + saveExplainSettings, + type ExplainSettings, +} from './explain'; + +/** + * inbuxa: Local AI › Explanations (ai-explain EX-21): the switch, which model + * explains, and the limits. Hidden on a server that predates explanations. + */ +export function ExplanationsCard({ canUpdate, models }: { canUpdate: boolean; models: AiModelSummary[] }) { + const { t } = useTranslation(); + const [current, setCurrent] = useState(null); + const [draft, setDraft] = useState({ enabled: true, modelId: '', perHour: '', ceiling: '' }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const fill = (s: ExplainSettings) => + setDraft({ + enabled: s.explainEnabled, + modelId: s.explainModelId ?? '', + perHour: String(s.explainCallsPerHour), + ceiling: String(s.explainCeiling / 1000), + }); + + useEffect(() => { + const ctl = new AbortController(); + fetchExplainSettings(ctl.signal) + .then((s) => { + if (ctl.signal.aborted || !s) return; + setCurrent(s); + fill(s); + }) + .catch(() => undefined); + return () => ctl.abort(); + }, []); + + if (!current) return null; + + const perHour = Number(draft.perHour); + const ceiling = Number(draft.ceiling); + const valid = Number.isInteger(perHour) && perHour >= 1 && Number.isFinite(ceiling) && ceiling >= 1 && ceiling <= 600; + + const save = async () => { + if (!valid) return; + const next: ExplainSettings = { + explainEnabled: draft.enabled, + explainModelId: draft.modelId || null, + explainCallsPerHour: perHour, + explainCeiling: Math.round(ceiling * 1000), + }; + setBusy(true); + setError(null); + const outcome = await saveExplainSettings(current, next).catch((e: unknown) => ({ + ok: false, + message: e instanceof Error ? e.message : String(e), + })); + if (outcome.ok) { + setCurrent(next); + // Explain buttons follow the switch at once + const session = await fetchSession().catch(() => null); + if (session) { + useExplainStore.getState().setAvailable(explainAvailable(session, useAuthStore.getState().primaryAccountId)); + } + toast({ title: t('explain.settingsSaved', 'Explanation settings saved.') }); + } else { + setError(outcome.message ?? ''); + } + setBusy(false); + }; + + return ( + + + {t('explain.settingsTitle', 'Explanations')} + + +

+ {t( + 'explain.settingsLead', + 'An Explain button beside delivery failures, spam verdicts, log and trace events, and settings asks the model for a short explanation in plain words. It never takes a slot the spam filter needs, and message text is never sent.', + )} +

+
+ setDraft({ ...draft, enabled })} + /> + +
+
+
+ + +
+
+ + setDraft({ ...draft, perHour: e.target.value })} + /> +

+ {t('localAi.limitDefault', 'Default: {{value}}', { value: DEFAULT_EXPLAIN.explainCallsPerHour })} +

+
+
+ + setDraft({ ...draft, ceiling: e.target.value })} + /> +

+ {t('localAi.limitDefault', 'Default: {{value}}', { value: DEFAULT_EXPLAIN.explainCeiling / 1000 })}{' '} + {t('localAi.seconds', 's')} +

+
+
+ {error &&

{error}

} + {canUpdate && ( + + )} +
+
+ ); +} diff --git a/src/features/ai/explain/FailedRecipients.tsx b/src/features/ai/explain/FailedRecipients.tsx new file mode 100644 index 0000000..d9c6044 --- /dev/null +++ b/src/features/ai/explain/FailedRecipients.tsx @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useTranslation } from 'react-i18next'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ExplainButton } from './ExplainButton'; +import { useExplainStore } from './explainStore'; +import { failedRecipients } from './explain'; + +/** + * inbuxa: a queued message's failed recipients, each with Explain (EX-17). + * Shown above the message's form, and only when there is something to + * explain and a model to explain it. + */ +export function FailedRecipients({ queueId, recipients }: { queueId: string; recipients: unknown }) { + const { t } = useTranslation(); + const available = useExplainStore((s) => s.available); + const failed = failedRecipients(recipients); + if (!available || failed.length === 0) return null; + return ( + + + {t('explain.failedRecipients', 'Failed deliveries')} + + +
    + {failed.map((rcpt) => ( +
  • +
    +

    {rcpt.address}

    +

    + {rcpt.status === 'PermanentFailure' + ? t('explain.permanent', 'Permanent failure') + : t('explain.temporary', 'Temporary failure')} + {rcpt.summary && ` · ${rcpt.summary}`} +

    +
    + +
  • + ))} +
+
+
+ ); +} diff --git a/src/features/ai/explain/explain.test.ts b/src/features/ai/explain/explain.test.ts new file mode 100644 index 0000000..9d2e63b --- /dev/null +++ b/src/features/ai/explain/explain.test.ts @@ -0,0 +1,187 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const jmapRequest = vi.fn(); +vi.mock('@/services/jmap/client', () => ({ + getAccountId: () => 'a', + jmapRequest: (...args: unknown[]) => jmapRequest(...args), +})); + +import { + DEFAULT_EXPLAIN, + explainAvailable, + failedRecipients, + isRawEvent, + liveEventSubject, + requestExplanation, + saveExplainSettings, + spamVerdictSubject, + valueText, +} from './explain'; +import { normalizeTraceEvents } from '@/features/tracing/normalize'; + +beforeEach(() => jmapRequest.mockReset()); + +describe('explainAvailable', () => { + it('reads the flag from the primary account only', () => { + const session = { + accounts: { + b: { accountCapabilities: { 'urn:inbuxa:jmap': { aiExplain: true } } }, + c: { accountCapabilities: { 'urn:inbuxa:jmap': { aiExplain: false } } }, + }, + }; + expect(explainAvailable(session, 'b')).toBe(true); + expect(explainAvailable(session, 'c')).toBe(false); + expect(explainAvailable(session, null)).toBe(false); + expect(explainAvailable({ accounts: { b: { accountCapabilities: {} } } }, 'b')).toBe(false); + }); +}); + +describe('subjects', () => { + it('trims a live event to what the server accepts, without raw contents', () => { + const keyValues = [ + { key: 'contents', value: { '@type': 'String', value: 'a LOGIN bob hunter2' } }, + { key: 'remoteIp', value: { '@type': 'IpAddr', value: '192.0.2.1' } }, + { key: 'long', value: { '@type': 'String', value: 'x'.repeat(900) } }, + ...Array.from({ length: 60 }, (_, n) => ({ key: `k${n}`, value: { '@type': 'UnsignedInt', value: n } })), + ]; + const subject = liveEventSubject({ event: 'imap.command', keyValues }); + if (!('keyValues' in subject)) throw new Error('not a live event'); + expect(subject.keyValues).toHaveLength(50); + expect(JSON.stringify(subject)).not.toContain('hunter2'); + expect(subject.keyValues[0]).toEqual({ key: 'remoteIp', value: '192.0.2.1' }); + expect(subject.keyValues[1].value.length).toBeLessThanOrEqual(500); + }); + + it('takes a live event as the stream sends it, key-values as a JMAP map', () => { + const [event] = normalizeTraceEvents( + JSON.parse( + '[{"event":"http.response-body","timestamp":"2026-09-26T08:07:09Z","keyValues":{' + + '"0":{"key":"spanId","value":{"value":331622707493871616,"@type":"UnsignedInt"}},' + + '"1":{"key":"contents","value":{"value":"{\\"accessToken\\":\\"secret\\"}","@type":"String"}},' + + '"2":{"key":"code","value":{"value":200,"@type":"UnsignedInt"}}}}]', + ), + ); + const subject = liveEventSubject(event); + if (!('keyValues' in subject)) throw new Error('not a live event'); + expect(subject.keyValues.map((kv) => kv.key)).toEqual(['spanId', 'code']); + expect(subject.keyValues[1]).toEqual({ key: 'code', value: '200' }); + expect(JSON.stringify(subject)).not.toContain('secret'); + }); + + it('flattens typed values the way the server does', () => { + expect( + valueText({ + '@type': 'List', + value: [ + { '@type': 'String', value: 'a' }, + { '@type': 'UnsignedInt', value: 2 }, + ], + }), + ).toBe('a, 2'); + expect(isRawEvent('smtp.raw-input')).toBe(true); + expect(isRawEvent('smtp.ehlo')).toBe(false); + }); + + it('lists only failed recipients, with their reply', () => { + const failed = failedRecipients({ + 'a@example.com': { status: { '@type': 'Scheduled' } }, + 'b@example.com': { + status: { + '@type': 'PermanentFailure', + responseCode: 550, + responseEnhanced: '5.7.26', + responseMessage: 'DMARC', + }, + }, + 'c@example.com': { status: { '@type': 'TemporaryFailure', errorType: 'ConnectionError' } }, + }); + expect(failed).toEqual([ + { address: 'b@example.com', status: 'PermanentFailure', summary: '550 5.7.26 DMARC' }, + { address: 'c@example.com', status: 'TemporaryFailure', summary: 'ConnectionError' }, + ]); + expect(failedRecipients(undefined)).toEqual([]); + }); + + it('sends a Classify result back as a verdict', () => { + expect( + spamVerdictSubject({ + result: 'spam', + score: 7.5, + tags: { DMARC_POLICY_REJECT: { score: 5, disposition: 'score' } }, + }), + ).toEqual({ + '@type': 'SpamVerdict', + result: 'spam', + score: 7.5, + tags: { DMARC_POLICY_REJECT: { score: 5, disposition: 'score' } }, + }); + }); +}); + +describe('requestExplanation', () => { + const setting = { '@type': 'Setting', object: 'x:Domain', id: 'b', property: 'isEnabled' } as const; + + it('returns the explanation and its provenance', async () => { + jmapRequest.mockResolvedValue([ + [ + 'inbuxa:Explanation/set', + { created: { e: { text: 'It turns it on.', model: 'qwen', node: 'host2', elapsedMs: 900 } } }, + ], + ]); + const result = await requestExplanation(setting); + expect(result).toEqual({ + ok: true, + explanation: { text: 'It turns it on.', model: 'qwen', node: 'host2', elapsedMs: 900 }, + }); + const [calls, , using] = jmapRequest.mock.calls[0]; + expect(calls[0][1]).toEqual({ accountId: 'a', create: { e: { subject: setting } } }); + expect(using).toEqual(['urn:inbuxa:jmap']); + }); + + it.each([ + [{ type: 'serverFail', description: 'busy' }, { kind: 'busy' }], + [{ type: 'serverFail', description: 'timeout' }, { kind: 'timeout' }], + [{ type: 'serverFail', description: 'paused' }, { kind: 'paused' }], + [{ type: 'serverFail', description: 'unavailable' }, { kind: 'unavailable' }], + [{ type: 'rateLimit' }, { kind: 'rateLimit' }], + [ + { type: 'forbidden', description: 'That setting holds a secret.' }, + { kind: 'refused', message: 'That setting holds a secret.' }, + ], + ])('words %o as %o (EX-20)', async (error, failure) => { + jmapRequest.mockResolvedValue([['inbuxa:Explanation/set', { notCreated: { e: error } }]]); + expect(await requestExplanation(setting)).toEqual({ ok: false, failure }); + }); + + it('treats a server without explanations as unavailable', async () => { + jmapRequest.mockResolvedValue([['error', { type: 'unknownMethod' }]]); + expect(await requestExplanation(setting)).toEqual({ ok: false, failure: { kind: 'unavailable' } }); + }); +}); + +describe('saveExplainSettings', () => { + it('sends only what changed, and a default as null', async () => { + jmapRequest.mockResolvedValue([['inbuxa:AiLimits/set', { updated: { singleton: null } }]]); + const outcome = await saveExplainSettings( + { ...DEFAULT_EXPLAIN, explainCallsPerHour: 10 }, + { ...DEFAULT_EXPLAIN, explainEnabled: false, explainModelId: 'm' }, + ); + expect(outcome).toEqual({ ok: true }); + expect(jmapRequest.mock.calls[0][0][0][1].update.singleton).toEqual({ + explainEnabled: false, + explainModelId: 'm', + explainCallsPerHour: null, + }); + }); + + it('does nothing when nothing changed', async () => { + expect(await saveExplainSettings(DEFAULT_EXPLAIN, { ...DEFAULT_EXPLAIN })).toEqual({ ok: true }); + expect(jmapRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/ai/explain/explain.ts b/src/features/ai/explain/explain.ts new file mode 100644 index 0000000..136e9ff --- /dev/null +++ b/src/features/ai/explain/explain.ts @@ -0,0 +1,250 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: "Explain this" (inbuxa-server's ai-explain spec). The console + * names what to explain; the server reads the data, builds the prompt and + * asks the local model on the node that answers. Nothing here sends free + * text to a model. + */ + +import { getAccountId, jmapRequest } from '@/services/jmap/client'; +import { INBUXA_CAPABILITY } from '../localAi'; + +/** What to explain: the `subject` of an `inbuxa:Explanation`. */ +export type ExplainSubject = + | { '@type': 'DeliveryFailure'; queueId: string; recipient: string } + | { + '@type': 'SpamVerdict'; + result: string; + score: number; + tags: Record; + } + | { '@type': 'LogEntry'; logId: string } + | { '@type': 'TraceEvent'; traceId: string; index: number } + | { '@type': 'TraceEvent'; event: string; keyValues: { key: string; value: string }[] } + | { '@type': 'Setting'; object: string; id: string; property: string }; + +export interface Explanation { + text: string; + model: string; + node: string; + elapsedMs: number; +} + +/** Why there's no explanation, in the few kinds the panel words differently (EX-20). */ +export type ExplainFailure = + { kind: 'busy' | 'timeout' | 'paused' | 'unavailable' | 'rateLimit' } | { kind: 'refused'; message: string }; + +export type ExplainResult = { ok: true; explanation: Explanation } | { ok: false; failure: ExplainFailure }; + +/** The session's flag (EX-1 to EX-4): Explain is shown only when it's true. */ +export function explainAvailable(session: Record, accountId: string | null): boolean { + const accounts = session.accounts as Record }> | undefined; + const capability = accountId ? accounts?.[accountId]?.accountCapabilities?.[INBUXA_CAPABILITY] : undefined; + return (capability as { aiExplain?: unknown } | undefined)?.aiExplain === true; +} + +// The server's limits (EX-8), kept here so a live event is trimmed rather than refused +const MAX_KEY_VALUES = 50; +const MAX_VALUE_CHARS = 500; +/** Raw protocol bytes: never sent (EX-9). */ +const DROPPED_KEYS = new Set(['contents']); + +export function isRawEvent(event: string): boolean { + return event.endsWith('.raw-input') || event.endsWith('.raw-output'); +} + +/** A trace value as plain text, the way the server flattens a stored one. */ +export function valueText(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.map(valueText).filter(Boolean).join(', '); + if (typeof value === 'object') { + return Object.entries(value as Record) + .filter(([k]) => k !== '@type') + .map(([, v]) => valueText(v)) + .filter(Boolean) + .join(' '); + } + return String(value); +} + +/** A live tracing event, trimmed to what the server accepts. */ +export function liveEventSubject(event: { + event: string; + keyValues: { key: string; value: unknown }[]; +}): ExplainSubject { + const keyValues = event.keyValues + .filter((kv) => !DROPPED_KEYS.has(kv.key)) + .slice(0, MAX_KEY_VALUES) + .map((kv) => { + const text = valueText(kv.value); + return { key: kv.key, value: text.length > MAX_VALUE_CHARS ? `${text.slice(0, MAX_VALUE_CHARS - 1)}…` : text }; + }); + return { '@type': 'TraceEvent', event: event.event, keyValues }; +} + +/** The failed recipients of a queued message, as the queue view holds it. */ +export function failedRecipients( + recipients: unknown, +): { address: string; status: 'TemporaryFailure' | 'PermanentFailure'; summary: string }[] { + if (!recipients || typeof recipients !== 'object') return []; + const out: { address: string; status: 'TemporaryFailure' | 'PermanentFailure'; summary: string }[] = []; + for (const [address, rcpt] of Object.entries(recipients as Record)) { + const status = (rcpt as { status?: Record } | null)?.status; + const type = status?.['@type']; + if (type !== 'TemporaryFailure' && type !== 'PermanentFailure') continue; + const code = [status?.responseCode, status?.responseEnhanced].filter((v) => v != null && v !== '').join(' '); + const words = status?.responseMessage ?? status?.errorMessage ?? status?.errorType ?? ''; + out.push({ address, status: type, summary: [code, String(words)].filter(Boolean).join(' ') }); + } + return out; +} + +function failureFrom(error: { type?: string; description?: string } | undefined): ExplainFailure { + const type = error?.type; + const description = error?.description ?? ''; + if (type === 'serverFail') { + if (description === 'busy' || description === 'timeout' || description === 'paused') return { kind: description }; + return { kind: 'unavailable' }; + } + if (type === 'rateLimit') return { kind: 'rateLimit' }; + if (type === 'unknownMethod' || type === 'unknownCapability') return { kind: 'unavailable' }; + return { kind: 'refused', message: description || type || 'error' }; +} + +/** Asks for one explanation. */ +export async function requestExplanation(subject: ExplainSubject, signal?: AbortSignal): Promise { + const accountId = getAccountId('x:'); + const responses = await jmapRequest( + [['inbuxa:Explanation/set', { accountId, create: { e: { subject } } }, '0']], + signal, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name === 'error') return { ok: false, failure: failureFrom(result as { type?: string; description?: string }) }; + const body = result as { + created?: Record>; + notCreated?: Record; + }; + const created = body.created?.e; + if (created && typeof created.text === 'string') { + return { + ok: true, + explanation: { + text: created.text, + model: String(created.model ?? ''), + node: String(created.node ?? ''), + elapsedMs: Number(created.elapsedMs ?? 0), + }, + }; + } + return { ok: false, failure: failureFrom(body.notCreated?.e) }; +} + +/** The result of Actions › Classify a message, sent back as a verdict. */ +export function spamVerdictSubject(result: Record): ExplainSubject { + const tags: Record = {}; + const raw = result.tags; + if (raw && typeof raw === 'object') { + for (const [name, tag] of Object.entries(raw as Record | null>)) { + tags[name] = { + score: typeof tag?.score === 'number' ? tag.score : undefined, + disposition: typeof tag?.disposition === 'string' ? tag.disposition : undefined, + }; + } + } + return { + '@type': 'SpamVerdict', + result: String(result.result ?? ''), + score: typeof result.score === 'number' ? result.score : 0, + tags, + }; +} + +// ── Settings, in inbuxa:AiLimits (EX-21) ───────────────────────────────── + +export interface ExplainSettings { + explainEnabled: boolean; + /** Unset: the spam classifier's model, or the only model there is (EX-3). */ + explainModelId: string | null; + explainCallsPerHour: number; + /** Milliseconds. */ + explainCeiling: number; +} + +export const DEFAULT_EXPLAIN: ExplainSettings = { + explainEnabled: true, + explainModelId: null, + explainCallsPerHour: 30, + explainCeiling: 45_000, +}; + +export function parseExplainSettings(raw: Record): ExplainSettings { + return { + explainEnabled: typeof raw.explainEnabled === 'boolean' ? raw.explainEnabled : DEFAULT_EXPLAIN.explainEnabled, + explainModelId: typeof raw.explainModelId === 'string' && raw.explainModelId ? raw.explainModelId : null, + explainCallsPerHour: + typeof raw.explainCallsPerHour === 'number' ? raw.explainCallsPerHour : DEFAULT_EXPLAIN.explainCallsPerHour, + explainCeiling: typeof raw.explainCeiling === 'number' ? raw.explainCeiling : DEFAULT_EXPLAIN.explainCeiling, + }; +} + +/** Null when the server predates explanations. */ +export async function fetchExplainSettings(signal?: AbortSignal): Promise { + const accountId = getAccountId('x:'); + const responses = await jmapRequest( + [ + [ + 'inbuxa:AiLimits/get', + { + accountId, + ids: null, + properties: ['explainEnabled', 'explainModelId', 'explainCallsPerHour', 'explainCeiling'], + }, + '0', + ], + ], + signal, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name === 'error') return null; + const row = ((result as { list?: Record[] }).list ?? [])[0] ?? {}; + // An older server ignores the properties it doesn't know + if (!('explainEnabled' in row)) return null; + return parseExplainSettings(row); +} + +/** + * Saves what changed; a value equal to the default goes as null, so the + * server keeps following the default. The error names the property, if any. + */ +export async function saveExplainSettings( + current: ExplainSettings, + next: ExplainSettings, +): Promise<{ ok: boolean; message?: string }> { + const patch: Record = {}; + for (const key of Object.keys(DEFAULT_EXPLAIN) as (keyof ExplainSettings)[]) { + if (next[key] === current[key]) continue; + patch[key] = next[key] === DEFAULT_EXPLAIN[key] ? null : next[key]; + } + if (Object.keys(patch).length === 0) return { ok: true }; + const accountId = getAccountId('x:'); + const responses = await jmapRequest( + [['inbuxa:AiLimits/set', { accountId, update: { singleton: patch } }, '0']], + undefined, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name === 'error') { + return { ok: false, message: String((result as { description?: string })?.description ?? 'error') }; + } + const failure = (result as { notUpdated?: Record }).notUpdated + ?.singleton; + return failure ? { ok: false, message: failure.description ?? failure.type } : { ok: true }; +} diff --git a/src/features/ai/explain/explainStore.ts b/src/features/ai/explain/explainStore.ts new file mode 100644 index 0000000..b808b9d --- /dev/null +++ b/src/features/ai/explain/explainStore.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: whether Explain is offered (the session's `aiExplain`), and the one + * explanation panel. Asking again replaces what the panel shows; a request + * left behind is abandoned in the console (EX-19), and the server finishes + * or times out on its own. + */ + +import { create } from 'zustand'; +import { requestExplanation, type ExplainFailure, type ExplainSubject, type Explanation } from './explain'; + +type PanelState = + | { status: 'closed' } + | { status: 'asking'; title: string; subject: ExplainSubject } + | { status: 'done'; title: string; subject: ExplainSubject; explanation: Explanation } + | { status: 'failed'; title: string; subject: ExplainSubject; failure: ExplainFailure }; + +interface ExplainState { + available: boolean; + panel: PanelState; + setAvailable: (available: boolean) => void; + ask: (subject: ExplainSubject, title: string) => void; + retry: () => void; + close: () => void; +} + +let controller: AbortController | null = null; + +export const useExplainStore = create()((set, get) => ({ + available: false, + panel: { status: 'closed' }, + + setAvailable: (available) => set({ available }), + + ask: (subject, title) => { + controller?.abort(); + const mine = new AbortController(); + controller = mine; + set({ panel: { status: 'asking', title, subject } }); + requestExplanation(subject, mine.signal) + .then((result) => { + if (controller !== mine) return; + set({ + panel: result.ok + ? { status: 'done', title, subject, explanation: result.explanation } + : { status: 'failed', title, subject, failure: result.failure }, + }); + }) + .catch((err: unknown) => { + if (controller !== mine || mine.signal.aborted) return; + set({ + panel: { + status: 'failed', + title, + subject, + failure: { kind: 'refused', message: err instanceof Error ? err.message : String(err) }, + }, + }); + }); + }, + + retry: () => { + const panel = get().panel; + if (panel.status !== 'closed') get().ask(panel.subject, panel.title); + }, + + close: () => { + controller?.abort(); + controller = null; + set({ panel: { status: 'closed' } }); + }, +})); diff --git a/src/features/tracing/components/LiveTracingPage.tsx b/src/features/tracing/components/LiveTracingPage.tsx index 658424b..8830881 100644 --- a/src/features/tracing/components/LiveTracingPage.tsx +++ b/src/features/tracing/components/LiveTracingPage.tsx @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; @@ -15,7 +17,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { useSchemaStore } from '@/stores/schemaStore'; import { apiFetch, getApiBaseUrl } from '@/services/api'; import type { TraceEvent } from '../types'; +import { normalizeTraceEvents } from '../normalize'; import { TraceTimeline } from './TraceTimeline'; +import { liveEventSubject } from '@/features/ai/explain/explain'; const MAX_EVENTS = 1000; const MAX_RECONNECT = 5; @@ -87,7 +91,7 @@ export function LiveTracingPage() { es.addEventListener('trace', (event) => { try { - const batch = JSON.parse(event.data) as TraceEvent[]; + const batch = normalizeTraceEvents(JSON.parse(event.data)); const currentEvents = eventsRef.current; for (const evt of batch) { @@ -336,7 +340,7 @@ export function LiveTracingPage() { - + liveEventSubject(event)} /> diff --git a/src/features/tracing/components/TraceDetailView.tsx b/src/features/tracing/components/TraceDetailView.tsx index f745a88..954447d 100644 --- a/src/features/tracing/components/TraceDetailView.tsx +++ b/src/features/tracing/components/TraceDetailView.tsx @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import { useEffect, useState } from 'react'; @@ -13,41 +15,10 @@ import { Card, CardContent } from '@/components/ui/card'; import { useSchemaStore } from '@/stores/schemaStore'; import { jmapGet, getAccountId } from '@/services/jmap/client'; import { resolveObject } from '@/lib/schemaResolver'; -import type { TraceEvent, TraceKeyValue, TraceValue } from '../types'; +import type { TraceEvent } from '../types'; +import { normalizeTraceEvents } from '../normalize'; import { TraceTimeline } from './TraceTimeline'; -import { jmapMapToArray } from '@/lib/jmapUtils'; - -function normalizeTraceEvents(raw: unknown): TraceEvent[] { - const events = jmapMapToArray>(raw); - return events.map((evt) => ({ - event: String(evt.event ?? ''), - timestamp: String(evt.timestamp ?? ''), - keyValues: normalizeKeyValues(evt.keyValues), - })); -} - -function normalizeKeyValues(raw: unknown): TraceKeyValue[] { - const kvs = jmapMapToArray>(raw); - return kvs.map((kv) => ({ - key: String(kv.key ?? ''), - value: normalizeTraceValue(kv.value), - })); -} - -function normalizeTraceValue(raw: unknown): TraceValue { - if (!raw || typeof raw !== 'object') return { '@type': 'Null' }; - const obj = raw as Record; - const type = obj['@type'] as string; - - if (type === 'List') { - return { '@type': 'List', value: jmapMapToArray(obj.value).map(normalizeTraceValue) }; - } - if (type === 'Event') { - return { '@type': 'Event', event: String(obj.event ?? ''), value: normalizeKeyValues(obj.value) }; - } - return raw as TraceValue; -} interface TraceDetailViewProps { viewName: string; @@ -200,7 +171,11 @@ export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) { - + ({ '@type': 'TraceEvent', traceId: objectId, index })} + /> diff --git a/src/features/tracing/components/TraceTimeline.tsx b/src/features/tracing/components/TraceTimeline.tsx index 5b93cbd..0f45a1c 100644 --- a/src/features/tracing/components/TraceTimeline.tsx +++ b/src/features/tracing/components/TraceTimeline.tsx @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import { useEffect, useRef } from 'react'; @@ -10,6 +12,8 @@ import { Link } from 'react-router-dom'; import { CircleDot, CircleX, Clock } from 'lucide-react'; import { useSchemaStore } from '@/stores/schemaStore'; import type { TraceEvent, TraceKeyValue, TraceValue } from '../types'; +import { ExplainButton } from '@/features/ai/explain/ExplainButton'; +import { isRawEvent, type ExplainSubject } from '@/features/ai/explain/explain'; const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013'; @@ -36,9 +40,11 @@ function isIdKey(key: string): boolean { interface TraceTimelineProps { events: TraceEvent[]; anchorTimestamp?: string; + /** inbuxa: what "Explain this" asks about an event (ai-explain EX-17). */ + explainSubject?: (event: TraceEvent, index: number) => ExplainSubject; } -export function TraceTimeline({ events, anchorTimestamp }: TraceTimelineProps) { +export function TraceTimeline({ events, anchorTimestamp, explainSubject }: TraceTimelineProps) { const { t } = useTranslation(); const schema = useSchemaStore((s) => s.schema); const containerRef = useRef(null); @@ -139,9 +145,20 @@ export function TraceTimeline({ events, anchorTimestamp }: TraceTimelineProps) { )} {explanation &&

{explanation}

} -
-
{formatAbsoluteTime(event.timestamp)}
-
{formatRelativeTime(event.timestamp)}
+
+ {/* inbuxa: raw protocol traffic isn't sent to the model (EX-9) */} + {explainSubject && !isRawEvent(event.event) && ( + explainSubject(event, idx)} + /> + )} +
+
{formatAbsoluteTime(event.timestamp)}
+
{formatRelativeTime(event.timestamp)}
+
diff --git a/src/features/tracing/normalize.ts b/src/features/tracing/normalize.ts new file mode 100644 index 0000000..2807b52 --- /dev/null +++ b/src/features/tracing/normalize.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +import { jmapMapToArray } from '@/lib/jmapUtils'; +import type { TraceEvent, TraceKeyValue, TraceValue } from './types'; + +/** + * Trace events as the server sends them, stored or live: key-values arrive as + * a JMAP map ({"0": …, "1": …}), not an array. + */ +export function normalizeTraceEvents(raw: unknown): TraceEvent[] { + const events = jmapMapToArray>(raw); + return events.map((evt) => ({ + event: String(evt.event ?? ''), + timestamp: String(evt.timestamp ?? ''), + keyValues: normalizeKeyValues(evt.keyValues), + })); +} + +function normalizeKeyValues(raw: unknown): TraceKeyValue[] { + const kvs = jmapMapToArray>(raw); + return kvs.map((kv) => ({ + key: String(kv.key ?? ''), + value: normalizeTraceValue(kv.value), + })); +} + +function normalizeTraceValue(raw: unknown): TraceValue { + if (!raw || typeof raw !== 'object') return { '@type': 'Null' }; + const obj = raw as Record; + const type = obj['@type'] as string; + + if (type === 'List') { + return { '@type': 'List', value: jmapMapToArray(obj.value).map(normalizeTraceValue) }; + } + if (type === 'Event') { + return { '@type': 'Event', event: String(obj.event ?? ''), value: normalizeKeyValues(obj.value) }; + } + return raw as TraceValue; +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 12db2b1..fc924d7 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -23,6 +23,9 @@ import { Sidebar } from '@/components/layout/Sidebar'; import { SectionNav } from '@/components/layout/SectionNav'; import { MainContent } from '@/components/layout/MainContent'; import { SettingsApplyBanner } from '@/components/layout/SettingsApplyBanner'; +import { ExplainPanel } from '@/features/ai/explain/ExplainPanel'; +import { useExplainStore } from '@/features/ai/explain/explainStore'; +import { explainAvailable } from '@/features/ai/explain/explain'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { @@ -164,6 +167,8 @@ export default function AdminPanel() { if (cancelled) return; setSession(accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet); + // inbuxa: whether "Explain this" is offered (ai-explain EX-1 to EX-4) + useExplainStore.getState().setAvailable(explainAvailable(session, primaryAccountId)); useAuthStore.getState().setUsername(typeof session.username === 'string' ? session.username : null); // INBUXA: the theme lives with the account, shared with the webmail. setAccountSettingsTarget(session); @@ -327,6 +332,7 @@ export default function AdminPanel() { + ); }