Spam filter strength as a level: Relaxed, Balanced or Strict #50
No files matched your search
@@ -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={<HelpPanel viewName={viewName} title={String(formTitle ?? '')} />}
|
||||
/>
|
||||
|
||||
{intro}
|
||||
|
||||
{generalError && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
|
||||
<p className="text-sm text-destructive">{generalError}</p>
|
||||
@@ -816,136 +823,138 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
<FailedRecipients queueId={objectId} recipients={formData.recipients} />
|
||||
)}
|
||||
|
||||
{sectionsToRender.map((section, sectionIdx) => (
|
||||
<Card key={sectionIdx}>
|
||||
{section.title && (
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{section.title}</CardTitle>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={section.title ? '' : 'pt-6'}>
|
||||
<div className="space-y-6">
|
||||
{resolved.obj.objectName === 'x:Domain' &&
|
||||
objectId &&
|
||||
!readOnly &&
|
||||
section.fields.some((sf) => sf.formField.name === 'dnsManagement') && (
|
||||
<DnsConnectCard
|
||||
domainId={objectId}
|
||||
automatic={
|
||||
(originalData.dnsManagement as { '@type'?: string } | undefined)?.['@type'] === 'Automatic'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{section.fields.map((sf) => {
|
||||
const { formField, field, visible, enterpriseDisabled } = sf;
|
||||
if (!visible) return null;
|
||||
<FoldedSections label={foldSections}>
|
||||
{sectionsToRender.map((section, sectionIdx) => (
|
||||
<Card key={sectionIdx}>
|
||||
{section.title && (
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{section.title}</CardTitle>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={section.title ? '' : 'pt-6'}>
|
||||
<div className="space-y-6">
|
||||
{resolved.obj.objectName === 'x:Domain' &&
|
||||
objectId &&
|
||||
!readOnly &&
|
||||
section.fields.some((sf) => sf.formField.name === 'dnsManagement') && (
|
||||
<DnsConnectCard
|
||||
domainId={objectId}
|
||||
automatic={
|
||||
(originalData.dnsManagement as { '@type'?: string } | undefined)?.['@type'] === 'Automatic'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{section.fields.map((sf) => {
|
||||
const { formField, field, visible, enterpriseDisabled } = sf;
|
||||
if (!visible) return null;
|
||||
|
||||
if (formField.name === '@type' && sch.type === 'multiple') {
|
||||
return (
|
||||
<div key="@type" className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{formField.label}</Label>
|
||||
<Select value={selectedVariant} onValueChange={handleVariantChange} disabled={readOnly}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('form.selectType', 'Select type...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sch.variants.map((v) => (
|
||||
<SelectItem key={v.name} value={v.name}>
|
||||
{v.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
if (formField.name === '@type' && sch.type === 'multiple') {
|
||||
return (
|
||||
<div key="@type" className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{formField.label}</Label>
|
||||
<Select value={selectedVariant} onValueChange={handleVariantChange} disabled={readOnly}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('form.selectType', 'Select type...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sch.variants.map((v) => (
|
||||
<SelectItem key={v.name} value={v.name}>
|
||||
{v.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldValue = formData[formField.name];
|
||||
const fieldError = fieldErrors[formField.name];
|
||||
const fieldReadOnly =
|
||||
readOnly ||
|
||||
(field.update === 'immutable' && !isCreate) ||
|
||||
field.update === 'serverSet' ||
|
||||
enterpriseDisabled;
|
||||
|
||||
const widget = (
|
||||
<FieldWidget
|
||||
key={formField.name}
|
||||
field={field}
|
||||
formField={
|
||||
formField.placeholder
|
||||
? formField
|
||||
: {
|
||||
...formField,
|
||||
// inbuxa: local example addresses on the AI model form
|
||||
placeholder: fieldPlaceholder(resolved.obj.objectName, formField.name),
|
||||
}
|
||||
}
|
||||
value={fieldValue}
|
||||
onChange={(v) => 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 (
|
||||
<TooltipProvider key={formField.name}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="opacity-60">{widget}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('enterprise.featureDisabled', "This feature isn't available on this server.")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const widget = (
|
||||
<FieldWidget
|
||||
key={formField.name}
|
||||
field={field}
|
||||
formField={
|
||||
formField.placeholder
|
||||
? formField
|
||||
: {
|
||||
...formField,
|
||||
// inbuxa: local example addresses on the AI model form
|
||||
placeholder: fieldPlaceholder(resolved.obj.objectName, formField.name),
|
||||
}
|
||||
}
|
||||
value={fieldValue}
|
||||
onChange={(v) => 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;
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
if (enterpriseDisabled) {
|
||||
return (
|
||||
<TooltipProvider key={formField.name}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="opacity-60">{widget}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('enterprise.featureDisabled', "This feature isn't available on this server.")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return widget;
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 pb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
{canDelete && (
|
||||
<Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('common.delete', 'Delete')}
|
||||
<div className="flex items-center justify-between pt-2 pb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
{canDelete && (
|
||||
<Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('common.delete', 'Delete')}
|
||||
</Button>
|
||||
)}
|
||||
{/* inbuxa: lock or unlock a person beside Delete (AL-1) */}
|
||||
{viewName === 'x:Account/User' && objectId && <AccountLockButton accountId={objectId} disabled={saving} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isDirty && (
|
||||
<span className="text-xs text-muted-foreground">{t('form.unsavedChangesLabel', 'Unsaved changes')}</span>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={saving}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{/* inbuxa: lock or unlock a person beside Delete (AL-1) */}
|
||||
{viewName === 'x:Account/User' && objectId && <AccountLockButton accountId={objectId} disabled={saving} />}
|
||||
{!readOnly && (
|
||||
<Button type="button" onClick={handleSave} disabled={saving || (!isCreate && !isDirty)}>
|
||||
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
{isCreate ? t('common.create', 'Create') : t('common.save', 'Save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isDirty && (
|
||||
<span className="text-xs text-muted-foreground">{t('form.unsavedChangesLabel', 'Unsaved changes')}</span>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={saving}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button type="button" onClick={handleSave} disabled={saving || (!isCreate && !isDirty)}>
|
||||
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
{isCreate ? t('common.create', 'Create') : t('common.save', 'Save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FoldedSections>
|
||||
|
||||
<AlertDialog
|
||||
open={blocker.state === 'blocked'}
|
||||
@@ -1198,3 +1207,17 @@ function isOtpAuthValid(data: unknown): boolean {
|
||||
}
|
||||
|
||||
export default DynamicForm;
|
||||
|
||||
/** inbuxa: the form's fields, folded under a label when the page's intro does the everyday work. */
|
||||
function FoldedSections({ label, children }: { label?: string; children: ReactNode }) {
|
||||
if (!label) return <>{children}</>;
|
||||
return (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="group flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=closed]:-rotate-90" />
|
||||
{label}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-6 pt-4">{children}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -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 <MetricsPage viewName={viewName} />;
|
||||
}
|
||||
// inbuxa: Spam filter › General opens with how hard the filter is (settings-reorg).
|
||||
if (resolved.objectName === 'x:SpamSettings') {
|
||||
return <SpamSettingsPage viewName={viewName} />;
|
||||
}
|
||||
if (resolved.objectName === 'x:Security') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<MetricsConnect onSaved={() => setVersion((v) => v + 1)} />
|
||||
<Collapsible className="mx-auto max-w-4xl">
|
||||
<CollapsibleTrigger className="group flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=closed]:-rotate-90" />
|
||||
{t('metricsConnect.more', 'Which metrics, and every setting on this page')}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">
|
||||
<DynamicForm key={version} viewName={viewName} objectId="singleton" />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<DynamicForm
|
||||
key={version}
|
||||
viewName={viewName}
|
||||
objectId="singleton"
|
||||
intro={<MetricsConnect onSaved={() => setVersion((v) => v + 1)} />}
|
||||
foldSections={t('metricsConnect.more', 'Which metrics, and every setting on this page')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<ReportsSheet onSaved={() => setVersion((v) => v + 1)} />
|
||||
<Collapsible className="mx-auto max-w-4xl">
|
||||
<CollapsibleTrigger className="group flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=closed]:-rotate-90" />
|
||||
{t('reports.more', 'Names, subjects and every setting on this page')}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">
|
||||
<DynamicForm key={version} viewName={viewName} objectId="singleton" />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<DynamicForm
|
||||
key={version}
|
||||
viewName={viewName}
|
||||
objectId="singleton"
|
||||
intro={<ReportsSheet onSaved={() => setVersion((v) => v + 1)} />}
|
||||
foldSections={t('reports.more', 'Names, subjects and every setting on this page')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<RetentionSheet onSaved={() => setVersion((v) => v + 1)} />
|
||||
<Collapsible className="mx-auto max-w-4xl">
|
||||
<CollapsibleTrigger className="group flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=closed]:-rotate-90" />
|
||||
{t('retention.more', 'Schedules and every setting on this page')}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">
|
||||
<DynamicForm key={version} viewName={viewName} objectId="singleton" />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<DynamicForm
|
||||
key={version}
|
||||
viewName={viewName}
|
||||
objectId="singleton"
|
||||
intro={<RetentionSheet onSaved={() => setVersion((v) => v + 1)} />}
|
||||
foldSections={t('retention.more', 'Schedules and every setting on this page')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<SenderChecksLevels onApplied={() => setVersion((v) => v + 1)} />
|
||||
<DynamicForm key={version} viewName={viewName} objectId="singleton" />
|
||||
</div>
|
||||
<DynamicForm
|
||||
key={version}
|
||||
viewName={viewName}
|
||||
objectId="singleton"
|
||||
intro={<SenderChecksLevels onApplied={() => setVersion((v) => v + 1)} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<State> {
|
||||
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<string, unknown>[] } | 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<State | null>(null);
|
||||
const [draft, setDraft] = useState<State | null>(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<Level, string> = {
|
||||
relaxed: t('spamLevels.relaxed', 'Relaxed'),
|
||||
balanced: t('spamLevels.balanced', 'Balanced'),
|
||||
strict: t('spamLevels.strict', 'Strict'),
|
||||
};
|
||||
const hint: Record<Level, string> = {
|
||||
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) => (
|
||||
<Link className="text-primary hover:underline" to={`/${viewToSection[view] ?? 'Settings'}/${view}`}>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const calls: [string, Record<string, unknown>, string][] = [];
|
||||
const patch: Record<string, unknown> = {};
|
||||
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) => (
|
||||
<label className="flex items-start justify-between gap-4 py-2 text-sm">
|
||||
<span>
|
||||
{text}
|
||||
{sub && <span className="block text-xs text-muted-foreground">{sub}</span>}
|
||||
</span>
|
||||
<Switch
|
||||
checked={Boolean(draft[key])}
|
||||
disabled={!canUpdate}
|
||||
onCheckedChange={(v) => setDraft((d) => (d ? { ...d, [key]: v } : d))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-4 rounded-xl border bg-card p-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ShieldAlert className="h-5 w-5 text-primary" />
|
||||
<p className="flex-1 font-medium">{t('spamLevels.title', 'How hard the spam filter is')}</p>
|
||||
{current === null && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{t('spamLevels.byHand', 'Thresholds set by hand below')}
|
||||
</span>
|
||||
)}
|
||||
{changed && (
|
||||
<Button type="button" size="sm" onClick={() => void save()} disabled={busy || !canUpdate}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('spamLevels.save', 'Save changes')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{toggle('enable', t('spamLevels.enable', 'Filter incoming mail for spam'))}
|
||||
|
||||
{draft.enable && (
|
||||
<>
|
||||
<div className="grid gap-2 sm:grid-cols-3" role="radiogroup">
|
||||
{LEVELS.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={picked === l}
|
||||
disabled={!canUpdate}
|
||||
onClick={() => setDraft((d) => (d ? { ...d, thresholds: { ...LEVEL_THRESHOLDS[l] } } : d))}
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors hover:border-primary/60',
|
||||
picked === l ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'bg-background',
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center gap-2 text-sm font-medium">
|
||||
{label[l]}
|
||||
{current === l && (
|
||||
<span className="ml-auto text-xs font-normal text-muted-foreground">
|
||||
{t('spamLevels.now', 'Current')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{hint[l]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{outcomes(draft.thresholds).map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
{draft.thresholds.scoreDiscard > 0 && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="divide-y border-t">
|
||||
{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.',
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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'))}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<DynamicForm
|
||||
key={version}
|
||||
viewName={viewName}
|
||||
objectId="singleton"
|
||||
intro={<SpamLevels onApplied={() => setVersion((v) => v + 1)} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<Level, Thresholds> = {
|
||||
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<Thresholds>): 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;
|
||||
}
|
||||
Reference in new issue
Block a user