diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index a27233f..5e453fa 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -13,7 +13,7 @@ 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 { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'; import { flushSync } from 'react-dom'; import { useNavigate, useBlocker } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -40,7 +40,8 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { ArrowLeft, Save, Trash2, Loader2 } from 'lucide-react'; +import { ArrowLeft, ChevronDown, Save, Trash2, Loader2 } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { AccountLockButton } from '@/features/lock/AccountLockButton'; import { useSchemaStore } from '@/stores/schemaStore'; @@ -73,9 +74,13 @@ import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap interface DynamicFormProps { viewName: string; objectId: string | null; + /** inbuxa: a summary drawn right under the page heading, above the fields (settings-reorg). */ + intro?: ReactNode; + /** inbuxa: fold the fields and Save under this label, for pages whose intro does the everyday work. */ + foldSections?: string; } -export function DynamicForm({ viewName, objectId }: DynamicFormProps) { +export function DynamicForm({ viewName, objectId, intro, foldSections }: DynamicFormProps) { const { t } = useTranslation(); const navigate = useNavigate(); const schema = useSchemaStore((s) => s.schema); @@ -790,6 +795,8 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { actions={} /> + {intro} + {generalError && (

{generalError}

@@ -816,136 +823,138 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { )} - {sectionsToRender.map((section, sectionIdx) => ( - - {section.title && ( - - {section.title} - - )} - -
- {resolved.obj.objectName === 'x:Domain' && - objectId && - !readOnly && - section.fields.some((sf) => sf.formField.name === 'dnsManagement') && ( - - )} - {section.fields.map((sf) => { - const { formField, field, visible, enterpriseDisabled } = sf; - if (!visible) return null; + + {sectionsToRender.map((section, sectionIdx) => ( + + {section.title && ( + + {section.title} + + )} + +
+ {resolved.obj.objectName === 'x:Domain' && + objectId && + !readOnly && + section.fields.some((sf) => sf.formField.name === 'dnsManagement') && ( + + )} + {section.fields.map((sf) => { + const { formField, field, visible, enterpriseDisabled } = sf; + if (!visible) return null; - if (formField.name === '@type' && sch.type === 'multiple') { - return ( -
- - -
+ if (formField.name === '@type' && sch.type === 'multiple') { + return ( +
+ + +
+ ); + } + + const fieldValue = formData[formField.name]; + const fieldError = fieldErrors[formField.name]; + const fieldReadOnly = + readOnly || + (field.update === 'immutable' && !isCreate) || + field.update === 'serverSet' || + enterpriseDisabled; + + const widget = ( + handleFieldChange(formField.name, v)} + readOnly={fieldReadOnly} + error={fieldError} + schema={schema} + sieveScriptName={ + isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined + } + helpScope={helpScope} + explainTarget={ + isCreate + ? undefined + : { object: resolved.obj.objectName, id: isSingleton ? 'singleton' : String(objectId) } + } + /> ); - } - const fieldValue = formData[formField.name]; - const fieldError = fieldErrors[formField.name]; - const fieldReadOnly = - readOnly || - (field.update === 'immutable' && !isCreate) || - field.update === 'serverSet' || - enterpriseDisabled; + if (enterpriseDisabled) { + return ( + + + +
{widget}
+
+ +

{t('enterprise.featureDisabled', "This feature isn't available on this server.")}

+
+
+
+ ); + } - const widget = ( - handleFieldChange(formField.name, v)} - readOnly={fieldReadOnly} - error={fieldError} - schema={schema} - sieveScriptName={ - isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined - } - helpScope={helpScope} - explainTarget={ - isCreate - ? undefined - : { object: resolved.obj.objectName, id: isSingleton ? 'singleton' : String(objectId) } - } - /> - ); + return widget; + })} +
+
+
+ ))} - if (enterpriseDisabled) { - return ( - - - -
{widget}
-
- -

{t('enterprise.featureDisabled', "This feature isn't available on this server.")}

-
-
-
- ); - } - - return widget; - })} -
-
-
- ))} - -
-
- {canDelete && ( - + )} + {/* inbuxa: lock or unlock a person beside Delete (AL-1) */} + {viewName === 'x:Account/User' && objectId && } +
+
+ {isDirty && ( + {t('form.unsavedChangesLabel', 'Unsaved changes')} + )} + - )} - {/* inbuxa: lock or unlock a person beside Delete (AL-1) */} - {viewName === 'x:Account/User' && objectId && } + {!readOnly && ( + + )} +
-
- {isDirty && ( - {t('form.unsavedChangesLabel', 'Unsaved changes')} - )} - - {!readOnly && ( - - )} -
-
+ {children}; + return ( + + + + {label} + + {children} + + ); +} diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index ba1a3e7..02243f2 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -17,6 +17,7 @@ import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; +import { SpamSettingsPage } from '@/features/spamLevel/SpamSettingsPage'; import { WebhooksPage } from '@/features/events/WebhooksPage'; import { ListenersPage } from '@/features/ports/ListenersPage'; import { MetricsPage } from '@/features/metricsConnect/MetricsPage'; @@ -276,6 +277,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (resolved.objectName === 'x:Metrics') { return ; } + // inbuxa: Spam filter › General opens with how hard the filter is (settings-reorg). + if (resolved.objectName === 'x:SpamSettings') { + return ; + } if (resolved.objectName === 'x:Security') { return (
diff --git a/src/features/metricsConnect/MetricsPage.tsx b/src/features/metricsConnect/MetricsPage.tsx index 1b56aba..d793134 100644 --- a/src/features/metricsConnect/MetricsPage.tsx +++ b/src/features/metricsConnect/MetricsPage.tsx @@ -6,27 +6,20 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ChevronDown } from 'lucide-react'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { MetricsConnect } from './MetricsConnect'; -/** A metrics page: where the numbers go, then this page's own settings folded underneath. */ +/** A metrics page: the page heading, where the numbers go, then this page's own settings folded underneath. */ export function MetricsPage({ viewName }: { viewName: string }) { const { t } = useTranslation(); const [version, setVersion] = useState(0); return ( -
- setVersion((v) => v + 1)} /> - - - - {t('metricsConnect.more', 'Which metrics, and every setting on this page')} - - - - - -
+ setVersion((v) => v + 1)} />} + foldSections={t('metricsConnect.more', 'Which metrics, and every setting on this page')} + /> ); } diff --git a/src/features/reports/ReportsPage.tsx b/src/features/reports/ReportsPage.tsx index 29a17dd..8e3dc83 100644 --- a/src/features/reports/ReportsPage.tsx +++ b/src/features/reports/ReportsPage.tsx @@ -6,31 +6,20 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ChevronDown } from 'lucide-react'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { ReportsSheet } from './ReportsSheet'; -/** - * A report page: what goes out and what comes in, in a few sentences, then - * this page's own settings (names, subjects, signing) folded underneath and - * reloaded after a save. - */ +/** A report page: the page heading, what goes out and comes in, then this page's own settings folded underneath and reloaded after a save. */ export function ReportsPage({ viewName }: { viewName: string }) { const { t } = useTranslation(); const [version, setVersion] = useState(0); return ( -
- setVersion((v) => v + 1)} /> - - - - {t('reports.more', 'Names, subjects and every setting on this page')} - - - - - -
+ setVersion((v) => v + 1)} />} + foldSections={t('reports.more', 'Names, subjects and every setting on this page')} + /> ); } diff --git a/src/features/retention/RetentionPage.tsx b/src/features/retention/RetentionPage.tsx index 9d120ce..9e7a92a 100644 --- a/src/features/retention/RetentionPage.tsx +++ b/src/features/retention/RetentionPage.tsx @@ -6,31 +6,20 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ChevronDown } from 'lucide-react'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { RetentionSheet } from './RetentionSheet'; -/** - * A retention page: every "how long" as sentences, then this page's own - * settings (schedules and the like) folded underneath, reloaded after the - * sentences are saved so the two never disagree. - */ +/** A retention page: the page heading, every "how long" as sentences, then this page's own settings folded underneath and reloaded after a save. */ export function RetentionPage({ viewName }: { viewName: string }) { const { t } = useTranslation(); const [version, setVersion] = useState(0); return ( -
- setVersion((v) => v + 1)} /> - - - - {t('retention.more', 'Schedules and every setting on this page')} - - - - - -
+ setVersion((v) => v + 1)} />} + foldSections={t('retention.more', 'Schedules and every setting on this page')} + /> ); } diff --git a/src/features/senderChecks/SenderChecksPage.tsx b/src/features/senderChecks/SenderChecksPage.tsx index 8413e68..050077c 100644 --- a/src/features/senderChecks/SenderChecksPage.tsx +++ b/src/features/senderChecks/SenderChecksPage.tsx @@ -8,13 +8,15 @@ import { useState } from 'react'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { SenderChecksLevels } from './SenderChecksLevels'; -/** Sender checks: the level choice, then the full form, reloaded when a level is applied. */ +/** Sender checks: the page heading, the level choice, then the full form, reloaded when a level is applied. */ export function SenderChecksPage({ viewName }: { viewName: string }) { const [version, setVersion] = useState(0); return ( -
- setVersion((v) => v + 1)} /> - -
+ setVersion((v) => v + 1)} />} + /> ); } diff --git a/src/features/spamLevel/SpamLevels.tsx b/src/features/spamLevel/SpamLevels.tsx new file mode 100644 index 0000000..e03462f --- /dev/null +++ b/src/features/spamLevel/SpamLevels.tsx @@ -0,0 +1,274 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: at the top of Spam filter › General, how hard the filter is as + * one choice, and the few switches that matter (settings-reorg, first + * wave). The form below stays for anything finer. + */ + +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Loader2, ShieldAlert } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { getAccountId, jmapRequest } from '@/services/jmap/client'; +import { useAccountStore } from '@/stores/accountStore'; +import { useSchemaStore } from '@/stores/schemaStore'; +import type { JmapSetResponse } from '@/types/jmap'; +import { toast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; +import { LEVEL_THRESHOLDS, LEVELS, levelOf, outcomes, type Level, type Thresholds } from './levels'; + +interface State { + thresholds: Thresholds; + enable: boolean; + trustContacts: boolean; + trustReplies: boolean; + pyzor: boolean | null; +} + +async function load(): Promise { + const res = await jmapRequest([ + [ + 'x:SpamSettings/get', + { + accountId: getAccountId('x:SpamSettings'), + ids: ['singleton'], + properties: ['scoreSpam', 'scoreReject', 'scoreDiscard', 'enable', 'trustContacts', 'trustReplies'], + }, + 's', + ], + ['x:SpamPyzor/get', { accountId: getAccountId('x:SpamPyzor'), ids: ['singleton'], properties: ['enable'] }, 'p'], + ]); + const one = (id: string) => + ((res.find(([name, , i]) => i === id && name !== 'error')?.[1] as { list?: Record[] } | undefined) + ?.list ?? [])[0]; + const s = one('s') ?? {}; + const p = one('p'); + return { + thresholds: { + scoreSpam: Number(s.scoreSpam ?? 5), + scoreReject: Number(s.scoreReject ?? 0), + scoreDiscard: Number(s.scoreDiscard ?? 0), + }, + enable: s.enable !== false, + trustContacts: s.trustContacts !== false, + trustReplies: s.trustReplies !== false, + pyzor: p ? p.enable !== false : null, + }; +} + +export function SpamLevels({ onApplied }: { onApplied?: () => void }) { + const { t } = useTranslation(); + const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysSpamSettings', 'Update')); + const viewToSection = useSchemaStore((s) => s.viewToSection); + const [saved, setSaved] = useState(null); + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let live = true; + load() + .then((s) => { + if (!live) return; + setSaved(s); + setDraft(s); + }) + .catch(() => undefined); + return () => { + live = false; + }; + }, []); + + if (!saved || !draft) return null; + + const current = levelOf(saved.thresholds); + const picked = levelOf(draft.thresholds); + const changed = + JSON.stringify(draft.thresholds) !== JSON.stringify(saved.thresholds) || + draft.enable !== saved.enable || + draft.trustContacts !== saved.trustContacts || + draft.trustReplies !== saved.trustReplies || + draft.pyzor !== saved.pyzor; + + const label: Record = { + relaxed: t('spamLevels.relaxed', 'Relaxed'), + balanced: t('spamLevels.balanced', 'Balanced'), + strict: t('spamLevels.strict', 'Strict'), + }; + const hint: Record = { + relaxed: t( + 'spamLevels.relaxedHint', + 'Only clear spam goes to Junk. Some spam reaches the inbox; almost nothing real is misfiled.', + ), + balanced: t('spamLevels.balancedHint', 'The server’s stock setting. Likely spam goes to Junk; nothing is refused.'), + strict: t( + 'spamLevels.strictHint', + 'More goes to Junk, and blatant spam is refused outright. The sender of a misjudged message gets a bounce.', + ), + }; + const link = (view: string, text: string) => ( + + {text} + + ); + + const save = async () => { + setBusy(true); + try { + const calls: [string, Record, string][] = []; + const patch: Record = {}; + if (JSON.stringify(draft.thresholds) !== JSON.stringify(saved.thresholds)) Object.assign(patch, draft.thresholds); + if (draft.enable !== saved.enable) patch.enable = draft.enable; + if (draft.trustContacts !== saved.trustContacts) patch.trustContacts = draft.trustContacts; + if (draft.trustReplies !== saved.trustReplies) patch.trustReplies = draft.trustReplies; + if (Object.keys(patch).length > 0) { + calls.push([ + 'x:SpamSettings/set', + { accountId: getAccountId('x:SpamSettings'), update: { singleton: patch } }, + 's', + ]); + } + if (draft.pyzor !== saved.pyzor && draft.pyzor !== null) { + calls.push([ + 'x:SpamPyzor/set', + { accountId: getAccountId('x:SpamPyzor'), update: { singleton: { enable: draft.pyzor } } }, + 'p', + ]); + } + const responses = await jmapRequest(calls); + const failed = responses + .map(([, body]) => (body as unknown as JmapSetResponse).notUpdated?.singleton) + .filter(Boolean) + .map((e) => e!.description ?? e!.type); + if (failed.length > 0) throw new Error(failed.join('; ')); + const fresh = await load(); + setSaved(fresh); + setDraft(fresh); + onApplied?.(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('spamLevels.failed', 'Not saved'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }; + + const toggle = (key: 'enable' | 'trustContacts' | 'trustReplies' | 'pyzor', text: string, sub?: string) => ( + + ); + + return ( +
+
+ +

{t('spamLevels.title', 'How hard the spam filter is')}

+ {current === null && ( + + {t('spamLevels.byHand', 'Thresholds set by hand below')} + + )} + {changed && ( + + )} +
+ + {toggle('enable', t('spamLevels.enable', 'Filter incoming mail for spam'))} + + {draft.enable && ( + <> +
+ {LEVELS.map((l) => ( + + ))} +
+
    + {outcomes(draft.thresholds).map((line) => ( +
  • {line}
  • + ))} +
+ {draft.thresholds.scoreDiscard > 0 && ( +

+ {t( + 'spamLevels.discardWarning', + 'A discard threshold is set by hand. Picking a level turns it off: deleted mail can’t be found again, and nobody is told.', + )} +

+ )} + +
+ {toggle( + 'trustReplies', + t('spamLevels.trustReplies', 'Never treat replies to people’s own messages as spam'), + )} + {toggle( + 'trustContacts', + t('spamLevels.trustContacts', 'Never treat mail from people in their address book as spam'), + )} + {draft.pyzor !== null && + toggle( + 'pyzor', + t('spamLevels.pyzor', 'Check messages against Pyzor’s shared spam list'), + t( + 'spamLevels.pyzorHint', + 'Sends a fingerprint of each message, not the message itself, to public.pyzor.org.', + ), + )} +
+ +

+ {t('spamLevels.more', 'More:')} {link('CustomComponent/LocalAi', t('spamLevels.localAi', 'Local AI'))} + {' · '} + {link('x:SpamDnsblServer', t('spamLevels.blocklists', 'Blocklists'))} + {' · '} + {link('x:SpamTag', t('spamLevels.scores', 'Scores'))} + {' · '} + {link('x:MemoryLookupKey/SpamTrustedDomain', t('spamLevels.trusted', 'Trusted domains'))} +

+ + )} +
+ ); +} diff --git a/src/features/spamLevel/SpamSettingsPage.tsx b/src/features/spamLevel/SpamSettingsPage.tsx new file mode 100644 index 0000000..7900980 --- /dev/null +++ b/src/features/spamLevel/SpamSettingsPage.tsx @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useState } from 'react'; +import { DynamicForm } from '@/components/forms/DynamicForm'; +import { SpamLevels } from './SpamLevels'; + +/** Spam filter › General: the page heading, how hard the filter is, then the full form, reloaded after a save. */ +export function SpamSettingsPage({ viewName }: { viewName: string }) { + const [version, setVersion] = useState(0); + return ( + setVersion((v) => v + 1)} />} + /> + ); +} diff --git a/src/features/spamLevel/levels.test.ts b/src/features/spamLevel/levels.test.ts new file mode 100644 index 0000000..3d3573e --- /dev/null +++ b/src/features/spamLevel/levels.test.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { levelOf, LEVEL_THRESHOLDS, outcomes } from './levels'; + +describe('spam levels', () => { + it('Balanced is the stock setting', () => { + expect(levelOf({ scoreSpam: 5, scoreReject: 0, scoreDiscard: 0 })).toBe('balanced'); + }); + + it('recognise each level, and hand-set values as none', () => { + for (const l of ['relaxed', 'balanced', 'strict'] as const) expect(levelOf(LEVEL_THRESHOLDS[l])).toBe(l); + expect(levelOf({ scoreSpam: 6, scoreReject: 0, scoreDiscard: 0 })).toBeNull(); + expect(levelOf({ scoreSpam: 5, scoreReject: 0, scoreDiscard: 20 })).toBeNull(); + }); + + it('never discard, and say what happens in words', () => { + for (const l of ['relaxed', 'balanced', 'strict'] as const) expect(LEVEL_THRESHOLDS[l].scoreDiscard).toBe(0); + expect(outcomes(LEVEL_THRESHOLDS.strict)).toEqual([ + 'Scoring 4 or more: filed in Junk.', + '12 or more: refused, and the sender gets a bounce.', + ]); + expect(outcomes(LEVEL_THRESHOLDS.balanced)[1]).toBe( + 'Nothing is refused or deleted: people can always look in Junk.', + ); + }); +}); diff --git a/src/features/spamLevel/levels.ts b/src/features/spamLevel/levels.ts new file mode 100644 index 0000000..6bb8b9c --- /dev/null +++ b/src/features/spamLevel/levels.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: how hard the spam filter is, as three levels (settings-reorg, + * first wave). What the thresholds do (spam-filter/src/analysis/score.rs): + * + * - scoreSpam: a message scoring at or above it goes to Junk; + * - scoreReject: at or above it, the message is refused during delivery, so + * the sender gets a bounce and a mistake is visible to them; + * - scoreDiscard: at or above it, the message is accepted and silently + * deleted. Nobody learns of a mistake, so no level uses it. + * + * A reject or discard threshold of 0 is off. Balanced is the server's stock + * setting. + */ + +export type Level = 'relaxed' | 'balanced' | 'strict'; +export const LEVELS: Level[] = ['relaxed', 'balanced', 'strict']; + +export interface Thresholds { + scoreSpam: number; + scoreReject: number; + scoreDiscard: number; +} + +export const LEVEL_THRESHOLDS: Record = { + relaxed: { scoreSpam: 8, scoreReject: 0, scoreDiscard: 0 }, + balanced: { scoreSpam: 5, scoreReject: 0, scoreDiscard: 0 }, + strict: { scoreSpam: 4, scoreReject: 12, scoreDiscard: 0 }, +}; + +export function levelOf(t: Partial): Level | null { + return ( + LEVELS.find((l) => { + const want = LEVEL_THRESHOLDS[l]; + return ( + Number(t.scoreSpam) === want.scoreSpam && + Number(t.scoreReject ?? 0) === want.scoreReject && + Number(t.scoreDiscard ?? 0) === want.scoreDiscard + ); + }) ?? null + ); +} + +/** What happens to a message by score, in words: "5 and up: Junk. 12 and up: refused." */ +export function outcomes(t: Thresholds): string[] { + const lines = [`Scoring ${t.scoreSpam} or more: filed in Junk.`]; + if (t.scoreReject > 0) lines.push(`${t.scoreReject} or more: refused, and the sender gets a bounce.`); + if (t.scoreDiscard > 0) lines.push(`${t.scoreDiscard} or more: accepted and deleted without telling anyone.`); + if (t.scoreReject <= 0 && t.scoreDiscard <= 0) + lines.push('Nothing is refused or deleted: people can always look in Junk.'); + return lines; +}