Guided setup: how this server sends mail #36
No files matched your search
@@ -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 { SendingSetupCard } from '@/features/sending/SendingSetupCard';
|
||||
import type { Schema } from '@/types/schema';
|
||||
|
||||
function lazyFeature<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
|
||||
@@ -62,6 +63,10 @@ const DataInventoryPage = lazyFeature(
|
||||
(m) => m.DataInventoryPage,
|
||||
);
|
||||
// inbuxa: Settings › Overview (settings-reorg spec).
|
||||
const SendingSetupPage = lazyFeature(
|
||||
() => import('@/features/sending/SendingSetupPage'),
|
||||
(m) => m.SendingSetupPage,
|
||||
);
|
||||
const SettingsOverviewPage = lazyFeature(
|
||||
() => import('@/features/settings/SettingsOverviewPage'),
|
||||
(m) => m.SettingsOverviewPage,
|
||||
@@ -131,6 +136,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
if (wizard === 'dns' && param) {
|
||||
return <ConnectDnsPage domainId={param} />;
|
||||
}
|
||||
// inbuxa: guided setup, how this server sends mail (settings-reorg).
|
||||
if (wizard === 'sending') {
|
||||
return <SendingSetupPage />;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
Unknown guide: {wizard}
|
||||
@@ -206,6 +215,15 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// inbuxa: the sending guide, offered where outbound delivery is set (settings-reorg).
|
||||
if (resolved.objectName === 'x:MtaOutboundStrategy') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SendingSetupCard className="mx-auto max-w-4xl" />
|
||||
<DynamicForm viewName={viewName} objectId="singleton" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <DynamicForm viewName={viewName} objectId="singleton" />;
|
||||
}
|
||||
|
||||
@@ -247,5 +265,13 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
return page;
|
||||
}
|
||||
|
||||
if (viewName === 'x:MtaRoute') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SendingSetupCard />
|
||||
<DynamicList viewName={viewName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <DynamicList viewName={viewName} />;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 { Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LaunchChoice } from '@/components/wizard/LaunchChoice';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const SENDING_WIZARD_VIEW = 'Wizard/sending';
|
||||
|
||||
/** "Guided or manual?" for the sending setup. Manual opens the delivery strategy unless told otherwise. */
|
||||
export function SendingLaunchChoice({
|
||||
open,
|
||||
onOpenChange,
|
||||
onManual,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onManual?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const section = useSchemaStore((s) => s.viewToSection['x:MtaOutboundStrategy']) ?? 'Settings';
|
||||
return (
|
||||
<LaunchChoice
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('sendingCard.chooseTitle', 'How this server sends mail')}
|
||||
guidedHint={t(
|
||||
'sendingCard.guidedHint',
|
||||
'Check port 25, choose direct delivery or a relay service, and see exactly what changes. About three minutes.',
|
||||
)}
|
||||
manualHint={t('sendingCard.manualHint', 'Edit the delivery strategy and routes yourself, with every option.')}
|
||||
onGuided={() => navigate(`/${section}/${SENDING_WIZARD_VIEW}`)}
|
||||
onManual={onManual ?? (() => navigate(`/${section}/x:MtaOutboundStrategy`))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa: at the top of Delivery strategy and Routes, an invitation to set up
|
||||
* sending with the guide (settings-reorg, guided setup 1).
|
||||
*/
|
||||
export function SendingSetupCard({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Send className="h-5 w-5 shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{t('sendingCard.title', 'Direct delivery or a relay?')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'sendingCard.hint',
|
||||
'A guide checks whether port 25 is open and sets up Amazon SES, Mailgun, SendGrid or another relay for you.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setOpen(true)}>
|
||||
{t('sendingCard.start', 'Set it up')}
|
||||
</Button>
|
||||
<SendingLaunchChoice open={open} onOpenChange={setOpen} onManual={() => undefined} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: guided setup, "How this server sends mail" (settings-reorg, first
|
||||
* wave). Direct delivery or a relay service: it checks whether port 25 is
|
||||
* open, fills in the relay's details from a preset, shows exactly what will
|
||||
* change, writes it, and says how to test it. Reached only by choosing
|
||||
* "Guide me"; the manual path is the Delivery strategy and Routes pages.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, CheckCircle2, CircleHelp, Loader2, Network, Send, ShieldCheck, XCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { WizardNote, WizardShell, type WizardStep } from '@/components/wizard/WizardShell';
|
||||
import { getAccountId, jmapRequest, jmapSet } from '@/services/jmap/client';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { JmapSetResponse } from '@/types/jmap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { currentMode, otherRules, plan, type Choice, type Current, type Expr, type RouteInfo } from './plan';
|
||||
import { presetForHost, RELAY_PRESETS, type RelayPreset } from './providers';
|
||||
import { usePort25Check } from './usePort25Check';
|
||||
|
||||
type Path = 'direct' | 'relay';
|
||||
|
||||
function setFailure(res: JmapSetResponse | undefined, kind: 'notCreated' | 'notUpdated'): string | null {
|
||||
const bad = res?.[kind];
|
||||
if (!bad) return null;
|
||||
const e = Object.values(bad)[0];
|
||||
if (!e) return null;
|
||||
const details = (e.validationErrors ?? []).map((v) => JSON.stringify(v)).join('; ');
|
||||
return [e.description, details].filter(Boolean).join(' ') || e.type;
|
||||
}
|
||||
|
||||
async function load(): Promise<Current> {
|
||||
const call = (obj: string, args: Record<string, unknown>, id: string) =>
|
||||
[`${obj}/get`, { accountId: getAccountId(obj), ...args }, id] as [string, Record<string, unknown>, string];
|
||||
const res = await jmapRequest([
|
||||
call('x:MtaOutboundStrategy', { ids: ['singleton'], properties: ['route', 'tls'] }, 's'),
|
||||
call('x:MtaRoute', { ids: null }, 'r'),
|
||||
call('x:MtaTlsStrategy', { ids: null, properties: ['name'] }, 't'),
|
||||
]);
|
||||
const byId = Object.fromEntries(res.map(([, body, id]) => [id, body as Record<string, unknown>]));
|
||||
const strategy = (byId.s?.list as Record<string, unknown>[] | undefined)?.[0];
|
||||
if (!strategy) throw new Error('The outbound delivery settings could not be read.');
|
||||
const expr = (v: unknown): Expr => {
|
||||
const e = (v ?? {}) as Partial<Expr>;
|
||||
return { match: e.match ?? {}, else: e.else ?? '' };
|
||||
};
|
||||
const routes = ((byId.r?.list as Record<string, unknown>[] | undefined) ?? []).map((r): RouteInfo => ({
|
||||
id: String(r.id),
|
||||
name: String(r.name ?? ''),
|
||||
type: String(r['@type'] ?? ''),
|
||||
address: r.address as string | undefined,
|
||||
port: r.port as number | undefined,
|
||||
implicitTls: r.implicitTls as boolean | undefined,
|
||||
authUsername: r.authUsername as string | null | undefined,
|
||||
}));
|
||||
const tlsStrategies = ((byId.t?.list as Record<string, unknown>[] | undefined) ?? []).map((s) => ({
|
||||
id: String(s.id),
|
||||
name: String(s.name ?? ''),
|
||||
}));
|
||||
return { route: expr(strategy.route), tls: expr(strategy.tls), routes, tlsStrategies };
|
||||
}
|
||||
|
||||
function OptionCard({
|
||||
selected,
|
||||
onClick,
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
badge,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
icon: typeof Send;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-2 rounded-xl border p-4 text-left transition-colors hover:border-primary/60',
|
||||
selected ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'bg-card',
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center gap-2 font-medium">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
{title}
|
||||
{badge && <span className="ml-auto">{badge}</span>}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendingSetupPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const section = useSchemaStore((s) => s.viewToSection['x:MtaOutboundStrategy']) ?? 'Settings';
|
||||
const canProbe = useAccountStore((s) => s.hasPermission('liveDeliveryTest'));
|
||||
const port25 = usePort25Check();
|
||||
|
||||
const [current, setCurrent] = useState<Current | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [path, setPath] = useState<Path | null>(null);
|
||||
const [step, setStep] = useState(0);
|
||||
const [preset, setPreset] = useState<RelayPreset>(RELAY_PRESETS[0]);
|
||||
const [host, setHost] = useState(RELAY_PRESETS[0].host);
|
||||
const [port, setPort] = useState(String(RELAY_PRESETS[0].port));
|
||||
const [implicitTls, setImplicitTls] = useState(RELAY_PRESETS[0].implicitTls);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [probeTarget, setProbeTarget] = useState('gmail.com');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
load()
|
||||
.then((cur) => {
|
||||
if (!live) return;
|
||||
setCurrent(cur);
|
||||
const mode = currentMode(cur);
|
||||
if (mode.kind === 'relay') {
|
||||
// Start from the relay already saved, so running the guide again edits it.
|
||||
const p = presetForHost(mode.route.address ?? '');
|
||||
setPreset(p);
|
||||
setHost(mode.route.address ?? '');
|
||||
setPort(String(mode.route.port ?? p.port));
|
||||
setImplicitTls(mode.route.implicitTls ?? p.implicitTls);
|
||||
setUsername(mode.route.authUsername ?? '');
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => live && setLoadError(e instanceof Error ? e.message : String(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mode = current ? currentMode(current) : null;
|
||||
const savedRelay = mode?.kind === 'relay' ? mode.route : undefined;
|
||||
|
||||
const steps: WizardStep[] = useMemo(
|
||||
() =>
|
||||
path === 'relay'
|
||||
? [
|
||||
{ id: 'how', title: t('sendingSetup.stepHow', 'How mail leaves') },
|
||||
{ id: 'service', title: t('sendingSetup.stepService', 'Relay service') },
|
||||
{ id: 'signin', title: t('sendingSetup.stepSignIn', 'Sign-in') },
|
||||
{ id: 'review', title: t('sendingSetup.stepReview', 'Review') },
|
||||
{ id: 'done', title: t('sendingSetup.stepDone', 'Test it') },
|
||||
]
|
||||
: [
|
||||
{ id: 'how', title: t('sendingSetup.stepHow', 'How mail leaves') },
|
||||
{ id: 'review', title: t('sendingSetup.stepReview', 'Review') },
|
||||
{ id: 'done', title: t('sendingSetup.stepDone', 'Test it') },
|
||||
],
|
||||
[path, t],
|
||||
);
|
||||
const stepId = steps[step]?.id ?? 'how';
|
||||
|
||||
const choice: Choice | null = useMemo(() => {
|
||||
if (path === 'direct') return { kind: 'direct' };
|
||||
if (path !== 'relay') return null;
|
||||
return {
|
||||
kind: 'relay',
|
||||
host,
|
||||
port: Number(port),
|
||||
implicitTls,
|
||||
username: preset.username ?? username,
|
||||
password,
|
||||
};
|
||||
}, [path, host, port, implicitTls, preset, username, password]);
|
||||
|
||||
const planned = useMemo(() => {
|
||||
if (!current || !choice) return null;
|
||||
try {
|
||||
return { plan: plan(current, choice), error: null };
|
||||
} catch (e) {
|
||||
return { plan: null, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}, [current, choice]);
|
||||
|
||||
const pickPreset = (p: RelayPreset) => {
|
||||
setPreset(p);
|
||||
setHost(
|
||||
savedRelay && presetForHost(savedRelay.address ?? '').id === p.id ? (savedRelay.address ?? p.host) : p.host,
|
||||
);
|
||||
setPort(String(p.port));
|
||||
setImplicitTls(p.implicitTls);
|
||||
if (p.username) setUsername(p.username);
|
||||
};
|
||||
|
||||
const apply = async () => {
|
||||
if (!planned?.plan) return;
|
||||
const p = planned.plan;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const undo: (() => Promise<unknown>)[] = [];
|
||||
try {
|
||||
if (p.tlsCreate) {
|
||||
const [res] = await jmapSet('x:MtaTlsStrategy', getAccountId('x:MtaTlsStrategy'), {
|
||||
create: { tls: p.tlsCreate },
|
||||
});
|
||||
const body = res?.[1] as unknown as JmapSetResponse | undefined;
|
||||
const id = (body?.created?.tls as { id?: string } | undefined)?.id;
|
||||
if (!id) throw new Error(setFailure(body, 'notCreated') ?? 'The TLS strategy could not be added.');
|
||||
undo.push(() => jmapSet('x:MtaTlsStrategy', getAccountId('x:MtaTlsStrategy'), { destroy: [id] }));
|
||||
}
|
||||
if (p.routeCreate) {
|
||||
const [res] = await jmapSet('x:MtaRoute', getAccountId('x:MtaRoute'), { create: { route: p.routeCreate } });
|
||||
const body = res?.[1] as unknown as JmapSetResponse | undefined;
|
||||
const id = (body?.created?.route as { id?: string } | undefined)?.id;
|
||||
if (!id) throw new Error(setFailure(body, 'notCreated') ?? 'The relay route could not be added.');
|
||||
undo.push(() => jmapSet('x:MtaRoute', getAccountId('x:MtaRoute'), { destroy: [id] }));
|
||||
}
|
||||
if (p.routeUpdate) {
|
||||
const { id, patch } = p.routeUpdate;
|
||||
const before = current!.routes.find((r) => r.id === id)!;
|
||||
const [res] = await jmapSet('x:MtaRoute', getAccountId('x:MtaRoute'), { update: { [id]: patch } });
|
||||
const body = res?.[1] as unknown as JmapSetResponse | undefined;
|
||||
const failed = setFailure(body, 'notUpdated');
|
||||
if (failed || !body?.updated || !(id in body.updated))
|
||||
throw new Error(failed ?? 'The relay could not be saved.');
|
||||
undo.push(() =>
|
||||
jmapSet('x:MtaRoute', getAccountId('x:MtaRoute'), {
|
||||
update: {
|
||||
[id]: {
|
||||
address: before.address,
|
||||
port: before.port,
|
||||
implicitTls: before.implicitTls,
|
||||
authUsername: before.authUsername ?? null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (p.strategy) {
|
||||
const [res] = await jmapSet('x:MtaOutboundStrategy', getAccountId('x:MtaOutboundStrategy'), {
|
||||
update: { singleton: p.strategy },
|
||||
});
|
||||
const body = res?.[1] as unknown as JmapSetResponse | undefined;
|
||||
const failed = setFailure(body, 'notUpdated');
|
||||
if (failed || !body?.updated || !('singleton' in body.updated)) {
|
||||
throw new Error(failed ?? 'The delivery strategy could not be changed.');
|
||||
}
|
||||
}
|
||||
setPassword('');
|
||||
setStep(steps.length - 1);
|
||||
} catch (e) {
|
||||
// Failsafe: what this run added or changed is put back, newest first.
|
||||
for (const u of undo.reverse()) await u().catch(() => undefined);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadError) return <div className="mx-auto max-w-3xl p-8 text-center text-destructive">{loadError}</div>;
|
||||
if (!current || !mode) return <LoadingFallback />;
|
||||
|
||||
const common = {
|
||||
icon: 'send',
|
||||
title: t('sendingSetup.title', 'How this server sends mail'),
|
||||
subtitle: t(
|
||||
'sendingSetup.subtitle',
|
||||
'Deliver straight to other mail servers, or hand outgoing mail to a relay service that delivers it for you.',
|
||||
),
|
||||
steps,
|
||||
current: step,
|
||||
onCancel: () => navigate(`/${section}/x:MtaOutboundStrategy`),
|
||||
};
|
||||
const others = otherRules(current);
|
||||
|
||||
// ── Step: how mail leaves ──
|
||||
if (stepId === 'how') {
|
||||
const s = port25.state;
|
||||
const suggestRelay = s.kind === 'blocked';
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
canNext={path !== null}
|
||||
onNext={() => setStep(1)}
|
||||
aside={
|
||||
<>
|
||||
<WizardNote title={t('sendingSetup.whyTitle', 'Which one?')}>
|
||||
<p>
|
||||
{t(
|
||||
'sendingSetup.whyDirect',
|
||||
'Direct delivery needs port 25 open to the internet, an IP address with a clean reputation, and reverse DNS that names this server.',
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'sendingSetup.whyRelay',
|
||||
'A relay suits a home connection or a cloud host that blocks port 25, or when you’d rather someone else handle reputation. It costs a little and the service sees your outgoing mail.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
<WizardNote tone="undo" title={t('sendingSetup.undoTitle', 'Changing your mind')}>
|
||||
<p>
|
||||
{t(
|
||||
'sendingSetup.undo',
|
||||
'Run this guide again and pick the other way. A relay you set up stays saved, unused, so switching back is quick.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{mode.kind === 'direct' && t('sendingSetup.nowDirect', 'Right now this server delivers directly.')}
|
||||
{mode.kind === 'relay' &&
|
||||
t('sendingSetup.nowRelay', 'Right now outgoing mail goes through a relay: {{host}}.', {
|
||||
host: mode.route.address ?? mode.route.name,
|
||||
})}
|
||||
{mode.kind === 'other' &&
|
||||
t(
|
||||
'sendingSetup.nowOther',
|
||||
'Right now outgoing mail follows a custom rule ({{target}}). Choosing here replaces that fallback.',
|
||||
{ target: mode.target },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canProbe && (
|
||||
<div className="space-y-3 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Network className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t('sendingSetup.checkTitle', 'Check port 25 first')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'sendingSetup.checkHint',
|
||||
'Connects from this server to a big mail provider’s servers on port 25 and waits for their greeting. Nothing is sent.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
value={probeTarget}
|
||||
onChange={(e) => setProbeTarget(e.target.value)}
|
||||
className="max-w-48"
|
||||
aria-label={t('sendingSetup.checkTarget', 'Domain to try')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void port25.run(probeTarget.trim() || 'gmail.com')}
|
||||
disabled={s.kind === 'running'}
|
||||
>
|
||||
{s.kind === 'running' && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('sendingSetup.checkRun', 'Check')}
|
||||
</Button>
|
||||
</div>
|
||||
{s.kind === 'open' && (
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
{t('sendingSetup.checkOpen', 'Port 25 is open: {{host}} answered. Direct delivery can work.', {
|
||||
host: s.host,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{s.kind === 'blocked' && (
|
||||
<p className="flex items-start gap-2 text-sm">
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
{t(
|
||||
'sendingSetup.checkBlocked',
|
||||
'Port 25 looks blocked ({{reason}}). Your host probably filters it: use a relay, or ask them to open it.',
|
||||
{ reason: s.reason.replace(/\.$/, '') },
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{s.kind === 'unknown' && (
|
||||
<p className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<CircleHelp className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{t('sendingSetup.checkUnknown', 'The check couldn’t tell: {{reason}}', { reason: s.reason })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={path === 'direct'}
|
||||
onClick={() => setPath('direct')}
|
||||
icon={Send}
|
||||
title={t('sendingSetup.direct', 'Deliver directly')}
|
||||
badge={
|
||||
s.kind === 'open' ? (
|
||||
<span className="text-xs text-emerald-600">{t('sendingSetup.recommended', 'Recommended')}</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t(
|
||||
'sendingSetup.directHint',
|
||||
'This server looks up each recipient’s mail servers and delivers to them itself. No third party, nothing to pay.',
|
||||
)}
|
||||
</OptionCard>
|
||||
<OptionCard
|
||||
selected={path === 'relay'}
|
||||
onClick={() => setPath('relay')}
|
||||
icon={ShieldCheck}
|
||||
title={t('sendingSetup.relay', 'Through a relay service')}
|
||||
badge={
|
||||
suggestRelay ? (
|
||||
<span className="text-xs text-emerald-600">{t('sendingSetup.recommended', 'Recommended')}</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t(
|
||||
'sendingSetup.relayHint',
|
||||
'Amazon SES, Mailgun, SendGrid, Postmark, Brevo, SMTP2GO or your ISP’s server. It delivers for you and handles reputation.',
|
||||
)}
|
||||
</OptionCard>
|
||||
</div>
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step: which relay ──
|
||||
if (stepId === 'service') {
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onBack={() => setStep(0)}
|
||||
onNext={() => setStep(2)}
|
||||
canNext={host.trim() !== '' && Number(port) > 0}
|
||||
aside={
|
||||
<WizardNote title={t('sendingSetup.beforeTitle', 'Before this works')}>
|
||||
<p>{preset.before}</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{RELAY_PRESETS.map((p) => (
|
||||
<OptionCard
|
||||
key={p.id}
|
||||
selected={preset.id === p.id}
|
||||
onClick={() => pickPreset(p)}
|
||||
icon={Send}
|
||||
title={p.name}
|
||||
>
|
||||
{p.id === 'other'
|
||||
? t('sendingSetup.otherHint', 'Your ISP’s or provider’s outgoing server.')
|
||||
: p.regions
|
||||
? t('sendingSetup.byRegion', 'Server by region · port {{port}}', { port: p.port })
|
||||
: `${p.host} · ${p.port}`}
|
||||
</OptionCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{preset.regions && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('sendingSetup.region', 'Region')}</Label>
|
||||
<Select
|
||||
value={preset.regions.find((r) => r.host === host)?.id}
|
||||
onValueChange={(id) => setHost(preset.regions!.find((r) => r.id === id)!.host)}
|
||||
>
|
||||
<SelectTrigger className="max-w-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{preset.regions.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('sendingSetup.regionHint', 'The region your account sends from.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="relay-host">{t('sendingSetup.host', 'Server')}</Label>
|
||||
<Input
|
||||
id="relay-host"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="smtp.example.net"
|
||||
spellCheck={false}
|
||||
readOnly={Boolean(preset.regions)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="relay-port">{t('sendingSetup.port', 'Port')}</Label>
|
||||
<Input
|
||||
id="relay-port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
onChange={(e) => {
|
||||
setPort(e.target.value);
|
||||
if (e.target.value === '465') setImplicitTls(true);
|
||||
else if (e.target.value === '587' || e.target.value === '25' || e.target.value === '2525')
|
||||
setImplicitTls(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('sendingSetup.encryption', 'Encryption')}</Label>
|
||||
<Select value={implicitTls ? 'implicit' : 'starttls'} onValueChange={(v) => setImplicitTls(v === 'implicit')}>
|
||||
<SelectTrigger className="max-w-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="implicit">
|
||||
{t('sendingSetup.implicit', 'Encrypted from the start (usually port 465)')}
|
||||
</SelectItem>
|
||||
<SelectItem value="starttls">
|
||||
{t('sendingSetup.starttls', 'STARTTLS, required (usually port 587)')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step: sign-in ──
|
||||
if (stepId === 'signin') {
|
||||
const needsPassword = (preset.username ?? username).trim() !== '' && !savedRelay && password === '';
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onBack={() => setStep(1)}
|
||||
onNext={() => setStep(3)}
|
||||
canNext={!needsPassword}
|
||||
aside={
|
||||
<WizardNote title={t('sendingSetup.storedTitle', 'Where it’s kept')}>
|
||||
<p>
|
||||
{t(
|
||||
'sendingSetup.stored',
|
||||
'The password is saved in this server’s settings, like other secrets, and is never shown again here.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="relay-user">{t('sendingSetup.username', 'User name')}</Label>
|
||||
<Input
|
||||
id="relay-user"
|
||||
value={preset.username ?? username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
readOnly={Boolean(preset.username)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="max-w-md"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{preset.usernameHint}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="relay-pass">{t('sendingSetup.password', 'Password')}</Label>
|
||||
<Input
|
||||
id="relay-pass"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder={savedRelay ? t('sendingSetup.keepPassword', 'Leave empty to keep the saved one') : undefined}
|
||||
className="max-w-md"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{preset.passwordHint}</p>
|
||||
</div>
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step: review ──
|
||||
if (stepId === 'review') {
|
||||
const p = planned?.plan;
|
||||
const nothing = p && p.changes.length === 0;
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onBack={() => setStep(step - 1)}
|
||||
onNext={nothing ? () => setStep(steps.length - 1) : () => void apply()}
|
||||
nextLabel={nothing ? t('wizard.next', 'Next') : t('sendingSetup.apply', 'Make the change')}
|
||||
canNext={Boolean(p)}
|
||||
busy={busy}
|
||||
aside={
|
||||
<WizardNote tone="undo" title={t('sendingSetup.ifFails', 'If something fails')}>
|
||||
<p>
|
||||
{t(
|
||||
'sendingSetup.rollback',
|
||||
'The changes are written one at a time. If one is refused, the ones before it are put back, so a failed attempt leaves things as they were.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
{planned?.error && (
|
||||
<p className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{planned.error}
|
||||
</p>
|
||||
)}
|
||||
{nothing && (
|
||||
<p className="text-sm">
|
||||
{t('sendingSetup.nothing', 'This server already sends mail this way. Nothing needs to change.')}
|
||||
</p>
|
||||
)}
|
||||
{p && p.changes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">{t('sendingSetup.willChange', 'What will change')}</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm">
|
||||
{p.changes.map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium">{t('sendingSetup.kept', 'Left as they are')}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'sendingSetup.keptHint',
|
||||
'Your {{count}} other routing rules still apply first, before this fallback.',
|
||||
{ count: others.length },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step: test it ──
|
||||
const management = useSchemaStore.getState().viewToSection['x:QueuedMessage'] ?? 'Management';
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onNext={() => navigate(`/${section}/x:MtaOutboundStrategy`)}
|
||||
nextLabel={t('wizard.finish', 'Finish')}
|
||||
aside={
|
||||
<WizardNote title={t('sendingSetup.whereTitle', 'Where to look later')}>
|
||||
<p>
|
||||
{t(
|
||||
'sendingSetup.where',
|
||||
'Settings › Mail flow › Sending holds the delivery strategy and routes this guide changed, for anything it doesn’t cover.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||
{path === 'relay'
|
||||
? t('sendingSetup.doneRelay', 'Outgoing mail now goes through {{host}}.', { host: host.trim() })
|
||||
: t('sendingSetup.doneDirect', 'This server delivers outgoing mail directly.')}
|
||||
</p>
|
||||
<ol className="list-decimal space-y-2 pl-5 text-sm">
|
||||
<li>
|
||||
{t(
|
||||
'sendingSetup.test1',
|
||||
'From the webmail, send a message to an address outside your domains, such as a personal Gmail or Outlook account.',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t('sendingSetup.test2', 'Watch it leave in')}{' '}
|
||||
<Link
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
to={`/${management}/x:Trace/OutboundDelivery`}
|
||||
>
|
||||
{t('sendingSetup.test2Link', 'Emails › History › Outbound delivery')}
|
||||
</Link>
|
||||
{t('sendingSetup.test2b', '. A delivery lists each attempt and the answer from the other side.')}
|
||||
</li>
|
||||
<li>
|
||||
{t('sendingSetup.test3', 'If it waits in')}{' '}
|
||||
<Link className="text-primary underline-offset-4 hover:underline" to={`/${management}/x:QueuedMessage`}>
|
||||
{t('sendingSetup.test3Link', 'Emails › Queued')}
|
||||
</Link>
|
||||
{path === 'relay'
|
||||
? t(
|
||||
'sendingSetup.test3Relay',
|
||||
', its last error says why: usually the sign-in, or a sender domain the relay hasn’t verified yet.',
|
||||
)
|
||||
: t(
|
||||
'sendingSetup.test3Direct',
|
||||
', its last error says why: often port 25 being blocked, or the other side rejecting this server’s IP.',
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { currentMode, plan, routeName, type Current } from './plan';
|
||||
|
||||
const stock: Current = {
|
||||
route: { match: { '0': { if: 'is_local_domain(rcpt_domain)', then: "'local'" } }, else: "'mx'" },
|
||||
tls: { match: { '0': { if: "retry_num > 0 && last_error == 'tls'", then: "'invalid-tls'" } }, else: "'default'" },
|
||||
routes: [],
|
||||
tlsStrategies: [],
|
||||
};
|
||||
|
||||
const ses = {
|
||||
kind: 'relay' as const,
|
||||
host: 'email-smtp.eu-west-1.amazonaws.com',
|
||||
port: 465,
|
||||
implicitTls: true,
|
||||
username: 'AKIAEXAMPLE',
|
||||
password: 'pw',
|
||||
};
|
||||
|
||||
describe('routeName', () => {
|
||||
it('reads quoted names only', () => {
|
||||
expect(routeName("'mx'")).toBe('mx');
|
||||
expect(routeName('"relay"')).toBe('relay');
|
||||
expect(routeName("if_then(a, 'x', 'y')")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentMode', () => {
|
||||
it('knows direct, a relay, and something else', () => {
|
||||
expect(currentMode(stock)).toEqual({ kind: 'direct' });
|
||||
const relay = { id: 'r1', name: 'relay', type: 'Relay', address: 'smtp.x' };
|
||||
expect(currentMode({ ...stock, route: { ...stock.route, else: "'relay'" }, routes: [relay] })).toEqual({
|
||||
kind: 'relay',
|
||||
route: relay,
|
||||
});
|
||||
expect(currentMode({ ...stock, route: { ...stock.route, else: "'gone'" } })).toEqual({
|
||||
kind: 'other',
|
||||
target: "'gone'",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan', () => {
|
||||
it('changes nothing when already direct', () => {
|
||||
expect(plan(stock, { kind: 'direct' })).toEqual({ changes: [] });
|
||||
});
|
||||
|
||||
it('adds a relay route and points the fallback at it, keeping local delivery', () => {
|
||||
const p = plan(stock, ses);
|
||||
expect(p.routeCreate).toMatchObject({
|
||||
'@type': 'Relay',
|
||||
name: 'relay',
|
||||
address: ses.host,
|
||||
port: 465,
|
||||
implicitTls: true,
|
||||
authUsername: 'AKIAEXAMPLE',
|
||||
authSecret: { '@type': 'Value', secret: 'pw' },
|
||||
});
|
||||
expect(p.strategy?.route).toEqual({ match: stock.route.match, else: "'relay'" });
|
||||
expect(p.strategy?.tls).toBeUndefined();
|
||||
expect(p.tlsCreate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires STARTTLS for a relay that is not encrypted from the start', () => {
|
||||
const p = plan(stock, { ...ses, host: 'smtp.postmarkapp.com', port: 587, implicitTls: false });
|
||||
expect(p.tlsCreate).toMatchObject({ name: 'relay-tls', startTls: 'require' });
|
||||
expect(Object.values(p.strategy!.tls!.match)[0]).toEqual({
|
||||
if: "mx == 'smtp.postmarkapp.com'",
|
||||
then: "'relay-tls'",
|
||||
});
|
||||
expect(Object.values(p.strategy!.tls!.match)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('updates a saved relay and keeps its password when none is typed', () => {
|
||||
const cur: Current = {
|
||||
...stock,
|
||||
route: { ...stock.route, else: "'relay'" },
|
||||
routes: [{ id: 'r1', name: 'relay', type: 'Relay', address: 'old.example' }],
|
||||
};
|
||||
const p = plan(cur, { ...ses, password: '' });
|
||||
expect(p.routeUpdate?.id).toBe('r1');
|
||||
expect(p.routeUpdate?.patch).not.toHaveProperty('authSecret');
|
||||
expect(p.routeCreate).toBeUndefined();
|
||||
expect(p.strategy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('going back to direct removes the relay encryption rule and leaves other rules alone', () => {
|
||||
const cur: Current = {
|
||||
...stock,
|
||||
route: {
|
||||
match: { ...stock.route.match, '1': { if: "rcpt_domain == 'partner.example'", then: "'partner'" } },
|
||||
else: "'relay'",
|
||||
},
|
||||
tls: {
|
||||
match: { '0': { if: "mx == 'smtp.x'", then: "'relay-tls'" }, '1': stock.tls.match['0'] },
|
||||
else: "'default'",
|
||||
},
|
||||
routes: [{ id: 'r1', name: 'relay', type: 'Relay' }],
|
||||
};
|
||||
const p = plan(cur, { kind: 'direct' });
|
||||
expect(p.strategy?.route).toEqual({ match: cur.route.match, else: "'mx'" });
|
||||
expect(p.strategy?.tls).toEqual({ match: { '0': stock.tls.match['0'] }, else: "'default'" });
|
||||
});
|
||||
|
||||
it('refuses to take over a route named relay that is not one', () => {
|
||||
expect(() => plan({ ...stock, routes: [{ id: 'm', name: 'relay', type: 'Mx' }] }, ses)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: what the sending setup reads and what it will write (settings-reorg,
|
||||
* guided setup 1). Kept apart from the page so the review step can show it
|
||||
* before anything happens, and so it can be tested.
|
||||
*
|
||||
* How the server picks a way out: the outbound strategy's `route` expression
|
||||
* names a route for each message. `'local'` and `'mx'` are built in; any
|
||||
* other name is an x:MtaRoute. The stock expression sends mail for this
|
||||
* server's own domains to `'local'` and everything else to `'mx'` (direct).
|
||||
* So a relay is one Relay route plus a changed fallback ("else"). The
|
||||
* conditions someone may have added are left exactly as they are.
|
||||
*
|
||||
* STARTTLS towards a relay is only tried by default. When a relay is reached
|
||||
* over STARTTLS, a TLS strategy that requires it is added for that host, so
|
||||
* the relay's password is never sent unencrypted.
|
||||
*/
|
||||
|
||||
export const RELAY_ROUTE = 'relay';
|
||||
export const RELAY_TLS = 'relay-tls';
|
||||
|
||||
export type Expr = { match: Record<string, { if: string; then: string }>; else: string };
|
||||
|
||||
export interface RouteInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
address?: string;
|
||||
port?: number;
|
||||
implicitTls?: boolean;
|
||||
authUsername?: string | null;
|
||||
}
|
||||
|
||||
export interface Current {
|
||||
route: Expr;
|
||||
tls: Expr;
|
||||
routes: RouteInfo[];
|
||||
tlsStrategies: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export type Mode =
|
||||
| { kind: 'direct' }
|
||||
| { kind: 'relay'; route: RouteInfo }
|
||||
/** A fallback this guide didn't set up: another route, or an expression. */
|
||||
| { kind: 'other'; target: string };
|
||||
|
||||
export interface RelayChoice {
|
||||
host: string;
|
||||
port: number;
|
||||
implicitTls: boolean;
|
||||
username: string;
|
||||
/** Empty keeps the password already saved on the route. */
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type Choice = { kind: 'direct' } | ({ kind: 'relay' } & RelayChoice);
|
||||
|
||||
export interface Plan {
|
||||
/** Plain sentences for the review step. */
|
||||
changes: string[];
|
||||
routeCreate?: Record<string, unknown>;
|
||||
routeUpdate?: { id: string; patch: Record<string, unknown> };
|
||||
tlsCreate?: Record<string, unknown>;
|
||||
strategy?: { route?: Expr; tls?: Expr };
|
||||
}
|
||||
|
||||
/** `'mx'` → mx; null when the fallback is an expression rather than a name. */
|
||||
export function routeName(value: string): string | null {
|
||||
const m = /^\s*'([^'\\]*)'\s*$/.exec(value) ?? /^\s*"([^"\\]*)"\s*$/.exec(value);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
export function currentMode(cur: Current): Mode {
|
||||
const name = routeName(cur.route.else);
|
||||
if (name === 'mx') return { kind: 'direct' };
|
||||
const route = name ? cur.routes.find((r) => r.name === name) : undefined;
|
||||
if (route?.type === 'Relay') return { kind: 'relay', route };
|
||||
if (route?.type === 'Mx') return { kind: 'direct' };
|
||||
return { kind: 'other', target: cur.route.else };
|
||||
}
|
||||
|
||||
/** The other conditions on the route expression, which this guide never touches. */
|
||||
export function otherRules(cur: Current): { if: string; then: string }[] {
|
||||
return Object.values(cur.route.match).filter((r) => !/^\s*is_local_domain\(rcpt_domain\)\s*$/.test(r.if));
|
||||
}
|
||||
|
||||
function renumber(rules: { if: string; then: string }[]): Record<string, { if: string; then: string }> {
|
||||
return Object.fromEntries(rules.map((r, i) => [String(i), r]));
|
||||
}
|
||||
|
||||
function isRelayTlsRule(r: { then: string }): boolean {
|
||||
return routeName(r.then) === RELAY_TLS;
|
||||
}
|
||||
|
||||
function quote(s: string): string {
|
||||
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
function sameExpr(a: Expr, b: Expr): boolean {
|
||||
const norm = (e: Expr) => JSON.stringify([Object.values(e.match), e.else.trim()]);
|
||||
return norm(a) === norm(b);
|
||||
}
|
||||
|
||||
export function plan(cur: Current, choice: Choice): Plan {
|
||||
const out: Plan = { changes: [] };
|
||||
const tlsRules = Object.values(cur.tls.match).filter((r) => !isRelayTlsRule(r));
|
||||
|
||||
if (choice.kind === 'direct') {
|
||||
const route: Expr = { match: cur.route.match, else: quote('mx') };
|
||||
const tls: Expr = { match: renumber(tlsRules), else: cur.tls.else };
|
||||
const next: Plan['strategy'] = {};
|
||||
if (!sameExpr(route, cur.route)) {
|
||||
next.route = route;
|
||||
out.changes.push('Mail to other domains is delivered directly to each domain’s mail servers, on port 25.');
|
||||
}
|
||||
if (!sameExpr(tls, cur.tls)) {
|
||||
next.tls = tls;
|
||||
out.changes.push('The rule that required encryption towards the relay is removed.');
|
||||
}
|
||||
if (next.route || next.tls) out.strategy = next;
|
||||
return out;
|
||||
}
|
||||
|
||||
const host = choice.host.trim();
|
||||
const existing = cur.routes.find((r) => r.name === RELAY_ROUTE);
|
||||
const fields: Record<string, unknown> = {
|
||||
address: host,
|
||||
port: choice.port,
|
||||
protocol: 'smtp',
|
||||
implicitTls: choice.implicitTls,
|
||||
allowInvalidCerts: false,
|
||||
authUsername: choice.username.trim() || null,
|
||||
};
|
||||
if (!choice.username.trim()) fields.authSecret = { '@type': 'None' };
|
||||
else if (choice.password) fields.authSecret = { '@type': 'Value', secret: choice.password };
|
||||
|
||||
const how = choice.implicitTls ? 'encrypted from the start' : 'STARTTLS, required';
|
||||
if (existing && existing.type === 'Relay') {
|
||||
out.routeUpdate = { id: existing.id, patch: fields };
|
||||
out.changes.push(`The saved relay route "${RELAY_ROUTE}" now points to ${host}, port ${choice.port} (${how}).`);
|
||||
} else if (existing) {
|
||||
throw new Error(`A route named "${RELAY_ROUTE}" already exists and isn’t a relay. Rename it first.`);
|
||||
} else {
|
||||
out.routeCreate = {
|
||||
'@type': 'Relay',
|
||||
name: RELAY_ROUTE,
|
||||
description: `Outgoing relay: ${host}`,
|
||||
...fields,
|
||||
...(fields.authSecret ? {} : { authSecret: { '@type': 'None' } }),
|
||||
};
|
||||
out.changes.push(`A relay route "${RELAY_ROUTE}" is added: ${host}, port ${choice.port} (${how}).`);
|
||||
}
|
||||
if (choice.username.trim()) {
|
||||
out.changes.push(
|
||||
choice.password || !existing
|
||||
? `It signs in as ${choice.username.trim()}, with the password you entered.`
|
||||
: `It signs in as ${choice.username.trim()}, with the password already saved.`,
|
||||
);
|
||||
}
|
||||
|
||||
const route: Expr = { match: cur.route.match, else: quote(RELAY_ROUTE) };
|
||||
const next: Plan['strategy'] = {};
|
||||
if (!sameExpr(route, cur.route)) {
|
||||
next.route = route;
|
||||
out.changes.push('Mail to other domains goes through the relay. Mail for this server’s own domains stays here.');
|
||||
}
|
||||
|
||||
let tls: Expr = { match: renumber(tlsRules), else: cur.tls.else };
|
||||
if (!choice.implicitTls) {
|
||||
tls = {
|
||||
match: renumber([{ if: `mx == ${quote(host)}`, then: quote(RELAY_TLS) }, ...tlsRules]),
|
||||
else: cur.tls.else,
|
||||
};
|
||||
if (!cur.tlsStrategies.some((s) => s.name === RELAY_TLS)) {
|
||||
out.tlsCreate = {
|
||||
name: RELAY_TLS,
|
||||
description: 'Encryption required towards the outgoing relay',
|
||||
startTls: 'require',
|
||||
dane: 'optional',
|
||||
mtaSts: 'optional',
|
||||
allowInvalidCerts: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!sameExpr(tls, cur.tls)) {
|
||||
next.tls = tls;
|
||||
out.changes.push(
|
||||
choice.implicitTls
|
||||
? 'The rule that required STARTTLS towards the old relay is removed; this one is encrypted from the start.'
|
||||
: `Connections to ${host} must be encrypted: if STARTTLS isn’t offered, nothing is sent.`,
|
||||
);
|
||||
}
|
||||
if (next.route || next.tls) out.strategy = next;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: relay services the sending setup knows (settings-reorg, guided
|
||||
* setup 1). Each preset fills in the host, port and TLS mode, and says where
|
||||
* the SMTP credentials are found. Implicit TLS on 465 wherever the service
|
||||
* offers it, so the sign-in never crosses the wire unencrypted; a service
|
||||
* that only takes STARTTLS gets it required instead (see plan.ts).
|
||||
*/
|
||||
|
||||
export interface RelayPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Regions, where the host depends on one. */
|
||||
regions?: { id: string; label: string; host: string }[];
|
||||
host: string;
|
||||
port: number;
|
||||
implicitTls: boolean;
|
||||
/** A fixed username, where the service uses one. */
|
||||
username?: string;
|
||||
usernameHint: string;
|
||||
passwordHint: string;
|
||||
/** What to do at the service before this works. */
|
||||
before: string;
|
||||
}
|
||||
|
||||
const SES_REGIONS = [
|
||||
['us-east-1', 'US East (N. Virginia)'],
|
||||
['us-east-2', 'US East (Ohio)'],
|
||||
['us-west-2', 'US West (Oregon)'],
|
||||
['ca-central-1', 'Canada (Central)'],
|
||||
['eu-west-1', 'Europe (Ireland)'],
|
||||
['eu-west-2', 'Europe (London)'],
|
||||
['eu-central-1', 'Europe (Frankfurt)'],
|
||||
['eu-north-1', 'Europe (Stockholm)'],
|
||||
['ap-south-1', 'Asia Pacific (Mumbai)'],
|
||||
['ap-southeast-1', 'Asia Pacific (Singapore)'],
|
||||
['ap-southeast-2', 'Asia Pacific (Sydney)'],
|
||||
['ap-northeast-1', 'Asia Pacific (Tokyo)'],
|
||||
['sa-east-1', 'South America (São Paulo)'],
|
||||
].map(([id, label]) => ({ id, label, host: `email-smtp.${id}.amazonaws.com` }));
|
||||
|
||||
export const RELAY_PRESETS: RelayPreset[] = [
|
||||
{
|
||||
id: 'ses',
|
||||
name: 'Amazon SES',
|
||||
regions: SES_REGIONS,
|
||||
host: SES_REGIONS[0].host,
|
||||
port: 465,
|
||||
implicitTls: true,
|
||||
usernameHint: 'The SMTP user name from SES › SMTP settings › Create SMTP credentials. Not your AWS access key.',
|
||||
passwordHint: 'The SMTP password shown once when the SMTP credentials are created.',
|
||||
before:
|
||||
'Verify your domain in SES (it gives you DKIM records to add), and ask AWS to move the account out of the sandbox, or it can only send to verified addresses.',
|
||||
},
|
||||
{
|
||||
id: 'mailgun',
|
||||
name: 'Mailgun',
|
||||
regions: [
|
||||
{ id: 'us', label: 'US', host: 'smtp.mailgun.org' },
|
||||
{ id: 'eu', label: 'EU', host: 'smtp.eu.mailgun.org' },
|
||||
],
|
||||
host: 'smtp.mailgun.org',
|
||||
port: 465,
|
||||
implicitTls: true,
|
||||
usernameHint: 'The SMTP login from your sending domain’s settings, usually postmaster@ followed by that domain.',
|
||||
passwordHint: 'The SMTP password for that login, from the same page.',
|
||||
before: 'Add and verify your sending domain in Mailgun. Pick the region your Mailgun account is in.',
|
||||
},
|
||||
{
|
||||
id: 'sendgrid',
|
||||
name: 'SendGrid',
|
||||
host: 'smtp.sendgrid.net',
|
||||
port: 465,
|
||||
implicitTls: true,
|
||||
username: 'apikey',
|
||||
usernameHint: 'SendGrid always uses the word apikey as the user name.',
|
||||
passwordHint: 'An API key with Mail Send permission, from Settings › API Keys.',
|
||||
before: 'Authenticate your domain in SendGrid (Settings › Sender Authentication).',
|
||||
},
|
||||
{
|
||||
id: 'postmark',
|
||||
name: 'Postmark',
|
||||
host: 'smtp.postmarkapp.com',
|
||||
port: 587,
|
||||
implicitTls: false,
|
||||
usernameHint: 'Your server’s API token, from the server’s API Tokens tab.',
|
||||
passwordHint: 'The same server API token again.',
|
||||
before: 'Add your domain as a sender signature in Postmark and add the DKIM and Return-Path records it shows.',
|
||||
},
|
||||
{
|
||||
id: 'brevo',
|
||||
name: 'Brevo',
|
||||
host: 'smtp-relay.brevo.com',
|
||||
port: 465,
|
||||
implicitTls: true,
|
||||
usernameHint: 'The login shown on the SMTP & API page (SMTP tab).',
|
||||
passwordHint: 'An SMTP key generated on that same page. Not your account password.',
|
||||
before: 'Authenticate your domain in Brevo (Senders, Domains & Dedicated IPs).',
|
||||
},
|
||||
{
|
||||
id: 'smtp2go',
|
||||
name: 'SMTP2GO',
|
||||
host: 'mail.smtp2go.com',
|
||||
port: 465,
|
||||
implicitTls: true,
|
||||
usernameHint: 'The user name of an SMTP user you add under Sending › SMTP Users.',
|
||||
passwordHint: 'That SMTP user’s password.',
|
||||
before: 'Verify your sender domain in SMTP2GO (Sending › Verified Senders).',
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
name: 'Another relay',
|
||||
host: '',
|
||||
port: 465,
|
||||
implicitTls: true,
|
||||
usernameHint:
|
||||
'The user name your provider or ISP gave you for sending mail. Leave empty if the relay doesn’t ask for one.',
|
||||
passwordHint: 'The password that goes with it.',
|
||||
before:
|
||||
'Your ISP or provider’s help pages list the relay’s host name and port, often called the outgoing or SMTP server.',
|
||||
},
|
||||
];
|
||||
|
||||
export function presetById(id: string): RelayPreset {
|
||||
return RELAY_PRESETS.find((p) => p.id === id) ?? RELAY_PRESETS[RELAY_PRESETS.length - 1];
|
||||
}
|
||||
|
||||
/** The preset a saved relay most likely came from, by its host. */
|
||||
export function presetForHost(host: string): RelayPreset {
|
||||
const h = host.trim().toLowerCase();
|
||||
return (
|
||||
RELAY_PRESETS.find((p) => p.id !== 'other' && (p.host === h || p.regions?.some((r) => r.host === h))) ??
|
||||
presetById('other')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { verdict } from './usePort25Check';
|
||||
|
||||
describe('verdict', () => {
|
||||
it('is open once a mail server greets us', () => {
|
||||
expect(
|
||||
verdict([
|
||||
{ type: 'deliveryAttemptStart', hostname: 'gmail-smtp-in.l.google.com' },
|
||||
{ type: 'connectionStart', remoteIp: '1.2.3.4' },
|
||||
{ type: 'connectionSuccess', elapsed: 20 },
|
||||
{ type: 'readGreetingSuccess', elapsed: 40 },
|
||||
]),
|
||||
).toEqual({ kind: 'open', host: 'gmail-smtp-in.l.google.com' });
|
||||
});
|
||||
|
||||
it('is blocked when every connection fails', () => {
|
||||
expect(
|
||||
verdict([
|
||||
{ type: 'deliveryAttemptStart', hostname: 'mx1' },
|
||||
{ type: 'connectionError', elapsed: 5000, reason: 'Connection timed out' },
|
||||
{ type: 'deliveryAttemptStart', hostname: 'mx2' },
|
||||
{ type: 'connectionError', elapsed: 5000, reason: 'Connection timed out' },
|
||||
]),
|
||||
).toEqual({ kind: 'blocked', reason: 'Connection timed out' });
|
||||
});
|
||||
|
||||
it('cannot tell when the domain has no mail servers', () => {
|
||||
expect(verdict([{ type: 'mxLookupError', reason: 'NXDOMAIN', elapsed: 3 }]).kind).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: can this server reach other mail servers on port 25? Many hosting
|
||||
* providers block it, and then direct delivery can't work. This runs the
|
||||
* same live probe as Delivery tests against a big mail domain: an MX lookup,
|
||||
* a connection and the server's greeting. Nothing is sent.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { apiFetch, getApiBaseUrl } from '@/services/api';
|
||||
import type { DeliveryStage } from '@/features/troubleshoot/types';
|
||||
|
||||
export type Port25State =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'running' }
|
||||
| { kind: 'open'; host: string }
|
||||
| { kind: 'blocked'; reason: string }
|
||||
| { kind: 'unknown'; reason: string };
|
||||
|
||||
/** What a finished probe says about port 25. */
|
||||
export function verdict(events: DeliveryStage[]): Port25State {
|
||||
let host = '';
|
||||
let lastError = '';
|
||||
for (const e of events) {
|
||||
if (e.type === 'deliveryAttemptStart') host = e.hostname;
|
||||
if (e.type === 'readGreetingSuccess') return { kind: 'open', host };
|
||||
if (e.type === 'connectionError' || e.type === 'readGreetingError') lastError = e.reason;
|
||||
if (e.type === 'mxLookupError') return { kind: 'unknown', reason: e.reason };
|
||||
}
|
||||
if (lastError) return { kind: 'blocked', reason: lastError };
|
||||
return { kind: 'unknown', reason: 'The check ended before connecting to any mail server.' };
|
||||
}
|
||||
|
||||
const TIMEOUT_MS = 60_000;
|
||||
|
||||
export function usePort25Check() {
|
||||
const [state, setState] = useState<Port25State>({ kind: 'idle' });
|
||||
const source = useRef<EventSource | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
source.current?.close();
|
||||
source.current = null;
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => stop, [stop]);
|
||||
|
||||
const run = useCallback(
|
||||
async (target: string) => {
|
||||
stop();
|
||||
setState({ kind: 'running' });
|
||||
try {
|
||||
const token = await (await apiFetch('/api/token/delivery')).text();
|
||||
const es = new EventSource(
|
||||
`${getApiBaseUrl()}/api/live/delivery/${encodeURIComponent(target)}?token=${encodeURIComponent(token)}`,
|
||||
);
|
||||
source.current = es;
|
||||
const events: DeliveryStage[] = [];
|
||||
const finish = (s: Port25State) => {
|
||||
stop();
|
||||
setState(s);
|
||||
};
|
||||
timer.current = setTimeout(
|
||||
() =>
|
||||
finish(
|
||||
verdict(events).kind === 'open'
|
||||
? verdict(events)
|
||||
: { kind: 'blocked', reason: 'no mail server answered within a minute' },
|
||||
),
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
es.addEventListener('event', (msg) => {
|
||||
for (const stage of JSON.parse((msg as MessageEvent).data) as DeliveryStage[]) {
|
||||
if (stage.type === 'completed') return finish(verdict(events));
|
||||
events.push(stage);
|
||||
// One greeting is the answer; no need to wait for the rest.
|
||||
if (stage.type === 'readGreetingSuccess') return finish(verdict(events));
|
||||
}
|
||||
});
|
||||
es.onerror = () =>
|
||||
finish(events.length > 0 ? verdict(events) : { kind: 'unknown', reason: 'The check could not run.' });
|
||||
} catch (e) {
|
||||
setState({ kind: 'unknown', reason: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
[stop],
|
||||
);
|
||||
|
||||
return { state, run };
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
* when there are any, the pages whose values differ from the defaults.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ListChecks } from 'lucide-react';
|
||||
@@ -22,6 +22,7 @@ import { checkLinkVisible, resolveViewPath, topItemVisible } from '@/lib/navTree
|
||||
import { SETTINGS_CATEGORIES, SETTINGS_LAYOUT_NAME } from '@/lib/settingsLayout';
|
||||
import { resolveObject } from '@/lib/schemaResolver';
|
||||
import { countChanged, singletonPages, type SettingsPageRef } from './changedFromDefault';
|
||||
import { SENDING_WIZARD_VIEW, SendingLaunchChoice } from '@/features/sending/SendingSetupCard';
|
||||
|
||||
/** Calls per JMAP request; the protocol's own floor, so any server takes it. */
|
||||
const CALLS_PER_REQUEST = 16;
|
||||
@@ -30,8 +31,8 @@ interface GuidedSetup {
|
||||
id: string;
|
||||
title: string;
|
||||
blurb: string;
|
||||
/** Where the job starts. */
|
||||
to: string;
|
||||
/** Where the job starts: a page, or a "Guided or manual?" choice for a job with a wizard. */
|
||||
to: string | { choice: (props: { open: boolean; onOpenChange: (open: boolean) => void }) => ReactNode };
|
||||
/** The view whose visibility decides whether this reader sees the card. */
|
||||
gate: string;
|
||||
/** Whether it is already set up; left out when there's no cheap way to tell. */
|
||||
@@ -48,6 +49,13 @@ async function anyExist(objectName: string): Promise<boolean> {
|
||||
|
||||
/** Each guided setup registers here as it lands. */
|
||||
const GUIDED_SETUPS: GuidedSetup[] = [
|
||||
{
|
||||
id: 'sending',
|
||||
title: 'How this server sends mail',
|
||||
blurb: 'Check whether port 25 is open, then deliver directly or through a relay such as Amazon SES or Mailgun.',
|
||||
to: { choice: (props) => <SendingLaunchChoice {...props} /> },
|
||||
gate: SENDING_WIZARD_VIEW,
|
||||
},
|
||||
{
|
||||
id: 'dns',
|
||||
title: 'Publish DNS records automatically',
|
||||
@@ -122,11 +130,11 @@ function SetupCard({ setup }: { setup: GuidedSetup }) {
|
||||
};
|
||||
}, [setup]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={setup.to}
|
||||
className="group flex flex-col gap-2 rounded-xl border bg-card p-4 transition-all hover:-translate-y-0.5 hover:border-primary/60 hover:shadow-soft"
|
||||
>
|
||||
const [choosing, setChoosing] = useState(false);
|
||||
const className =
|
||||
'group flex flex-col gap-2 rounded-xl border bg-card p-4 text-left transition-all hover:-translate-y-0.5 hover:border-primary/60 hover:shadow-soft';
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{setup.title}</span>
|
||||
{done && (
|
||||
@@ -137,7 +145,23 @@ function SetupCard({ setup }: { setup: GuidedSetup }) {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{setup.blurb}</p>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
|
||||
if (typeof setup.to === 'string') {
|
||||
return (
|
||||
<Link to={setup.to} className={className}>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={className} onClick={() => setChoosing(true)}>
|
||||
{body}
|
||||
</button>
|
||||
{setup.to.choice({ open: choosing, onOpenChange: setChoosing })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,10 @@ function checkSpecialLink(
|
||||
}
|
||||
|
||||
// INBUXA: guided jobs are open to whoever may manage what they change.
|
||||
if (viewName === 'Wizard/sending') {
|
||||
const ok = canGet ? canGet('sysMtaRoute') && canGet('sysMtaOutboundStrategy') : true;
|
||||
return { visible: ok, enterprise: false };
|
||||
}
|
||||
if (viewName.startsWith('Wizard/dns/')) {
|
||||
return { visible: canGet ? canGet('sysDomain') : true, enterprise: false };
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export default function AdminPanel() {
|
||||
if (!section) return t('dashboard.title', 'Dashboard');
|
||||
if (!viewName) return section;
|
||||
if (viewName.startsWith('Wizard/dns/')) return `${t('dnsWizard.tabTitle', 'Publish DNS')} · ${section}`;
|
||||
if (viewName === 'Wizard/sending') return `${t('sendingSetup.tabTitle', 'Sending mail')} · ${section}`;
|
||||
if (viewName.startsWith('Dashboard/')) {
|
||||
const board = schema?.dashboards?.find((d) => d.id === viewName.slice('Dashboard/'.length));
|
||||
return `${board?.label ?? t('dashboard.title', 'Dashboard')} · ${t('dashboard.title', 'Dashboard')}`;
|
||||
|
||||
Reference in new issue
Block a user