From 57b44cae27a59d82cc7f4ce6b00b53bd9d642830 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 10:56:35 -0700 Subject: [PATCH] Guided setup: how this server sends mail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Overview, Delivery strategy and Routes offer a guide ("Guided or manual?" each time) that: - checks port 25 from this server with the Delivery tests probe (MX lookup, connection, greeting; nothing is sent) and recommends direct delivery or a relay from the result; - sets up a relay from presets: Amazon SES (by region), Mailgun (US/EU), SendGrid, Postmark, Brevo, SMTP2GO or any other server, with where to find each one's SMTP credentials and what to verify first; - shows every change in plain words before writing it: a Relay route named relay, the route fallback pointed at it (local delivery and any other rules untouched), and, for a STARTTLS relay, a relay-tls strategy with STARTTLS required for that host, so the password never goes out unencrypted. Implicit TLS on 465 wherever the service offers it; - writes it step by step and puts back what it wrote if a later step is refused; - ends with how to test it: send from webmail, watch Outbound delivery and the queue. Running it again edits the saved relay (an empty password keeps the saved one), or switches back to direct delivery, which also drops the relay's TLS rule. --- src/components/layout/MainContent.tsx | 26 + src/features/sending/SendingSetupCard.tsx | 77 ++ src/features/sending/SendingSetupPage.tsx | 718 ++++++++++++++++++ src/features/sending/plan.test.ts | 114 +++ src/features/sending/plan.ts | 200 +++++ src/features/sending/providers.ts | 140 ++++ src/features/sending/usePort25Check.test.ts | 36 + src/features/sending/usePort25Check.ts | 97 +++ .../settings/SettingsOverviewPage.tsx | 42 +- src/lib/layout.ts | 4 + src/pages/AdminPanel.tsx | 1 + 11 files changed, 1446 insertions(+), 9 deletions(-) create mode 100644 src/features/sending/SendingSetupCard.tsx create mode 100644 src/features/sending/SendingSetupPage.tsx create mode 100644 src/features/sending/plan.test.ts create mode 100644 src/features/sending/plan.ts create mode 100644 src/features/sending/providers.ts create mode 100644 src/features/sending/usePort25Check.test.ts create mode 100644 src/features/sending/usePort25Check.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 33432c5..b0513f7 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -17,6 +17,7 @@ import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; +import { SendingSetupCard } from '@/features/sending/SendingSetupCard'; import type { Schema } from '@/types/schema'; function lazyFeature(load: () => Promise, select: (module: M) => ComponentType

) { @@ -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 ; } + // inbuxa: guided setup, how this server sends mail (settings-reorg). + if (wizard === 'sending') { + return ; + } return (

Unknown guide: {wizard} @@ -206,6 +215,15 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
); } + // inbuxa: the sending guide, offered where outbound delivery is set (settings-reorg). + if (resolved.objectName === 'x:MtaOutboundStrategy') { + return ( +
+ + +
+ ); + } return ; } @@ -247,5 +265,13 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti return page; } + if (viewName === 'x:MtaRoute') { + return ( +
+ + +
+ ); + } return ; } diff --git a/src/features/sending/SendingSetupCard.tsx b/src/features/sending/SendingSetupCard.tsx new file mode 100644 index 0000000..bc4bcb9 --- /dev/null +++ b/src/features/sending/SendingSetupCard.tsx @@ -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 ( + 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 ( +
+ +
+

{t('sendingCard.title', 'Direct delivery or a relay?')}

+

+ {t( + 'sendingCard.hint', + 'A guide checks whether port 25 is open and sets up Amazon SES, Mailgun, SendGrid or another relay for you.', + )} +

+
+ + undefined} /> +
+ ); +} diff --git a/src/features/sending/SendingSetupPage.tsx b/src/features/sending/SendingSetupPage.tsx new file mode 100644 index 0000000..6c511ca --- /dev/null +++ b/src/features/sending/SendingSetupPage.tsx @@ -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 { + const call = (obj: string, args: Record, id: string) => + [`${obj}/get`, { accountId: getAccountId(obj), ...args }, id] as [string, Record, 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])); + const strategy = (byId.s?.list as Record[] | 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; + return { match: e.match ?? {}, else: e.else ?? '' }; + }; + const routes = ((byId.r?.list as Record[] | 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[] | 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 ( + + ); +} + +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(null); + const [loadError, setLoadError] = useState(null); + const [path, setPath] = useState(null); + const [step, setStep] = useState(0); + const [preset, setPreset] = useState(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(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)[] = []; + 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
{loadError}
; + if (!current || !mode) return ; + + 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 ( + setStep(1)} + aside={ + <> + +

+ {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.', + )} +

+

+ {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.', + )} +

+
+ +

+ {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.', + )} +

+
+ + } + > +
+

+ {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 }, + )} +

+
+ + {canProbe && ( +
+
+ +
+

{t('sendingSetup.checkTitle', 'Check port 25 first')}

+

+ {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.', + )} +

+
+
+
+ setProbeTarget(e.target.value)} + className="max-w-48" + aria-label={t('sendingSetup.checkTarget', 'Domain to try')} + /> + +
+ {s.kind === 'open' && ( +

+ + {t('sendingSetup.checkOpen', 'Port 25 is open: {{host}} answered. Direct delivery can work.', { + host: s.host, + })} +

+ )} + {s.kind === 'blocked' && ( +

+ + {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(/\.$/, '') }, + )} +

+ )} + {s.kind === 'unknown' && ( +

+ + {t('sendingSetup.checkUnknown', 'The check couldn’t tell: {{reason}}', { reason: s.reason })} +

+ )} +
+ )} + +
+ setPath('direct')} + icon={Send} + title={t('sendingSetup.direct', 'Deliver directly')} + badge={ + s.kind === 'open' ? ( + {t('sendingSetup.recommended', 'Recommended')} + ) : undefined + } + > + {t( + 'sendingSetup.directHint', + 'This server looks up each recipient’s mail servers and delivers to them itself. No third party, nothing to pay.', + )} + + setPath('relay')} + icon={ShieldCheck} + title={t('sendingSetup.relay', 'Through a relay service')} + badge={ + suggestRelay ? ( + {t('sendingSetup.recommended', 'Recommended')} + ) : undefined + } + > + {t( + 'sendingSetup.relayHint', + 'Amazon SES, Mailgun, SendGrid, Postmark, Brevo, SMTP2GO or your ISP’s server. It delivers for you and handles reputation.', + )} + +
+
+ ); + } + + // ── Step: which relay ── + if (stepId === 'service') { + return ( + setStep(0)} + onNext={() => setStep(2)} + canNext={host.trim() !== '' && Number(port) > 0} + aside={ + +

{preset.before}

+
+ } + > +
+ {RELAY_PRESETS.map((p) => ( + 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}`} + + ))} +
+ + {preset.regions && ( +
+ + +

+ {t('sendingSetup.regionHint', 'The region your account sends from.')} +

+
+ )} + +
+
+ + setHost(e.target.value)} + placeholder="smtp.example.net" + spellCheck={false} + readOnly={Boolean(preset.regions)} + /> +
+
+ + { + 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); + }} + /> +
+
+
+ + +
+
+ ); + } + + // ── Step: sign-in ── + if (stepId === 'signin') { + const needsPassword = (preset.username ?? username).trim() !== '' && !savedRelay && password === ''; + return ( + setStep(1)} + onNext={() => setStep(3)} + canNext={!needsPassword} + aside={ + +

+ {t( + 'sendingSetup.stored', + 'The password is saved in this server’s settings, like other secrets, and is never shown again here.', + )} +

+
+ } + > +
+ + setUsername(e.target.value)} + readOnly={Boolean(preset.username)} + autoComplete="off" + spellCheck={false} + className="max-w-md" + /> +

{preset.usernameHint}

+
+
+ + setPassword(e.target.value)} + autoComplete="new-password" + placeholder={savedRelay ? t('sendingSetup.keepPassword', 'Leave empty to keep the saved one') : undefined} + className="max-w-md" + /> +

{preset.passwordHint}

+
+
+ ); + } + + // ── Step: review ── + if (stepId === 'review') { + const p = planned?.plan; + const nothing = p && p.changes.length === 0; + return ( + 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={ + +

+ {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.', + )} +

+
+ } + > + {planned?.error && ( +

+ + {planned.error} +

+ )} + {nothing && ( +

+ {t('sendingSetup.nothing', 'This server already sends mail this way. Nothing needs to change.')} +

+ )} + {p && p.changes.length > 0 && ( +
+

{t('sendingSetup.willChange', 'What will change')}

+
    + {p.changes.map((c) => ( +
  • {c}
  • + ))} +
+
+ )} + {others.length > 0 && ( +
+

{t('sendingSetup.kept', 'Left as they are')}

+

+ {t( + 'sendingSetup.keptHint', + 'Your {{count}} other routing rules still apply first, before this fallback.', + { count: others.length }, + )} +

+
+ )} + {error && ( +

+ + {error} +

+ )} +
+ ); + } + + // ── Step: test it ── + const management = useSchemaStore.getState().viewToSection['x:QueuedMessage'] ?? 'Management'; + return ( + navigate(`/${section}/x:MtaOutboundStrategy`)} + nextLabel={t('wizard.finish', 'Finish')} + aside={ + +

+ {t( + 'sendingSetup.where', + 'Settings › Mail flow › Sending holds the delivery strategy and routes this guide changed, for anything it doesn’t cover.', + )} +

+
+ } + > +

+ + {path === 'relay' + ? t('sendingSetup.doneRelay', 'Outgoing mail now goes through {{host}}.', { host: host.trim() }) + : t('sendingSetup.doneDirect', 'This server delivers outgoing mail directly.')} +

+
    +
  1. + {t( + 'sendingSetup.test1', + 'From the webmail, send a message to an address outside your domains, such as a personal Gmail or Outlook account.', + )} +
  2. +
  3. + {t('sendingSetup.test2', 'Watch it leave in')}{' '} + + {t('sendingSetup.test2Link', 'Emails › History › Outbound delivery')} + + {t('sendingSetup.test2b', '. A delivery lists each attempt and the answer from the other side.')} +
  4. +
  5. + {t('sendingSetup.test3', 'If it waits in')}{' '} + + {t('sendingSetup.test3Link', 'Emails › Queued')} + + {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.', + )} +
  6. +
+
+ ); +} diff --git a/src/features/sending/plan.test.ts b/src/features/sending/plan.test.ts new file mode 100644 index 0000000..06fb5f2 --- /dev/null +++ b/src/features/sending/plan.test.ts @@ -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(); + }); +}); diff --git a/src/features/sending/plan.ts b/src/features/sending/plan.ts new file mode 100644 index 0000000..31cc393 --- /dev/null +++ b/src/features/sending/plan.ts @@ -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; 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; + routeUpdate?: { id: string; patch: Record }; + tlsCreate?: Record; + 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 { + 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 = { + 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; +} diff --git a/src/features/sending/providers.ts b/src/features/sending/providers.ts new file mode 100644 index 0000000..cce0313 --- /dev/null +++ b/src/features/sending/providers.ts @@ -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') + ); +} diff --git a/src/features/sending/usePort25Check.test.ts b/src/features/sending/usePort25Check.test.ts new file mode 100644 index 0000000..458a172 --- /dev/null +++ b/src/features/sending/usePort25Check.test.ts @@ -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'); + }); +}); diff --git a/src/features/sending/usePort25Check.ts b/src/features/sending/usePort25Check.ts new file mode 100644 index 0000000..5e75e02 --- /dev/null +++ b/src/features/sending/usePort25Check.ts @@ -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({ kind: 'idle' }); + const source = useRef(null); + const timer = useRef | 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 }; +} diff --git a/src/features/settings/SettingsOverviewPage.tsx b/src/features/settings/SettingsOverviewPage.tsx index a2bea4d..33abea8 100644 --- a/src/features/settings/SettingsOverviewPage.tsx +++ b/src/features/settings/SettingsOverviewPage.tsx @@ -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 { /** 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) => }, + gate: SENDING_WIZARD_VIEW, + }, { id: 'dns', title: 'Publish DNS records automatically', @@ -122,11 +130,11 @@ function SetupCard({ setup }: { setup: GuidedSetup }) { }; }, [setup]); - return ( - + 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 = ( + <>
{setup.title} {done && ( @@ -137,7 +145,23 @@ function SetupCard({ setup }: { setup: GuidedSetup }) { )}

{setup.blurb}

- + + ); + + if (typeof setup.to === 'string') { + return ( + + {body} + + ); + } + return ( + <> + + {setup.to.choice({ open: choosing, onOpenChange: setChoosing })} + ); } diff --git a/src/lib/layout.ts b/src/lib/layout.ts index a56f185..32a935d 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -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 }; } diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index fc924d7..383721d 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -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')}`; -- 2.54.0