From f1169e6e472edde2475270db801505e1d185e4a2 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 16:08:47 -0700 Subject: [PATCH] One-sentence intro at the top of every Settings page --- src/components/forms/DynamicForm.tsx | 12 +- src/components/lists/DynamicList.tsx | 7 +- src/help/settingsPages.test.ts | 30 +++ src/help/settingsPages.ts | 341 +++++++++++++++++++++++++++ src/help/texts.ts | 12 +- 5 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 src/help/settingsPages.test.ts create mode 100644 src/help/settingsPages.ts diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index 5e453fa..ae05aec 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -10,6 +10,7 @@ import { humanize } from '@/lib/humanize'; import { PageHeader } from '@/components/common/PageHeader'; import { HelpPanel } from '@/help/HelpPanel'; +import { pageAbout } from '@/help/texts'; import { fieldPlaceholder, formNotices, variantPrefill } from '@/features/ai/formExtras'; import { FailedRecipients } from '@/features/ai/explain/FailedRecipients'; import { iconForView } from '@/lib/viewIcon'; @@ -741,9 +742,14 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic }, [resolved, schema, isCreate, isSingleton, formData, viewName, titleForm, t]); const formSubtitle = useMemo(() => { - if (titleForm?.subtitle) return titleForm.subtitle; - return isSingleton && !titleForm ? resolved?.obj.objectType.description : undefined; - }, [titleForm, isSingleton, resolved]); + // inbuxa: our own one-line intro where written (settings-reorg), else the schema's. + const schemaSubtitle = titleForm?.subtitle + ? titleForm.subtitle + : isSingleton && !titleForm + ? resolved?.obj.objectType.description + : undefined; + return pageAbout(viewName, schemaSubtitle); + }, [titleForm, isSingleton, resolved, viewName]); if (!schema || !resolved) { return ( diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index 89f7eeb..308a9c6 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -7,6 +7,7 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ +import { pageAbout } from '@/help/texts'; import { EmptyState } from '@/components/common/EmptyState'; import { PageHeader } from '@/components/common/PageHeader'; import { HelpPanel } from '@/help/HelpPanel'; @@ -1131,7 +1132,11 @@ export function DynamicList({ viewName }: DynamicListProps) { return (
- +
{hasMassActions && selectedIds.size > 0 && ( diff --git a/src/help/settingsPages.test.ts b/src/help/settingsPages.test.ts new file mode 100644 index 0000000..2da2d03 --- /dev/null +++ b/src/help/settingsPages.test.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { SETTINGS_CATEGORIES } from '@/lib/settingsLayout'; +import { PAGE_HELP, pageAbout } from './texts'; + +describe('settings page intros', () => { + it('every Settings page has one sentence of our own', () => { + const pages = SETTINGS_CATEGORIES.flatMap((c) => c.groups.flatMap((g) => g.pages.map((p) => p.viewName))).filter( + (v) => !v.startsWith('CustomComponent/'), + ); + const missing = pages.filter((v) => !PAGE_HELP[v]?.about); + expect(missing).toEqual([]); + }); + + it('are short enough for a page heading', () => { + for (const [view, help] of Object.entries(PAGE_HELP)) { + expect(help.about.length, view).toBeLessThanOrEqual(130); + } + }); + + it('fall back to the schema where we have none', () => { + expect(pageAbout('x:SenderAuth', 'schema text')).not.toBe('schema text'); + expect(pageAbout('x:Nothing', 'schema text')).toBe('schema text'); + }); +}); diff --git a/src/help/settingsPages.ts b/src/help/settingsPages.ts new file mode 100644 index 0000000..22bc9a2 --- /dev/null +++ b/src/help/settingsPages.ts @@ -0,0 +1,341 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: one sentence at the top of every Settings page, in our own words + * (settings-reorg): what the page is for, for someone who hasn't run a mail + * server for decades. It replaces the schema's subtitle in the page heading + * and opens the page's "?" panel. Keys are view names. + */ + +import type { PageHelp } from './texts'; + +export const SETTINGS_PAGE_HELP: Record = { + // ── Mail flow ── + 'x:MtaInboundSession': { + about: 'How long other servers may stay connected and how much they may send in one conversation.', + }, + 'x:SenderAuth': { + about: + 'The checks that tell real mail from forged mail (SPF, DKIM, DMARC), and how strict to be with mail that fails them.', + }, + 'x:MtaSts': { + about: 'Tells other servers to always use encryption when they deliver to you, and to refuse if they can’t.', + }, + 'x:MtaOutboundStrategy': { + about: 'The rules that pick, for each outgoing message, which route, schedule, connection and encryption it uses.', + }, + 'x:MtaRoute': { + about: 'The ways outgoing mail can leave: straight to each recipient’s server, through a relay, or delivered here.', + }, + 'x:MtaDeliverySchedule': { + about: 'How often delivery is retried when the other side isn’t answering, and when the sender is told it failed.', + }, + 'x:MtaVirtualQueue': { + about: 'Separate lanes in the outgoing queue, so one busy destination or kind of mail doesn’t hold up the rest.', + }, + 'x:MtaInboundThrottle': { + about: + 'How fast other servers and your own users may connect and send, so a flood or a stolen password can’t run away.', + }, + 'x:MtaOutboundThrottle': { + about: 'How fast this server delivers to other servers, so big providers don’t see a burst and slow you down.', + }, + 'x:MtaQueueQuota': { + about: 'How much mail one sender or destination may have waiting in the queue at once.', + }, + 'x:ReportSettings': { + about: 'Which incoming reports the server reads, and the name and domain it signs its own reports with.', + }, + 'x:DmarcReportSettings': { + about: 'The reports this server sends to other domains about mail claiming to be from them.', + }, + 'x:TlsReportSettings': { + about: 'The reports this server sends to other domains about whether their mail reached you encrypted.', + }, + 'x:DkimReportSettings': { + about: 'The report sent back to a domain when a message signed by it fails its DKIM check here.', + }, + 'x:SpfReportSettings': { + about: 'The report sent back to a domain when mail claiming to come from it fails its SPF check here.', + }, + 'x:DsnReportSettings': { + about: 'Who bounce messages come from when mail can’t be delivered or is delayed.', + }, + 'x:MtaMilter': { + about: 'Outside mail filters (milters) that get a look at each incoming message, such as a virus scanner.', + }, + 'x:MtaHook': { + about: 'Outside programs this server asks, over HTTP, whether to accept or change each incoming message.', + }, + 'x:SieveSystemScript': { + about: 'Sieve scripts the server itself runs on mail, set up by an administrator.', + }, + 'x:SieveSystemInterpreter': { + about: 'Limits and defaults for the server’s own Sieve scripts. For specialists.', + }, + 'x:MtaStageConnect': { + about: 'What happens when another server first connects: the greeting and name this server gives. For specialists.', + }, + 'x:MtaStageEhlo': { + about: 'What’s required of the name a connecting server introduces itself with. For specialists.', + }, + 'x:MtaStageAuth': { + about: 'When mail apps must sign in to send, and which sign-in methods are offered. For specialists.', + }, + 'x:MtaStageMail': { + about: 'Rules for the sender address given at the start of each delivery. For specialists.', + }, + 'x:MtaStageRcpt': { + about: + 'Rules for recipients: who may relay, how many per message, and how unknown addresses are handled. For specialists.', + }, + 'x:MtaStageData': { + about: 'Rules for the message itself as it arrives: size, headers, spam filtering and scripts. For specialists.', + }, + 'x:MtaExtensions': { + about: 'Which optional SMTP features this server offers to other servers. For specialists.', + }, + 'x:MtaConnectionStrategy': { + about: 'How outgoing connections are made: which local address and name to use, and timeouts. For specialists.', + }, + 'x:MtaTlsStrategy': { + about: 'When outgoing delivery must be encrypted, and how strictly certificates are checked. For specialists.', + }, + + // ── Spam filter ── + 'x:SpamSettings': { + about: 'How hard the spam filter is, and who is always trusted.', + }, + 'x:SpamTag': { + about: 'How much each sign of spam adds to a message’s score. Higher totals mean more likely spam.', + }, + 'x:SpamRule': { + about: 'The rules that look for signs of spam. Updates keep any rule you’ve edited.', + }, + 'x:SpamClassifier': { + about: 'The filter that learns from the mail people mark as spam and not spam.', + }, + 'x:SpamLlm': { + about: 'An AI model that reads doubtful messages and gives its verdict. Off unless a model is set up.', + }, + 'x:SpamPyzor': { + about: 'Checks messages against Pyzor’s shared list of known spam, using a fingerprint rather than the message.', + }, + 'x:SpamDnsblSettings': { + about: 'How many blocklist lookups the filter may make per message.', + }, + 'x:SpamDnsblServer': { + about: 'The public blocklists the filter asks about senders, links and domains.', + }, + 'x:MemoryLookupKey/SpamTrustedDomain': { + about: 'Domains whose mail is never treated as spam.', + }, + 'x:MemoryLookupKey/SpamBlockedDomain': { + about: 'Domains whose mail is always treated as spam.', + }, + 'x:MemoryLookupKey/SpamTrap': { + about: 'Addresses nobody uses, so anything sent to them is spam and teaches the filter.', + }, + 'x:MemoryLookupKey/SpamUrlRedirector': { + about: 'Link shorteners and redirect services, which the filter follows to see where a link really goes.', + }, + 'x:SpamFileExtension': { + about: 'Attachment types that count as risky, like programs disguised as documents.', + }, + + // ── Security ── + 'x:Authentication': { + about: 'Where accounts and passwords come from, the rules for passwords, and what new accounts may do.', + }, + 'x:Directory': { + about: 'Outside places accounts can come from: Active Directory, LDAP, SQL or an OpenID Connect provider.', + }, + 'x:OidcProvider': { + about: 'This server as a sign-in provider for apps: how long sign-ins last and which apps may register.', + }, + 'x:Certificate': { + about: 'The TLS certificates that encrypt connections to this server.', + }, + 'x:AcmeProvider': { + about: 'Services like Let’s Encrypt that issue and renew certificates automatically.', + }, + 'x:Security': { + about: 'When the server blocks an address by itself: after repeated wrong passwords, abuse or scanning.', + }, + 'x:Asn': { + about: 'Where the server looks up which network and country an address belongs to, for filtering and reports.', + }, + + // ── Network ── + 'x:SystemSettings/NetworkSettings': { + about: 'This server’s name on the internet, its main domain and certificate, and which proxies it trusts.', + }, + 'x:NetworkListener': { + about: 'The ports the server listens on for mail, mail apps and the web.', + }, + 'x:SystemSettings/NetworkServices': { + about: 'The host names mail apps and other servers are told to use, in automatic setup and DNS.', + }, + 'x:Http': { + about: 'How the web server answers: extra headers, and trusting addresses passed on by a proxy.', + }, + 'x:Http/HttpSecurity': { + about: 'Web security: which pages are open, which sites may call the API, and forcing HTTPS.', + }, + 'x:HttpForm': { + about: 'A contact form on your website that arrives here as email.', + }, + 'x:DnsResolver': { + about: 'Which DNS servers this server asks when it looks things up, including for secure (DNSSEC) answers.', + }, + + // ── Mail & apps ── + 'x:Email/EmailStorage': { + about: 'How messages are stored, such as compressing them to save space.', + }, + 'x:Email/EmailDefaults': { + about: 'The folders every new mailbox starts with, and default limits for each person.', + }, + 'x:Email/EmailLimits': { + about: 'The largest message, folder depth and name length the server accepts.', + }, + 'x:Email/EmailEncryption': { + about: 'Encrypting stored mail with each person’s own key. Hard to undo; read the help before turning on.', + }, + 'x:Imap': { + about: 'Settings for IMAP mail apps: time limits, request sizes and sign-in rules.', + }, + 'x:Jmap/JmapLimits': { + about: 'Limits for JMAP apps, like the webmail: how much one request may ask for or upload.', + }, + 'x:Jmap/JmapPush': { + about: 'How apps are told about new mail straight away, without asking again and again.', + }, + 'x:Jmap/JmapWebsocket': { + about: 'The live connection apps like the webmail keep open for instant updates.', + }, + 'x:WebDav': { + about: 'Settings for calendar, contacts and file apps that use WebDAV, CalDAV or CardDAV.', + }, + 'x:Calendar': { + about: 'Limits for calendars and events, and how many each person may have.', + }, + 'x:CalendarScheduling': { + about: 'Meeting invitations: how they’re sent, received and answered.', + }, + 'x:CalendarAlarm': { + about: 'Email reminders for calendar events: who they come from and what they look like.', + }, + 'x:AddressBook': { + about: 'Limits for address books and contacts, and how many each person may have.', + }, + 'x:FileStorage': { + about: 'Limits for files people store on the server: file size and how many.', + }, + 'x:Sharing': { + about: 'Whether people can share calendars, contacts and files with each other, and with how many.', + }, + 'x:SieveUserInterpreter': { + about: 'What people’s own mail filters (Sieve) may do, and their vacation reply defaults.', + }, + 'x:SieveUserScript': { + about: 'Filter scripts you provide that people can include in their own filters.', + }, + 'x:Application': { + about: 'Web apps this server hosts, like inbuxa webmail, and where they’re downloaded from.', + }, + + // ── Storage ── + 'x:DataStore': { + about: 'Where mailbox structure, calendars, contacts and settings are kept. Changing it moves your data.', + }, + 'x:BlobStore': { + about: 'Where message bodies, attachments and files are kept.', + }, + 'x:SearchStore': { + about: 'Where the search index for mail, calendars and contacts is kept.', + }, + 'x:InMemoryStore': { + about: 'Fast short-lived storage for rate limits and state shared between servers.', + }, + 'x:TracingStore': { + about: 'Where delivery history is kept, or whether it’s kept at all.', + }, + 'x:MetricsStore': { + about: 'Where the history behind the dashboard’s charts is kept, or whether it’s kept at all.', + }, + 'x:DataRetention/DataExpunge': { + about: 'How long things are kept before they’re cleared out automatically.', + }, + 'x:DataRetention/DataCleanup': { + about: 'How long things are kept, and when the storage cleanup jobs run.', + }, + 'x:DataRetention/ArchivingRetention': { + about: 'How long deleted mail and accounts can still be brought back.', + }, + 'x:DataRetention/TelemetryRetention': { + about: 'How long delivery history and chart history are kept, and how often charts are updated.', + }, + 'x:Cache': { + about: 'How much memory is used to speed things up. Larger is faster, up to what the machine has.', + }, + 'x:Search': { + about: 'What gets indexed for search, and in which languages.', + }, + + // ── Monitoring ── + 'x:Tracer': { + about: 'Where the server writes its logs: files, the console, or a logging service.', + }, + 'x:EventTracingLevel': { + about: 'Make particular events louder or quieter in the logs than their usual level.', + }, + 'x:Metrics': { + about: 'Where the server’s numbers go, and which are collected.', + }, + 'x:Metrics/CollectorOtel': { + about: 'Where the server’s numbers go, and which are collected.', + }, + 'x:Metrics/CollectorPrometheus': { + about: 'Where the server’s numbers go, and which are collected.', + }, + 'x:Alert': { + about: 'Messages that tell you when something needs attention, like a backed-up queue.', + }, + 'x:WebHook': { + about: 'Events the server posts to your own systems as they happen.', + }, + + // ── System ── + 'x:Coordinator': { + about: 'How the servers in a cluster talk to each other. Only needed with more than one server.', + }, + 'x:ClusterRole': { + about: 'Which jobs each server in a cluster takes on. Only needed with more than one server.', + }, + 'x:TaskManager': { + about: 'How background jobs are retried when they fail, and when the server gives up on them.', + }, + 'x:AiModel': { + about: 'The AI models the server can use, such as for spam filtering and “Explain this”.', + }, + 'x:Enterprise': { + about: 'The logo shown on sign-in pages and in apps when a domain has none of its own.', + }, + 'x:HttpLookup': { + about: 'Lists fetched from the web, like public lists of spam domains, kept up to date for filters and rules.', + }, + 'x:StoreLookup': { + about: 'Lists kept in a database for filters and rules to check against. For specialists.', + }, + 'x:MemoryLookupKey': { + about: 'Named lists of values, like domains or addresses, that filters and rules check against.', + }, + 'x:MemoryLookupKeyValue': { + about: 'Named lists of values paired with settings, for filters and rules. For specialists.', + }, +}; diff --git a/src/help/texts.ts b/src/help/texts.ts index 36674bf..4bd6789 100644 --- a/src/help/texts.ts +++ b/src/help/texts.ts @@ -14,6 +14,8 @@ * Keys: `object` for a page, `object.field` for an option on it. */ +import { SETTINGS_PAGE_HELP } from './settingsPages'; + export const FIELD_HELP: Record = { // Domains 'x:Domain.name': 'The domain people’s addresses end in, like example.com.', @@ -73,7 +75,7 @@ export interface PageHelp { tasks?: string[]; } -export const PAGE_HELP: Record = { +const MANAGEMENT_PAGE_HELP: Record = { 'x:Domain': { about: 'The domains this server receives and sends mail for. Each person’s address belongs to one of them.', tasks: [ @@ -147,6 +149,14 @@ export const PAGE_HELP: Record = { }, }; +/** Every page's own words: Management's here, Settings' in settingsPages.ts. */ +export const PAGE_HELP: Record = { ...SETTINGS_PAGE_HELP, ...MANAGEMENT_PAGE_HELP }; + +/** A page's one-line intro: ours when written, else the schema's subtitle. */ +export function pageAbout(viewName: string, fallback?: string | null): string | undefined { + return PAGE_HELP[viewName]?.about ?? fallback ?? undefined; +} + /** The help text for an option: ours when written, else the schema's. */ export function fieldHelp(id: string | undefined, fallback?: string | null): string | null { return (id && FIELD_HELP[id]) || fallback || null; -- 2.54.0