Guided wizards, opt-in every time, and automatic DNS as the first

A job that has a wizard now asks "Guide me / I'll do it myself" each time
it starts; nothing is remembered. The shared wizard shell gives every guide
a stepper, a side panel on what each step does and how to undo it, and the
way forward or back.

Automatic DNS, from a domain's DNS section:
- finds where the domain's DNS is hosted from its SOA and NS records, and
  offers that host when the server can drive it;
- for the major hosts, steps to create the narrowest credential, and the
  field named as the steps name it;
- records grouped by what they do, TLSA off unless the zone is signed;
- saves the provider and switches the domain over, removing the provider
  again if the switch fails;
- watches the publishing task and public DNS, ticking each record green,
  and boils a host's refusal down to its distinct messages;
- for hosts it can't drive, or domains not in DNS yet, every record laid
  out for copying, with the same live checks.
This commit is contained in:
2026-09-19 01:41:00 -07:00
parent 2e02532279
commit 5641560a91
18 changed files with 2435 additions and 1 deletions
+13 -1
View File
@@ -57,6 +57,7 @@ import { SECRET_MASK } from '@/lib/jmapUtils';
import { toast } from '@/hooks/use-toast';
import { logFormChange } from '@/lib/debug';
import { FieldWidget } from '@/components/forms/FieldWidget';
import { DnsConnectCard } from '@/features/dns/DnsConnectCard';
import { isSieveScriptField } from '@/lib/sievepad';
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
@@ -777,6 +778,17 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
)}
<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;
@@ -833,7 +845,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
<div className="opacity-60">{widget}</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('enterprise.featureDisabled', 'This feature isn\'t available on this server.')}</p>
<p>{t('enterprise.featureDisabled', "This feature isn't available on this server.")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
+18
View File
@@ -1,5 +1,6 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
@@ -35,6 +36,10 @@ const TraceDetailView = lazyFeature(
() => import('@/features/tracing/components/TraceDetailView'),
(m) => m.TraceDetailView,
);
const ConnectDnsPage = lazyFeature(
() => import('@/features/dns/ConnectDnsPage'),
(m) => m.ConnectDnsPage,
);
const ActionPage = lazyFeature(
() => import('@/features/actions/ActionPage'),
(m) => m.ActionPage,
@@ -67,6 +72,19 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
return <DashboardView dashboardId={dashboardId} section={section ?? ''} />;
}
// INBUXA: guided jobs. Always reached by choosing "Guide me", never by default.
if (viewName.startsWith('Wizard/')) {
const [, wizard, param] = viewName.split('/');
if (wizard === 'dns' && param) {
return <ConnectDnsPage domainId={param} />;
}
return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
Unknown guide: {wizard}
</div>
);
}
if (viewName.startsWith('CustomComponent/')) {
const componentName = viewName.slice('CustomComponent/'.length);
if (componentName === 'Dashboard') {
+92
View File
@@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useTranslation } from 'react-i18next';
import { ListChecks, SlidersHorizontal } from 'lucide-react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
/**
* "Guided or manual?" Every job that has a wizard asks this each time it
* starts. Nothing is remembered: the wizard is always opt-in.
*/
export function LaunchChoice({
open,
onOpenChange,
title,
guidedHint,
manualHint,
onGuided,
onManual,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
guidedHint: string;
manualHint?: string;
onGuided: () => void;
onManual: () => void;
}) {
const { t } = useTranslation();
const option = (
icon: typeof ListChecks,
heading: string,
hint: string,
onClick: () => void,
accent: boolean,
autoFocus: boolean,
) => {
const Icon = icon;
return (
<button
type="button"
autoFocus={autoFocus}
onClick={() => {
onOpenChange(false);
onClick();
}}
className={cn(
'group flex flex-col items-start gap-3 rounded-xl border p-5 text-left transition-all',
'hover:-translate-y-0.5 hover:border-primary/60 hover:shadow-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
accent ? 'border-primary/40 bg-primary/5' : 'border-border bg-card',
)}
>
<span
className={cn(
'flex h-10 w-10 items-center justify-center rounded-xl',
accent ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground',
)}
>
<Icon className="h-5 w-5" />
</span>
<span className="font-medium">{heading}</span>
<span className="text-sm text-muted-foreground">{hint}</span>
</button>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{t('wizard.chooseHow', 'How would you like to do this?')}</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
{option(ListChecks, t('wizard.guided', 'Guide me'), guidedHint, onGuided, true, true)}
{option(
SlidersHorizontal,
t('wizard.manual', "I'll do it myself"),
manualHint ?? t('wizard.manualHint', 'The full form, with every option at once.'),
onManual,
false,
false,
)}
</div>
</DialogContent>
</Dialog>
);
}
+148
View File
@@ -0,0 +1,148 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, ArrowRight, Check, Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { PageHeader } from '@/components/common/PageHeader';
import { cn } from '@/lib/utils';
export interface WizardStep {
id: string;
title: string;
}
/**
* The frame every guided job shares: where you are in it, the step itself,
* a side panel saying what this step does (and how to undo it, for the big
* jobs), and the way forward or back. The steps own their content and decide
* when "Next" is allowed; the shell never does anything on its own.
*/
export function WizardShell({
icon,
title,
subtitle,
steps,
current,
children,
aside,
canNext = true,
busy = false,
nextLabel,
onBack,
onNext,
onCancel,
hideFooter = false,
}: {
icon: string;
title: ReactNode;
subtitle?: ReactNode;
steps: WizardStep[];
current: number;
children: ReactNode;
aside?: ReactNode;
canNext?: boolean;
busy?: boolean;
nextLabel?: string;
onBack?: () => void;
onNext?: () => void;
onCancel: () => void;
hideFooter?: boolean;
}) {
const { t } = useTranslation();
return (
<div className="mx-auto max-w-5xl space-y-6">
<PageHeader
icon={icon}
title={title}
subtitle={subtitle}
actions={
<Button variant="ghost" onClick={onCancel}>
<X className="h-4 w-4" />
{t('wizard.close', 'Close')}
</Button>
}
/>
<ol className="flex flex-wrap items-center gap-x-2 gap-y-3" aria-label={t('wizard.progress', 'Progress')}>
{steps.map((s, i) => {
const done = i < current;
const here = i === current;
return (
<li key={s.id} className="flex items-center gap-2" aria-current={here ? 'step' : undefined}>
<span
className={cn(
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors',
done && 'bg-primary text-primary-foreground',
here && 'bg-primary/15 text-primary ring-2 ring-primary',
!done && !here && 'bg-muted text-muted-foreground',
)}
>
{done ? <Check className="h-3.5 w-3.5" /> : i + 1}
</span>
<span className={cn('text-sm', here ? 'font-medium text-foreground' : 'text-muted-foreground')}>
{s.title}
</span>
{i < steps.length - 1 && <span className="mx-1 hidden h-px w-8 bg-border sm:block" aria-hidden />}
</li>
);
})}
</ol>
<div className={cn('grid items-start gap-6', aside && 'lg:grid-cols-[minmax(0,1fr)_18rem]')}>
<Card>
<CardContent className="space-y-6 pt-6">{children}</CardContent>
</Card>
{aside && <aside className="space-y-4 text-sm">{aside}</aside>}
</div>
{!hideFooter && (
<div className="flex items-center justify-between">
{onBack ? (
<Button variant="ghost" onClick={onBack} disabled={busy}>
<ArrowLeft className="h-4 w-4" />
{t('wizard.back', 'Back')}
</Button>
) : (
<span />
)}
{onNext && (
<Button onClick={onNext} disabled={!canNext || busy}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{nextLabel ?? t('wizard.next', 'Next')}
{!busy && <ArrowRight className="h-4 w-4" />}
</Button>
)}
</div>
)}
</div>
);
}
/** A side-panel note: what this step does, or how to undo it. */
export function WizardNote({
title,
children,
tone = 'plain',
}: {
title: string;
children: ReactNode;
tone?: 'plain' | 'undo';
}) {
return (
<div
className={cn(
'rounded-xl border p-4',
tone === 'undo' ? 'border-emerald-500/30 bg-emerald-500/5' : 'border-border bg-card',
)}
>
<p className="mb-1.5 font-medium">{title}</p>
<div className="space-y-2 text-muted-foreground">{children}</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+204
View File
@@ -0,0 +1,204 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Check, Copy, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { WizardNote, WizardShell } from '@/components/wizard/WizardShell';
import { cn } from '@/lib/utils';
import { RECORD_GROUPS } from './records';
import { RESOLVER_NAME } from './liveCheck';
import { useRecordChecks } from './useRecordChecks';
import { ProgressRing, StateIcon } from './parts';
import type { DomainInfo } from './ConnectDnsPage';
import { hostLabel, pasteParts, type ZoneRecord } from './zone';
function CopyButton({ text, label }: { text: string; label: string }) {
const { t } = useTranslation();
const [done, setDone] = useState(false);
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
onClick={() => {
void navigator.clipboard.writeText(text).then(() => {
setDone(true);
setTimeout(() => setDone(false), 1500);
});
}}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{done ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</TooltipTrigger>
<TooltipContent>{done ? t('dnsCopy.copied', 'Copied') : label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
/**
* The by-hand path, for hosts the server can't drive: every record set out
* the way a host's DNS panel asks for it, a copy button on each part, and a
* live tick as each one appears in public DNS.
*/
export function CopyStep({
common,
domain,
zoneName,
records,
onBack,
onDone,
}: {
common: Omit<Parameters<typeof WizardShell>[0], 'children'>;
domain: DomainInfo;
zoneName: string;
records: ZoneRecord[];
onBack: () => void;
onDone: () => void;
}) {
const { t } = useTranslation();
const { states, checking, lastChecked, check, liveCount, allLive } = useRecordChecks(records, domain.id, false);
const pct = records.length ? Math.round((liveCount / records.length) * 100) : 0;
const zoneText = records.map((r) => `${r.name}. IN ${r.type} ${r.value}`).join('\n');
return (
<WizardShell
{...common}
subtitle={t('dnsCopy.subtitle', 'Add these at your DNS host. Each one ticks green as the internet sees it.')}
onBack={onBack}
onNext={onDone}
nextLabel={allLive ? t('dnsWizard.done', 'Done') : t('dnsCopy.later', 'Ill finish later')}
aside={
<>
<WizardNote title={t('dnsCopy.howTitle', 'How to add them')}>
<p>
{t(
'dnsCopy.how1',
'In your DNS hosts panel, add a record for each row: pick the type, paste the name and the value.',
)}
</p>
<p>
{t(
'dnsCopy.how2',
'The name is shown the way most panels want it: “@” means {{zone}} itself. If yours asks for full names, add .{{zone}} to the end.',
{ zone: zoneName },
)}
</p>
<p>
{t(
'dnsCopy.how3',
'Set the TTL to Auto or one hour. If a record with the same name and type exists, replace it.',
)}
</p>
</WizardNote>
<WizardNote title={t('dnsWizard.howChecked', 'How this is checked')}>
<p>
{t(
'dnsWizard.howCheckedBody',
'Every few seconds this page asks {{resolver}} for each record, so a green tick means the whole internet can see it.',
{ resolver: RESOLVER_NAME },
)}
</p>
</WizardNote>
</>
}
>
<div className="flex flex-wrap items-center gap-6">
<ProgressRing pct={pct} done={allLive} />
<div className="min-w-0 flex-1 space-y-1">
<h2 className="text-lg font-semibold">
{allLive
? t('dnsWizard.allLive', 'All set. {{domain}} is live.', { domain: domain.name })
: t('dnsWizard.progress', '{{live}} of {{total}} records are live', {
live: liveCount,
total: records.length,
})}
</h2>
<div className="flex flex-wrap items-center gap-3 pt-1 text-xs text-muted-foreground">
<Button variant="outline" size="sm" onClick={() => void check()} disabled={checking}>
<RefreshCw className={cn('h-3.5 w-3.5', checking && 'animate-spin')} />
{t('dnsWizard.checkNow', 'Check now')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void navigator.clipboard.writeText(zoneText)}
title={t('dnsCopy.zoneHint', 'For hosts that can import a zone file')}
>
<Copy className="h-3.5 w-3.5" />
{t('dnsCopy.copyZone', 'Copy all as a zone file')}
</Button>
{lastChecked &&
t('dnsWizard.lastChecked', 'Last checked {{time}}', { time: lastChecked.toLocaleTimeString() })}
</div>
</div>
</div>
{RECORD_GROUPS.map((g) => {
const rows = records.filter((r) => g.kinds.some((k) => k.kind === r.kind));
if (rows.length === 0) return null;
return (
<section key={g.id} className="space-y-2">
<div>
<h3 className="font-medium">{g.title}</h3>
<p className="text-sm text-muted-foreground">{g.why}</p>
</div>
<div className="divide-y rounded-xl border">
{rows.map((r, i) => {
const host = hostLabel(r.name, zoneName);
const { value, priority } = pasteParts(r);
return (
<div
key={`${r.name}-${r.type}-${i}`}
className="grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid-cols-[1.25rem_4.5rem_minmax(0,12rem)_minmax(0,1fr)] sm:items-center"
>
<StateIcon state={states.get(r)} />
<span className="flex flex-col font-mono text-xs font-semibold leading-tight">
{r.type}
{priority && (
<span className="font-sans text-[11px] font-normal text-muted-foreground">
{t('dnsCopy.priority', 'priority {{p}}', { p: priority })}
</span>
)}
</span>
<span className="col-start-2 flex min-w-0 items-center gap-1 sm:col-start-auto">
<span className="truncate font-mono text-xs" title={r.name}>
{host}
</span>
<CopyButton text={host} label={t('dnsCopy.copyName', 'Copy name')} />
</span>
<span className="col-start-2 flex min-w-0 items-center gap-1 sm:col-start-auto">
<span className="truncate font-mono text-xs text-muted-foreground" title={value}>
{value}
</span>
<CopyButton text={value} label={t('dnsCopy.copyValue', 'Copy value')} />
</span>
</div>
);
})}
</div>
</section>
);
})}
{records.some((r) => states.get(r) === 'different') && (
<p className="text-xs text-muted-foreground">
<AlertTriangle className="mr-1 inline h-3.5 w-3.5 text-highlight" />
{t(
'dnsWizard.differentHelp',
'An amber mark means that name has a different value right now, often an old record that hasnt expired from caches yet.',
)}
</p>
)}
</WizardShell>
);
}
+63
View File
@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CheckCircle2, Wand2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { LaunchChoice } from '@/components/wizard/LaunchChoice';
import { useSchemaStore } from '@/stores/schemaStore';
/**
* At the top of a domain's DNS section: an invitation to have the server
* publish the records itself. It asks "guided or manual?" every time; manual
* closes the question and leaves you at the DNS Management field just below.
*/
export function DnsConnectCard({ domainId, automatic }: { domainId: string; automatic: boolean }) {
const { t } = useTranslation();
const navigate = useNavigate();
const section = useSchemaStore((s) => s.viewToSection['x:Domain']) ?? 'Management';
const [open, setOpen] = useState(false);
return (
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
{automatic ? <CheckCircle2 className="h-5 w-5" /> : <Wand2 className="h-5 w-5" />}
</span>
<div className="min-w-0 flex-1">
<p className="font-medium">
{automatic
? t('dnsCard.autoTitle', 'The server keeps this domains DNS up to date')
: t('dnsCard.title', 'Let the server publish your DNS records')}
</p>
<p className="text-sm text-muted-foreground">
{automatic
? t('dnsCard.autoHint', 'See which records are live, or change what it publishes.')
: t(
'dnsCard.hint',
'Connect your DNS host, such as Cloudflare, and skip copying records by hand. Optional.',
)}
</p>
</div>
<Button type="button" variant={automatic ? 'outline' : 'default'} onClick={() => setOpen(true)}>
{automatic ? t('dnsCard.review', 'Check records') : t('dnsCard.start', 'Set it up')}
</Button>
<LaunchChoice
open={open}
onOpenChange={setOpen}
title={t('dnsCard.chooseTitle', 'Automatic DNS')}
guidedHint={t(
'dnsCard.guidedHint',
'Pick your DNS host, paste a key, choose records, and watch them go live. About two minutes.',
)}
manualHint={t('dnsCard.manualHint', 'Use the DNS Management setting below, with every option at once.')}
onGuided={() => navigate(`/${section}/Wizard/dns/${domainId}`)}
onManual={() => undefined}
/>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import { providerForNameservers } from './detect';
describe('providerForNameservers', () => {
it('knows the big hosts', () => {
expect(providerForNameservers(['dean.ns.cloudflare.com', 'gina.ns.cloudflare.com.'])).toBe('Cloudflare');
expect(providerForNameservers(['ns-421.awsdns-52.com', 'ns-1707.awsdns-21.co.uk'])).toBe('Route53');
expect(providerForNameservers(['ns-cloud-a1.googledomains.com'])).toBe('GoogleCloudDns');
expect(providerForNameservers(['ns1-01.azure-dns.com', 'ns2-01.azure-dns.net'])).toBe('AzureDns');
expect(providerForNameservers(['ns1.digitalocean.com'])).toBe('DigitalOcean');
expect(providerForNameservers(['hydrogen.ns.hetzner.com', 'helium.ns.hetzner.de'])).toBe('Hetzner');
expect(providerForNameservers(['ns01.domaincontrol.com'])).toBe('Godaddy');
expect(providerForNameservers(['dns1.gandi.net', 'e.gandi-ns.fr'])).toBe('GandiV5');
});
it('refuses to guess for split or unknown hosting', () => {
expect(providerForNameservers(['dns1.p08.nsone.net', 'ns-421.awsdns-52.com'])).toBeNull();
expect(providerForNameservers(['ns1.example-hosting.net'])).toBeNull();
expect(providerForNameservers(['dean.ns.cloudflare.com', 'ns1.example-hosting.net'])).toBeNull();
expect(providerForNameservers([])).toBeNull();
});
});
+93
View File
@@ -0,0 +1,93 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Where is a domain's DNS hosted? Found the way any resolver would: the SOA
* record names the zone that holds the domain (which may be a parent zone),
* and the zone's NS records name the host. The host is then matched against
* nameserver patterns we know for the providers the server can drive.
*/
import { dohQuery } from './liveCheck';
export interface DnsHosting {
/** The zone holding the domain, e.g. example.com for mail.example.com. */
zone: string;
nameservers: string[];
/** The server's provider type, when the host is one it can update. */
variant: string | null;
}
/**
* Nameserver suffixes by provider type. Only hosts whose nameservers are
* unambiguous are listed: a wrong guess costs the user more than no guess.
*/
const NAMESERVERS: [RegExp, string][] = [
[/\.ns\.cloudflare\.com$/, 'Cloudflare'],
[/\.awsdns-\d+\.(com|net|org|co\.uk)$/, 'Route53'],
[/^ns-cloud-[a-z]\d+\.googledomains\.com$/, 'GoogleCloudDns'],
[/\.azure-dns\.(com|net|org|info)$/, 'AzureDns'],
[/^ns\d\.digitalocean\.com$/, 'DigitalOcean'],
[/\.ns\.hetzner\.(com|de)$/, 'Hetzner'],
[/\.(ovh\.net|ovh\.ca|anycast\.me)$/, 'Ovh'],
[/\.domaincontrol\.com$/, 'Godaddy'],
[/\.porkbun\.com$/, 'Porkbun'],
[/^ns\d\.desec\.(io|org)$/, 'DeSEC'],
[/\.linode\.com$/, 'Linode'],
[/\.vultr\.com$/, 'Vultr'],
[/\.gandi\.net$|\.gandi-ns\.(fr|com|net)$/, 'GandiV5'],
[/\.registrar-servers\.com$/, 'Namecheap'],
[/\.dnsimple(-edge)?\.(com|net|org|info)$/, 'Dnsimple'],
[/\.bunny\.net$/, 'Bunny'],
[/\.nsone\.net$/, 'Ns1'],
[/\.ui-dns\.(com|de|org|biz)$/, 'Ionos'],
[/\.dnsmadeeasy\.com$/, 'DnsMadeEasy'],
[/\.cloudns\.net$/, 'ClouDns'],
[/^ns\d\.he\.net$/, 'Hurricane'],
[/\.vercel-dns\.com$/, 'Vercel'],
[/\.name\.com$/, 'NameDotCom'],
[/\.inwx\.(de|net|eu)$/, 'Inwx'],
[/\.transip\.(nl|net|eu)$/, 'Transip'],
[/\.scw\.cloud$/, 'Scaleway'],
[/\.infomaniak\.ch$/, 'Infomaniak'],
[/\.dns-parking\.com$/, 'Hostinger'],
[/\.akam\.net$/, 'EdgeDns'],
[/\.exoscale\.(ch|net|io|com)$/, 'Exoscale'],
[/\.netcup\.net$/, 'Netcup'],
[/\.joker\.com$/, 'Joker'],
[/\.glesys\.se$/, 'Glesys'],
[/\.dreamhost\.com$/, 'Dreamhost'],
[/\.easydns\.(com|net|org|info)$/, 'EasyDns'],
[/\.ultradns\.(com|net|org|biz|info|co\.uk)$/, 'UltraDns'],
[/\.mythic-beasts\.com$/, 'MythicBeasts'],
[/\.luadns\.net$/, 'LuaDns'],
[/\.spaceship\.net$/, 'Spaceship'],
[/\.hosting\.de$/, 'HostingDe'],
];
/** The provider type for a set of nameservers, if they all point to one we know. */
export function providerForNameservers(nameservers: string[]): string | null {
const hits = new Set<string>();
for (const ns of nameservers) {
const host = ns.toLowerCase().replace(/\.$/, '');
const match = NAMESERVERS.find(([re]) => re.test(host));
if (!match) return null;
hits.add(match[1]);
}
return hits.size === 1 ? [...hits][0] : null;
}
/** Find the zone and host for a domain; null when it isn't in public DNS yet. */
export async function detectHosting(domain: string, signal?: AbortSignal): Promise<DnsHosting | null> {
const soa = await dohQuery(domain, 'SOA', signal);
const zoneRecord = [...soa.answer, ...soa.authority].find((a) => a.type === 6);
const zone = zoneRecord?.name.replace(/\.$/, '').toLowerCase();
// NXDOMAIN answers carry the TLD's SOA: that's "not registered", not a zone.
if (!zone || !zone.includes('.') || soa.status === 3) return null;
const ns = await dohQuery(zone, 'NS', signal);
const nameservers = ns.answer.filter((a) => a.type === 2).map((a) => a.data.replace(/\.$/, '').toLowerCase());
if (nameservers.length === 0) return null;
return { zone, nameservers, variant: providerForNameservers(nameservers) };
}
+90
View File
@@ -0,0 +1,90 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Is a record live in public DNS yet? Asked from the browser over DNS-over-HTTPS,
* so the answer is what the rest of the internet sees, not what the mail
* server believes it wrote. The resolver sees the names being checked, which
* are public DNS names anyway; the page says which resolver it uses.
*/
import { normalizeValue, type ZoneRecord } from './zone';
export const RESOLVER_NAME = 'Cloudflare public DNS (1.1.1.1)';
const RESOLVER = 'https://cloudflare-dns.com/dns-query';
/** Resource record type numbers, for reading the JSON answer. */
const TYPE_NUMBERS: Record<string, number> = {
A: 1,
CNAME: 5,
MX: 15,
TXT: 16,
AAAA: 28,
SRV: 33,
TLSA: 52,
CAA: 257,
};
export type LiveState = 'live' | 'different' | 'missing' | 'error';
export interface DohRecord {
name: string;
type: number;
data: string;
}
export interface DohResult {
/** 0 is an answer, 3 is NXDOMAIN (the name doesn't exist). */
status: number;
answer: DohRecord[];
authority: DohRecord[];
}
/** One DNS-over-HTTPS question, answered in the resolver's JSON form. */
export async function dohQuery(name: string, type: string, signal?: AbortSignal): Promise<DohResult> {
const url = `${RESOLVER}?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}`;
const res = await fetch(url, { headers: { Accept: 'application/dns-json' }, signal, cache: 'no-store' });
if (!res.ok) throw new Error(`resolver answered ${res.status}`);
const body = (await res.json()) as { Status: number; Answer?: DohRecord[]; Authority?: DohRecord[] };
return { status: body.Status, answer: body.Answer ?? [], authority: body.Authority ?? [] };
}
async function lookup(name: string, type: string, signal?: AbortSignal): Promise<string[]> {
const { status, answer } = await dohQuery(name, type, signal);
// NXDOMAIN (3) and "no data" both simply mean: not there yet.
if (status !== 0 && status !== 3) throw new Error(`resolver status ${status}`);
const want = TYPE_NUMBERS[type];
return answer.filter((a) => want === undefined || a.type === want).map((a) => a.data);
}
/**
* Check a batch of records. Records sharing a name and type are looked up
* once. A record is `live` when its exact value is published, `different`
* when that name has other values of the type (another provider's SPF, say),
* and `missing` when there is nothing.
*/
export async function checkRecords(records: ZoneRecord[], signal?: AbortSignal): Promise<Map<ZoneRecord, LiveState>> {
const groups = new Map<string, ZoneRecord[]>();
for (const r of records) {
const key = `${r.name.toLowerCase()}|${r.type}`;
groups.set(key, [...(groups.get(key) ?? []), r]);
}
const out = new Map<ZoneRecord, LiveState>();
await Promise.all(
[...groups.values()].map(async (group) => {
const { name, type } = group[0];
try {
const found = (await lookup(name, type, signal)).map((d) => normalizeValue(type, d));
for (const r of group) {
const want = normalizeValue(type, r.value);
out.set(r, found.includes(want) ? 'live' : found.length > 0 ? 'different' : 'missing');
}
} catch {
for (const r of group) out.set(r, 'error');
}
}),
);
return out;
}
+57
View File
@@ -0,0 +1,57 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { AlertTriangle, CheckCircle2, CircleDashed, Loader2, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { LiveState } from './liveCheck';
/** One record's state in public DNS, as an icon. */
export function StateIcon({ state }: { state?: LiveState }) {
switch (state) {
case 'live':
return <CheckCircle2 className="h-4 w-4 text-emerald-500 animate-in zoom-in" />;
case 'different':
return <AlertTriangle className="h-4 w-4 text-highlight" />;
case 'error':
return <XCircle className="h-4 w-4 text-destructive" />;
case 'missing':
return <CircleDashed className="h-4 w-4 text-muted-foreground" />;
default:
return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
}
}
/** How many of the records are live, as a ring that fills. */
export function ProgressRing({ pct, done }: { pct: number; done: boolean }) {
const r = 30;
const c = 2 * Math.PI * r;
return (
<svg viewBox="0 0 72 72" className="h-20 w-20 shrink-0 -rotate-90" role="img" aria-label={`${pct}%`}>
<circle cx="36" cy="36" r={r} fill="none" strokeWidth="7" className="stroke-muted" />
<circle
cx="36"
cy="36"
r={r}
fill="none"
strokeWidth="7"
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={c - (c * pct) / 100}
className={cn('transition-[stroke-dashoffset] duration-700', done ? 'stroke-emerald-500' : 'stroke-primary')}
/>
<text
x="36"
y="36"
dominantBaseline="central"
textAnchor="middle"
className="rotate-90 fill-foreground text-[15px] font-semibold"
style={{ transformOrigin: '36px 36px' }}
>
{pct}%
</text>
</svg>
);
}
+155
View File
@@ -0,0 +1,155 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* The DNS providers the guided setup leads with, and how to get each one's
* credentials. The server supports many more; they're all offered under
* "Another provider", with their fields taken from the server's schema.
*
* `variant` is the server's name for the provider type. The steps are ours:
* keep them short, and aim for the narrowest credential that works.
*/
export interface ProviderGuide {
variant: string;
name: string;
/** One line on the tile. */
blurb: string;
/** How to make a credential, in order. */
steps: string[];
/** Where to start, on the provider's own site. */
link?: string;
/** The fields named the way the steps above name them. */
fields?: Record<string, { label: string; hint?: string }>;
}
export const FEATURED: ProviderGuide[] = [
{
variant: 'Cloudflare',
name: 'Cloudflare',
blurb: 'An API token limited to this one zone.',
steps: [
'In the Cloudflare dashboard, open My Profile → API Tokens and choose Create Token.',
'Use the "Edit zone DNS" template.',
'Under Zone Resources, pick Include → Specific zone → your domain, so the token can touch nothing else.',
'Create the token and paste it below. Cloudflare shows it only once.',
],
link: 'https://dash.cloudflare.com/profile/api-tokens',
fields: { secret: { label: 'API token', hint: 'The token from step 4.' } },
},
{
variant: 'Route53',
name: 'Amazon Route 53',
blurb: 'An IAM access key allowed to change one hosted zone.',
steps: [
'In IAM, create a user or role for the mail server.',
'Give it a policy that allows route53:ChangeResourceRecordSets and route53:ListResourceRecordSets on your hosted zone only, plus route53:ListHostedZonesByName.',
'Create an access key for it and paste the key ID and secret below.',
],
link: 'https://console.aws.amazon.com/iam/',
},
{
variant: 'GoogleCloudDns',
name: 'Google Cloud DNS',
blurb: 'A service account with the DNS Administrator role.',
steps: [
'In IAM & Admin → Service Accounts, create an account for the mail server.',
'Grant it the DNS Administrator role on the project that holds your zone.',
'Create a JSON key for it and paste the details below.',
],
link: 'https://console.cloud.google.com/iam-admin/serviceaccounts',
},
{
variant: 'AzureDns',
name: 'Azure DNS',
blurb: 'An app registration with DNS Zone Contributor on your zone.',
steps: [
'Register an application in Microsoft Entra ID and create a client secret.',
'On your DNS zone, open Access control (IAM) and give the app the DNS Zone Contributor role.',
'Paste the tenant, client and subscription IDs and the secret below.',
],
link: 'https://portal.azure.com/',
},
{
variant: 'DigitalOcean',
name: 'DigitalOcean',
blurb: 'A personal access token with domain access.',
steps: [
'Open API → Tokens and generate a new token.',
'Give it the "domain" scopes (read and update) only.',
'Paste the token below.',
],
link: 'https://cloud.digitalocean.com/account/api/tokens',
fields: { secret: { label: 'Access token' } },
},
{
variant: 'Hetzner',
name: 'Hetzner DNS',
blurb: 'A DNS API token.',
steps: ['In the Hetzner DNS console, open API tokens and create one.', 'Paste the token below.'],
link: 'https://dns.hetzner.com/settings/api-token',
fields: { secret: { label: 'API token' } },
},
{
variant: 'Ovh',
name: 'OVHcloud',
blurb: 'An application key and consumer key for the DNS API.',
steps: [
'Create API keys for your region, allowing GET, POST, PUT and DELETE on /domain/zone/*.',
'Paste the application key, application secret and consumer key below.',
],
link: 'https://api.ovh.com/createToken/',
},
{
variant: 'Godaddy',
name: 'GoDaddy',
blurb: 'A production API key and secret.',
steps: [
'In the GoDaddy developer portal, create a Production API key.',
'Paste the key and secret below. GoDaddy only allows API access on some account types.',
],
link: 'https://developer.godaddy.com/keys',
},
{
variant: 'Porkbun',
name: 'Porkbun',
blurb: 'An API key pair, with API access turned on for the domain.',
steps: [
'Open Account → API Access and create an API key.',
'In Domain Management, turn on API Access for this domain.',
'Paste the API key and secret below.',
],
link: 'https://porkbun.com/account/api',
},
{
variant: 'DeSEC',
name: 'deSEC',
blurb: 'A token, ideally limited to this domain.',
steps: [
'In deSEC, open Token Management and create a token.',
'Restrict it to this domain if you can, and paste it below.',
],
link: 'https://desec.io/tokens',
fields: { secret: { label: 'Token' } },
},
];
/** Provider types the guided setup doesn't offer: retired ones. */
export const HIDDEN_VARIANTS = new Set(['Deprecated1']);
/**
* Fields the guided setup leaves at the server's defaults: timing and
* bookkeeping. They stay editable on the DNS provider's own page.
*/
export const ADVANCED_FIELDS = new Set([
'description',
'memberTenantId',
'pollingInterval',
'propagationDelay',
'propagationTimeout',
'timeout',
'ttl',
]);
+73
View File
@@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RecordKind } from './zone';
export interface RecordGroup {
id: string;
title: string;
why: string;
kinds: { kind: RecordKind; label: string; caution?: string }[];
}
/**
* The record types, grouped by what they do for you rather than by
* their DNS type. The labels say what switching one on achieves.
*/
export const RECORD_GROUPS: RecordGroup[] = [
{
id: 'deliver',
title: 'Receive mail',
why: 'Tells the rest of the internet where mail for this domain goes.',
kinds: [{ kind: 'mx', label: 'Mail exchanger (MX)' }],
},
{
id: 'trust',
title: 'Prove mail is really yours',
why: 'Without these, big providers send your mail to spam or refuse it.',
kinds: [
{ kind: 'spf', label: 'Allowed senders (SPF)' },
{ kind: 'dkim', label: 'Signing keys (DKIM)' },
{ kind: 'dmarc', label: 'What to do with fakes (DMARC)' },
],
},
{
id: 'secure',
title: 'Keep mail encrypted on the way',
why: 'Asks other servers to only deliver to you over a verified, encrypted connection, and to report when they cant.',
kinds: [
{ kind: 'mtaSts', label: 'Require encryption (MTA-STS)' },
{ kind: 'tlsRpt', label: 'Encryption failure reports (TLS-RPT)' },
{
kind: 'tlsa',
label: 'Certificate pinning (DANE / TLSA)',
caution: 'Only useful when the zone is signed with DNSSEC. Leave off unless you know it is.',
},
],
},
{
id: 'apps',
title: 'Let mail apps set themselves up',
why: 'People type their address and password; Thunderbird, Apple Mail, Outlook and phones find the rest.',
kinds: [
{ kind: 'srv', label: 'Service records (SRV)' },
{ kind: 'autoConfig', label: 'Autoconfig' },
{ kind: 'autoConfigLegacy', label: 'Thunderbird autoconfig' },
{ kind: 'autoDiscover', label: 'Outlook autodiscover' },
],
},
{
id: 'certs',
title: 'Limit who can issue certificates',
why: 'Names the certificate authorities allowed to issue for this domain.',
kinds: [{ kind: 'caa', label: 'Certificate authorities (CAA)' }],
},
];
/** The guided default: everything but TLSA, which needs DNSSEC to mean anything. */
export const DEFAULT_KINDS: RecordKind[] = RECORD_GROUPS.flatMap((g) => g.kinds.map((k) => k.kind)).filter(
(k) => k !== 'tlsa',
);
+79
View File
@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { getAccountId, jmapQueryAndGet } from '@/services/jmap/client';
import { checkRecords, type LiveState } from './liveCheck';
import type { ZoneRecord } from './zone';
export interface DnsTask {
id: string;
'@type': string;
domainId?: string;
status?: { '@type': string; failureReason?: string };
}
const POLL_MS = 8000;
const GIVE_UP_MS = 10 * 60_000;
/**
* Keep checking a set of records in public DNS, every few seconds for ten
* minutes, and (for automatic DNS) the server's publishing task for the
* domain. `task` is undefined until first read, null when none is queued.
*/
export function useRecordChecks(records: ZoneRecord[], domainId: string, watchTask: boolean) {
const [states, setStates] = useState<Map<ZoneRecord, LiveState>>(new Map());
const [task, setTask] = useState<DnsTask | null | undefined>(undefined);
const [checking, setChecking] = useState(false);
const [lastChecked, setLastChecked] = useState<Date | null>(null);
const started = useRef<number | null>(null);
const check = useCallback(async () => {
setChecking(true);
if (watchTask) {
try {
const [, getRes] = await jmapQueryAndGet('x:Task', getAccountId('x:Task'), {}, ['@type', 'domainId', 'status']);
const list = ((getRes?.[1] as { list?: DnsTask[] })?.list ?? []).filter(
(x) => x['@type'] === 'DnsManagement' && x.domainId === domainId,
);
setTask(list[0] ?? null);
} catch {
setTask(null);
}
}
setStates(await checkRecords(records));
setLastChecked(new Date());
setChecking(false);
}, [domainId, records, watchTask]);
useEffect(() => {
// Checking DNS and the task list syncs with outside systems, so state
// lands from their callbacks, never synchronously in the effect.
const first = setTimeout(() => {
started.current = Date.now();
void check();
}, 0);
const timer = setInterval(() => {
if (started.current !== null && Date.now() - started.current > GIVE_UP_MS) return;
void check();
}, POLL_MS);
return () => {
clearTimeout(first);
clearInterval(timer);
};
}, [check]);
const liveCount = records.filter((r) => states.get(r) === 'live').length;
return {
states,
task,
checking,
lastChecked,
check,
liveCount,
allLive: records.length > 0 && liveCount === records.length,
};
}
+122
View File
@@ -0,0 +1,122 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import { hostLabel, normalizeValue, parseZone, pasteParts, summarizeFailure } from './zone';
const ZONE = `mail.example.com. IN TXT "v=spf1 a -all"
example.com. IN TXT "v=spf1 mx -all"
example.com. IN MX 10 mail.example.com.
_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:[email protected]"
s1._domainkey.example.com. 300 IN TXT "v=DKIM1; k=ed25519; p=abc"
_jmap._tcp.example.com. IN SRV 0 1 443 mail.example.com.
mta-sts.example.com. IN CNAME mail.example.com.
_mta-sts.example.com. IN TXT "v=STSv1; id=1"
_smtp._tls.example.com. IN TXT "v=TLSRPTv1; rua=mailto:[email protected]"
ua-auto-config.example.com. IN CNAME mail.example.com.
autoconfig.example.com. IN CNAME mail.example.com.
autodiscover.example.com. IN CNAME mail.example.com.
example.com. IN CAA 0 issue "letsencrypt.org"
_25._tcp.mail.example.com. IN TLSA 3 1 1 abcdef
; a comment
www.example.com. IN A 192.0.2.1`;
describe('parseZone', () => {
const records = parseZone(ZONE);
const kind = (name: string, type: string) => records.find((r) => r.name === name && r.type === type)?.kind;
it('sorts every record the server owns into its group', () => {
expect(kind('example.com', 'MX')).toBe('mx');
expect(kind('mail.example.com', 'TXT')).toBe('spf');
expect(kind('example.com', 'TXT')).toBe('spf');
expect(kind('_dmarc.example.com', 'TXT')).toBe('dmarc');
expect(kind('s1._domainkey.example.com', 'TXT')).toBe('dkim');
expect(kind('_jmap._tcp.example.com', 'SRV')).toBe('srv');
expect(kind('mta-sts.example.com', 'CNAME')).toBe('mtaSts');
expect(kind('_mta-sts.example.com', 'TXT')).toBe('mtaSts');
expect(kind('_smtp._tls.example.com', 'TXT')).toBe('tlsRpt');
expect(kind('ua-auto-config.example.com', 'CNAME')).toBe('autoConfig');
expect(kind('autoconfig.example.com', 'CNAME')).toBe('autoConfigLegacy');
expect(kind('autodiscover.example.com', 'CNAME')).toBe('autoDiscover');
expect(kind('example.com', 'CAA')).toBe('caa');
expect(kind('_25._tcp.mail.example.com', 'TLSA')).toBe('tlsa');
});
it('skips comments and records it cannot place', () => {
expect(records.some((r) => r.name === 'www.example.com')).toBe(false);
expect(records).toHaveLength(14);
});
it('reads past an optional TTL', () => {
expect(records.find((r) => r.kind === 'dkim')?.value).toBe('"v=DKIM1; k=ed25519; p=abc"');
});
it('copes with nothing', () => {
expect(parseZone(undefined)).toEqual([]);
expect(parseZone('')).toEqual([]);
});
});
describe('normalizeValue', () => {
it('joins TXT strings a resolver split up', () => {
expect(normalizeValue('TXT', '"v=DKIM1; p=ab" "cd"')).toBe('v=DKIM1; p=abcd');
expect(normalizeValue('TXT', '"v=spf1 mx -all"')).toBe(normalizeValue('TXT', '"v=spf1 mx -all"'));
});
it('ignores case and trailing dots on names', () => {
expect(normalizeValue('MX', '10 Mail.Example.com.')).toBe('10 mail.example.com');
expect(normalizeValue('CNAME', 'mail.example.com.')).toBe(normalizeValue('CNAME', 'mail.example.com'));
});
it('drops the quotes in CAA values', () => {
expect(normalizeValue('CAA', '0 issue "letsencrypt.org"')).toBe('0 issue letsencrypt.org');
});
});
describe('hostLabel', () => {
it('names records relative to their zone', () => {
expect(hostLabel('example.com', 'example.com')).toBe('@');
expect(hostLabel('_dmarc.example.com', 'example.com')).toBe('_dmarc');
expect(hostLabel('_dmarc.mail.example.com.', 'Example.com')).toBe('_dmarc.mail');
expect(hostLabel('other.org', 'example.com')).toBe('other.org');
});
});
describe('pasteParts', () => {
const rec = (type: string, value: string) => ({ name: 'example.com', type, value, kind: 'mx' as const });
it('unquotes and joins TXT', () => {
expect(pasteParts(rec('TXT', '"v=DKIM1; p=ab" "cd"')).value).toBe('v=DKIM1; p=abcd');
});
it('splits the MX priority out', () => {
expect(pasteParts(rec('MX', '10 mail.example.com.'))).toEqual({ value: 'mail.example.com', priority: '10' });
});
it('drops the trailing dot elsewhere', () => {
expect(pasteParts(rec('CNAME', 'mail.example.com.')).value).toBe('mail.example.com');
});
});
describe('summarizeFailure', () => {
const cf =
'{"success":false,"errors":[{"code":6003,"message":"Invalid request headers","error_chain":[{"code":6111,"message":"Invalid format for Authorization header"}]}],"messages":[],"result":null}';
const reason = [
`Failed to set DNS RRSet for _smtp._tls.dev.test./TXT: Failed to set DNS RRSet: API error: BadRequest ${cf}`,
`Failed to set DNS RRSet for dev.test./MX: Failed to set DNS RRSet: API error: BadRequest ${cf}`,
].join('; ');
it('keeps each provider message once, the cause first, and counts the records', () => {
expect(summarizeFailure(reason)).toEqual({
messages: ['Invalid format for Authorization header', 'Invalid request headers'],
records: 2,
});
});
it('falls back to the first error when there is no provider JSON', () => {
expect(summarizeFailure('Failed to build DNS updater: bad zone').messages).toEqual([
'Failed to build DNS updater: bad zone',
]);
expect(summarizeFailure(undefined)).toEqual({ messages: [], records: 0 });
});
});
+153
View File
@@ -0,0 +1,153 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* The records a domain needs, read from the zone text the server builds for
* it (the domain's `dnsZoneFile`), and sorted into the groups the server's
* `publishRecords` setting switches on and off.
*/
export type RecordKind =
| 'mx'
| 'spf'
| 'dkim'
| 'dmarc'
| 'mtaSts'
| 'tlsRpt'
| 'srv'
| 'autoConfig'
| 'autoConfigLegacy'
| 'autoDiscover'
| 'caa'
| 'tlsa';
export interface ZoneRecord {
/** Owner name, without the trailing dot. */
name: string;
type: string;
/** The record data as the zone text has it. */
value: string;
kind: RecordKind;
}
/** Split a zone line into fields, keeping quoted strings whole. */
function fields(line: string): string[] {
const out: string[] = [];
const re = /"((?:[^"\\]|\\.)*)"|(\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(line))) out.push(m[1] !== undefined ? `"${m[1]}"` : m[2]);
return out;
}
const CLASSES = new Set(['IN', 'CH', 'HS']);
function kindOf(name: string, type: string, value: string): RecordKind | null {
const n = name.toLowerCase();
switch (type) {
case 'MX':
return 'mx';
case 'SRV':
return 'srv';
case 'CAA':
return 'caa';
case 'TLSA':
return 'tlsa';
}
if (n.includes('._domainkey.')) return 'dkim';
if (n.startsWith('_dmarc.')) return 'dmarc';
if (n.startsWith('mta-sts.') || n.startsWith('_mta-sts.')) return 'mtaSts';
if (n.startsWith('_smtp._tls.')) return 'tlsRpt';
if (n.startsWith('ua-auto-config.') || n.startsWith('_ua-auto-config.')) return 'autoConfig';
if (n.startsWith('autoconfig.')) return 'autoConfigLegacy';
if (n.startsWith('autodiscover.')) return 'autoDiscover';
if (type === 'TXT' && /^"?v=spf1\b/i.test(value)) return 'spf';
return null;
}
/** Parse the zone text. Lines it can't place are left out, not guessed at. */
export function parseZone(text: string | null | undefined): ZoneRecord[] {
const out: ZoneRecord[] = [];
for (const raw of (text ?? '').split('\n')) {
const line = raw.trim();
if (!line || line.startsWith(';')) continue;
const f = fields(line);
if (f.length < 3) continue;
const name = f[0].replace(/\.$/, '');
let i = 1;
// Optional TTL and class, in either order.
for (let k = 0; k < 2 && i < f.length; k++) {
if (/^\d+$/.test(f[i]) || CLASSES.has(f[i].toUpperCase())) i++;
}
const type = (f[i] ?? '').toUpperCase();
const value = f.slice(i + 1).join(' ');
if (!type || !value) continue;
const kind = kindOf(name, type, value);
if (kind) out.push({ name, type, value, kind });
}
return out;
}
/**
* One value in a comparable form: TXT strings joined (resolvers split long
* ones), quotes, case and trailing dots dropped, spaces collapsed.
*/
export function normalizeValue(type: string, value: string): string {
let v = value.trim();
if (type === 'TXT') {
const parts = [...v.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
v = parts.length > 0 ? parts.join('') : v;
return v.replace(/\\"/g, '"').replace(/\s+/g, ' ').trim();
}
v = v.replace(/"/g, '').replace(/\s+/g, ' ').toLowerCase();
return v
.split(' ')
.map((p) => p.replace(/\.$/, ''))
.join(' ');
}
/** The name as DNS host panels ask for it: relative to the zone, "@" for the zone itself. */
export function hostLabel(name: string, zone: string): string {
const n = name.toLowerCase().replace(/\.$/, '');
const z = zone.toLowerCase().replace(/\.$/, '');
if (n === z) return '@';
return n.endsWith(`.${z}`) ? n.slice(0, -(z.length + 1)) : n;
}
/**
* The record's value the way host panels want it pasted: TXT without the
* quotes (long ones joined back together), and MX with its priority apart,
* since nearly every panel has a separate box for it.
*/
export function pasteParts(r: ZoneRecord): { value: string; priority?: string } {
const v = r.value.trim();
if (r.type === 'TXT') {
const parts = [...v.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
return { value: parts.length ? parts.join('') : v };
}
if (r.type === 'MX') {
const [priority, ...rest] = v.split(/\s+/);
return { value: rest.join(' ').replace(/\.$/, ''), priority };
}
return { value: v.replace(/\.$/, '') };
}
/**
* A publishing failure, boiled down. The server reports each record's error
* in full, so one bad credential repeats the same provider message a dozen
* times; keep each distinct message once, innermost cause first.
*/
export function summarizeFailure(reason: string | undefined): { messages: string[]; records: number } {
const text = reason ?? '';
const records = (text.match(/Failed to set DNS RRSet for /g) ?? []).length;
const found = [...text.matchAll(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
// Nested error chains list the cause last; show the most specific first.
const messages = [...new Set(found.reverse())];
if (messages.length === 0 && text) {
const first = text.split(/;\s*/)[0].replace(/^Failed to set DNS RRSet for \S+: /, '');
messages.push(first.length > 200 ? `${first.slice(0, 200)}` : first);
}
return { messages, records };
}
+6
View File
@@ -1,5 +1,6 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
@@ -66,6 +67,11 @@ function checkSpecialLink(
return { visible: true, enterprise: false };
}
// INBUXA: guided jobs are open to whoever may manage what they change.
if (viewName.startsWith('Wizard/dns/')) {
return { visible: canGet ? canGet('sysDomain') : true, enterprise: false };
}
return null;
}
+1
View File
@@ -93,6 +93,7 @@ export default function AdminPanel() {
const pageTitle = useMemo(() => {
if (!section) return t('dashboard.title', 'Dashboard');
if (!viewName) return section;
if (viewName.startsWith('Wizard/dns/')) return `${t('dnsWizard.tabTitle', 'Publish DNS')} · ${section}`;
let label: string | undefined;
for (const entry of searchIndex) {
if (entry.type !== 'link' || entry.viewName !== viewName) continue;