From d5eeb4a1048a2c01adefa8154dde002bc38ead43 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 30 Oct 2026 15:21:53 -0700 Subject: [PATCH] Relays: each relay's certificate The list says when a joined relay has no certificate, when this server couldn't order one and why, and when it runs out, from the certificate stored here before the one the relay reports (relay spec, RL-27). A server from before RL-27 sends none of this, and no line shows. A relay's settings get a Certificate section: how long it's good for or why the last order failed, and the chain and key to paste where no domain here manages the public name's DNS. The key is never shown; left empty, the stored one stays. Clearing the chain removes both. --- src/features/relays/RelaysPage.tsx | 65 ++++++++++++++++++++++++++++++ src/features/relays/model.test.ts | 17 ++++++++ src/features/relays/model.ts | 28 +++++++++---- src/help/texts.ts | 4 ++ src/i18n/en.json | 20 +++++++-- 5 files changed, 123 insertions(+), 11 deletions(-) diff --git a/src/features/relays/RelaysPage.tsx b/src/features/relays/RelaysPage.tsx index 7aaec7d..7fcb293 100644 --- a/src/features/relays/RelaysPage.tsx +++ b/src/features/relays/RelaysPage.tsx @@ -154,6 +154,13 @@ function attentionText(t: TFunction, a: Attention): string { return t('relays.att.certSoon', 'Its certificate runs out {{when}}.', { when: when(a.notAfter) }); case 'certificateExpired': return t('relays.att.certGone', 'Its certificate ran out {{when}}.', { when: when(a.notAfter) }); + case 'noCertificate': + return t( + 'relays.att.noCertificate', + 'It has no certificate yet. This server orders one when a domain here manages the DNS of its public name; otherwise, upload one in its settings.', + ); + case 'certificateFailed': + return t('relays.att.certFailed', 'Its certificate couldn’t be ordered: {{error}}', { error: a.error }); case 'relayError': return t('relays.att.relayError', 'The relay reports: {{error}}', { error: a.error }); case 'recipientsDiffer': @@ -323,6 +330,8 @@ function SettingsDialog({ const startForm = useMemo(() => toForm(relay.settings), [relay]); const [g, setG] = useState(startGeneral); const [form, setForm] = useState(startForm); + const [chain, setChain] = useState(relay.smtpCertificate ?? ''); + const [key, setKey] = useState(''); const [reason, setReason] = useState(''); const [busy, setBusy] = useState(false); const [invalid, setInvalid] = useState(null); @@ -363,6 +372,19 @@ function SettingsDialog({ } patch.mxPreference = Number(g.mxPreference); } + // A certificate uploaded by hand: the chain and its key go together + if (chain.trim() !== (relay.smtpCertificate ?? '').trim() || key.trim()) { + if (!chain.trim()) { + patch.smtpCertificate = null; + patch.smtpPrivateKey = null; + } else if (!key.trim() && !relay.smtpPrivateKey) { + setInvalid('smtpPrivateKey'); + return; + } else { + patch.smtpCertificate = chain.trim(); + if (key.trim()) patch.smtpPrivateKey = key.trim(); + } + } for (const flag of ['enabled', 'sendThrough', 'inboundOnly'] as const) { if (g[flag] !== startGeneral[flag]) patch[flag] = g[flag]; } @@ -536,6 +558,49 @@ function SettingsDialog({ +
+

{t('relays.s.certificate', 'Certificate')}

+

+ {relay.certificateError + ? t('relays.cert.failed', 'The last order failed: {{error}}', { error: relay.certificateError }) + : relay.certificateNotAfter + ? t('relays.cert.until', 'Good until {{when}}.', { when: when(relay.certificateNotAfter) }) + : t('relays.cert.none', 'None yet.')}{' '} + {t( + 'relays.cert.how', + 'Where a domain here manages its public name’s DNS, this server orders and renews it. Otherwise, paste one here.', + )} +

+ +