From 6337dc53649f5d46efda1e1a0b5b9c223bbe1df6 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 10:40:47 -0700 Subject: [PATCH] Expression fields as choices: values from a list, conditions in words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the server's schema naming each expression field's allowed constants and variables, an expression field opens in a plain form: - a value that is one of the constants is a dropdown (Relaxed, Strict, Off), with Custom expression… for anything else; - a condition that is one comparison reads "Port on this server / is / 25", with the variable picked from the ones the field allows; - a line above says the whole thing in words when it can ("Relaxed when port on this server is 25; otherwise off"). What the form can't read stays as text, and Edit as text gives the full IF / THEN / ELSE editor back. Without hints from the server the field is unchanged. --- .../expression/ExpressionEditor.tsx | 38 ++- .../expression/SimpleExpressionEditor.tsx | 299 ++++++++++++++++++ src/components/expression/simple.test.ts | 77 +++++ src/components/expression/simple.ts | 185 +++++++++++ src/components/forms/FieldWidget.tsx | 6 + src/types/schema.ts | 5 + 6 files changed, 607 insertions(+), 3 deletions(-) create mode 100644 src/components/expression/SimpleExpressionEditor.tsx create mode 100644 src/components/expression/simple.test.ts create mode 100644 src/components/expression/simple.ts diff --git a/src/components/expression/ExpressionEditor.tsx b/src/components/expression/ExpressionEditor.tsx index 6513f06..6ec5a85 100644 --- a/src/components/expression/ExpressionEditor.tsx +++ b/src/components/expression/ExpressionEditor.tsx @@ -1,7 +1,10 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import { useState } from 'react'; @@ -12,7 +15,9 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; -import { X, Plus } from 'lucide-react'; +import { X, Plus, ListChecks } from 'lucide-react'; +import { SimpleExpressionEditor } from './SimpleExpressionEditor'; +import type { ExpressionHints } from './simple'; function BufferedExprInput({ value, @@ -48,14 +53,29 @@ interface ExpressionEditorProps { }; onChange: (value: { match: Record; else: string }) => void; readOnly?: boolean; + /** inbuxa: what the field accepts, from the schema; with it, the field opens in plain form. */ + hints?: ExpressionHints; } -export function ExpressionEditor({ value, onChange, readOnly = false }: ExpressionEditorProps) { +export function ExpressionEditor({ value, onChange, readOnly = false, hints }: ExpressionEditorProps) { const { t } = useTranslation(); const [nextIndex, setNextIndex] = useState(() => { const keys = Object.keys(value.match).map(Number).filter(Number.isFinite); return keys.length > 0 ? Math.max(...keys) + 1 : 0; }); + const [asText, setAsText] = useState(false); + + if (hints && !asText) { + return ( + setAsText(true)} + /> + ); + } const matchEntries = Object.entries(value.match); @@ -135,7 +155,19 @@ export function ExpressionEditor({ value, onChange, readOnly = false }: Expressi ))} {!readOnly && ( -
+
+ {hints && ( + + )} + )} +
+
+ {t('expression.use', 'Use')} + setRule(key, 'then', v)} readOnly={readOnly} /> + {!readOnly &&
} +
+
+ ))} + +
+ {entries.length > 0 && {t('expression.otherwise', 'Otherwise')}} + onChange({ ...value, else: v })} + readOnly={readOnly} + /> + {!readOnly && entries.length > 0 &&
} +
+ + {!readOnly && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/components/expression/simple.test.ts b/src/components/expression/simple.test.ts new file mode 100644 index 0000000..9d90121 --- /dev/null +++ b/src/components/expression/simple.test.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { describeValue, formatCondition, parseCondition, summarize, writeLiteral } from './simple'; + +const hints = { + constants: ['relaxed', 'strict', 'disable'], + variables: ['local_port', 'listener', 'sender_domain', 'remote_ip', 'is_tls'], +}; + +describe('parseCondition', () => { + it('reads one comparison of an allowed variable with a literal', () => { + expect(parseCondition('local_port == 25', hints.variables)).toEqual({ + variable: 'local_port', + op: '==', + value: '25', + }); + expect(parseCondition("listener != 'smtp'", hints.variables)).toEqual({ + variable: 'listener', + op: '!=', + value: 'smtp', + }); + expect(parseCondition('is_tls == true', hints.variables)).toEqual({ variable: 'is_tls', op: '==', value: 'true' }); + }); + + it('leaves anything more complex alone', () => { + expect(parseCondition('is_local_domain(sender_domain)', hints.variables)).toBeNull(); + expect(parseCondition('local_port == 25 && is_tls', hints.variables)).toBeNull(); + expect(parseCondition('local_port == sender_domain', hints.variables)).toBeNull(); + expect(parseCondition('unknown_var == 1', hints.variables)).toBeNull(); + }); +}); + +describe('literals', () => { + it('quotes words and leaves numbers, durations and booleans bare', () => { + expect(writeLiteral('smtp')).toBe("'smtp'"); + expect(writeLiteral('25')).toBe('25'); + expect(writeLiteral('5m')).toBe('5m'); + expect(writeLiteral('false')).toBe('false'); + expect(writeLiteral("it's")).toBe("'it\\'s'"); + }); + + it('round-trips a condition', () => { + const c = parseCondition("listener == 'smtp'", hints.variables)!; + expect(formatCondition(c)).toBe("listener == 'smtp'"); + expect( + parseCondition(formatCondition({ variable: 'listener', op: '==', value: "it's" }), hints.variables)?.value, + ).toBe("it's"); + }); +}); + +describe('describeValue', () => { + it('names constants and unquotes literals, but not expressions', () => { + expect(describeValue('disable', hints)).toBe('Off'); + expect(describeValue("'mx.example.com'", hints)).toBe('mx.example.com'); + expect(describeValue('sender_domain', hints)).toBeNull(); + }); +}); + +describe('summarize', () => { + it('says the whole expression in words when it can', () => { + expect(summarize({ match: {}, else: 'relaxed' }, hints)).toBe('Relaxed'); + expect(summarize({ match: { '0': { if: 'local_port == 25', then: 'relaxed' } }, else: 'disable' }, hints)).toBe( + 'Relaxed when port on this server is 25; otherwise off', + ); + }); + + it('gives up rather than guess', () => { + expect( + summarize({ match: { '0': { if: 'is_local_domain(sender_domain)', then: 'relaxed' } }, else: 'disable' }, hints), + ).toBeNull(); + }); +}); diff --git a/src/components/expression/simple.ts b/src/components/expression/simple.ts new file mode 100644 index 0000000..3068863 --- /dev/null +++ b/src/components/expression/simple.ts @@ -0,0 +1,185 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: expression fields in plain words (settings-reorg, expression simple + * mode). The server's schema says, for each expression field, which constants + * it may evaluate to and which variables its conditions may read. With that, + * a value that is one of the constants becomes a choice ("Relaxed", "Off"), + * and a condition of the form `variable op literal` becomes three controls + * ("Port on this server" "is" "25"). Anything else stays text: the simple + * form never rewrites what it can't read. + */ + +import { humanize } from '@/lib/humanize'; + +export interface ExpressionHints { + constants: string[]; + variables: string[]; +} + +export type Operator = '==' | '!=' | '>' | '<' | '>=' | '<='; +export const OPERATORS: Operator[] = ['==', '!=', '>', '<', '>=', '<=']; + +export interface SimpleCondition { + variable: string; + op: Operator; + /** The literal as a person types it: no quotes. */ + value: string; +} + +const CONSTANT_LABELS: Record = { + relaxed: 'Relaxed', + strict: 'Strict', + disable: 'Off', + optional: 'Optional', + require: 'Required', + ipv4_only: 'IPv4 only', + ipv6_only: 'IPv6 only', + ipv6_then_ipv4: 'IPv6, then IPv4', + ipv4_then_ipv6: 'IPv4, then IPv6', + hourly: 'Hourly', + daily: 'Daily', + weekly: 'Weekly', + login: 'LOGIN', + plain: 'PLAIN', + xoauth2: 'XOAUTH2', + oauthbearer: 'OAUTHBEARER', + mixer: 'MIXER', + stanag4406: 'STANAG 4406', + nsep: 'NSEP', +}; + +const VARIABLE_LABELS: Record = { + local_port: 'Port on this server', + local_ip: 'IP on this server', + remote_ip: 'Remote IP', + remote_port: 'Remote port', + 'remote_ip.ptr': 'Remote host name (PTR)', + listener: 'Listener', + protocol: 'Protocol', + is_tls: 'Connection uses TLS', + authenticated_as: 'Signed-in account', + sender: 'Sender', + sender_domain: 'Sender’s domain', + rcpt: 'Recipient', + rcpt_domain: 'Recipient’s domain', + recipients: 'Recipients', + helo_domain: 'Greeting name (EHLO)', + asn: 'Network (ASN)', + country: 'Country', + priority: 'Priority', + size: 'Message size', + mx: 'Mail exchanger', + host: 'Host', + domain: 'Domain', + ip: 'IP address', + is_v4: 'Address is IPv4', + is_v6: 'Address is IPv6', + reverse_ip: 'Reverse IP', + queue_name: 'Queue', + queue_age: 'Time in queue', + retry_num: 'Retries so far', + notify_num: 'Notices sent so far', + last_status: 'Last status', + last_error: 'Last error', + expires_in: 'Time until expiry', + source: 'Source', + received_from_ip: 'Received from IP', + received_via_port: 'Received on port', + env_from: 'Envelope sender', + env_to: 'Envelope recipient', +}; + +const OPERATOR_LABELS: Record = { + '==': 'is', + '!=': 'is not', + '>': 'is more than', + '<': 'is less than', + '>=': 'is at least', + '<=': 'is at most', +}; + +export function constantLabel(name: string): string { + return CONSTANT_LABELS[name] ?? humanize(name); +} + +export function variableLabel(name: string): string { + return VARIABLE_LABELS[name] ?? humanize(name); +} + +export function operatorLabel(op: Operator): string { + return OPERATOR_LABELS[op]; +} + +const BARE_LITERAL = /^(?:-?\d+(?:\.\d+)?(?:ms|s|m|h|d)?|true|false)$/; + +/** A literal from the expression, unquoted for display; null if it isn't a plain literal. */ +function readLiteral(raw: string): string | null { + const s = raw.trim(); + const quoted = /^(['"])((?:(?!\1)[^\\]|\\.)*)\1$/.exec(s); + if (quoted) return quoted[2].replace(/\\(.)/g, '$1'); + return BARE_LITERAL.test(s) ? s : null; +} + +/** A value as the expression needs it: numbers, durations and booleans bare, everything else quoted. */ +export function writeLiteral(value: string): string { + const s = value.trim(); + if (BARE_LITERAL.test(s)) return s; + return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} + +/** `local_port == 25` → {local_port, ==, 25}; null for anything more than one comparison. */ +export function parseCondition(text: string, variables: string[]): SimpleCondition | null { + const m = /^\s*([a-z_][a-z0-9_.]*)\s*(==|!=|>=|<=|>|<)\s*(.+?)\s*$/.exec(text); + if (!m || !variables.includes(m[1])) return null; + const value = readLiteral(m[3]); + if (value === null) return null; + return { variable: m[1], op: m[2] as Operator, value }; +} + +export function formatCondition(c: SimpleCondition): string { + return `${c.variable} ${c.op} ${writeLiteral(c.value)}`; +} + +/** A value (THEN / ELSE) in words: a constant's label, a literal unquoted, or null if it's an expression. */ +export function describeValue(text: string, hints: ExpressionHints): string | null { + const s = text.trim(); + if (hints.constants.includes(s)) return constantLabel(s); + return readLiteral(s); +} + +/** A label inside a sentence: "Remote port" → "remote port", but "IP on this server" stays. */ +function midSentence(label: string): string { + return /^[A-Z][a-z]/.test(label) ? label[0].toLowerCase() + label.slice(1) : label; +} + +export function describeCondition(text: string, hints: ExpressionHints): string | null { + const c = parseCondition(text, hints.variables); + return c ? `${midSentence(variableLabel(c.variable))} ${operatorLabel(c.op)} ${c.value}` : null; +} + +/** + * The whole expression in one sentence, when every part of it can be said + * in words: "Relaxed when port on this server is 25; otherwise Off". + */ +export function summarize( + value: { match: Record; else: string }, + hints: ExpressionHints, +): string | null { + const rules = Object.values(value.match); + const otherwise = describeValue(value.else, hints); + if (otherwise === null) return null; + if (rules.length === 0) return otherwise; + const parts: string[] = []; + for (const rule of rules) { + const when = describeCondition(rule.if, hints); + const then = describeValue(rule.then, hints); + if (when === null || then === null) return null; + parts.push(`${then} when ${when}`); + } + return `${parts.join('; ')}; otherwise ${midSentence(otherwise)}`; +} diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index 87e32fb..651e323 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -30,6 +30,7 @@ import { Calendar } from '@/components/ui/calendar'; import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { ExpressionEditor } from '@/components/expression/ExpressionEditor'; +import type { ExpressionHints } from '@/components/expression/simple'; import { OtpAuthField } from '@/components/forms/OtpAuthField'; import { SievepadButton } from '@/components/forms/SievepadButton'; import { @@ -179,6 +180,7 @@ export function FieldWidget(props: FieldWidgetProps) { schema={schema} formField={formField} nullable={ft.nullable} + expression={ft.expression} /> ); case 'objectList': @@ -1395,6 +1397,8 @@ interface EmbeddedObjectFieldProps { schema: Schema; formField: FormField; nullable?: boolean; + /** inbuxa: an expression field's allowed constants and variables, from the schema. */ + expression?: ExpressionHints; } function EmbeddedObjectField({ @@ -1405,6 +1409,7 @@ function EmbeddedObjectField({ schema, formField, nullable, + expression, }: EmbeddedObjectFieldProps) { const { t } = useTranslation(); const objValue = (value as Record) ?? {}; @@ -1418,6 +1423,7 @@ function EmbeddedObjectField({ }} onChange={(v) => onChange(v)} readOnly={readOnly} + hints={expression} /> ); } diff --git a/src/types/schema.ts b/src/types/schema.ts index a83a6aa..d628299 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -1,7 +1,10 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import type { Dashboard } from '@/features/dashboard/types/schema'; @@ -128,6 +131,8 @@ export interface FieldTypeObject { type: 'object'; objectName: string; nullable?: boolean; + /** inbuxa: on an x:Expression, the constants it may evaluate to and the variables it may read. */ + expression?: { constants: string[]; variables: string[] }; } export interface FieldTypeObjectList { -- 2.54.0