Relays: the Relays page #147

Merged
jcoffey-dev merged 2 commits from relay/page into main 2026-10-30 21:23:14 +00:00
10 changed files with 1924 additions and 1 deletions

No files matched your search

+4
View File
@@ -131,6 +131,10 @@ const CUSTOM_PAGES: Record<string, ComponentType> = {
() => import('@/features/dlp/RulesPage'),
(m) => m.MailFlowRulesPage,
),
Relays: onDemand(
() => import('@/features/relays/RelaysPage'),
(m) => m.RelaysPage,
),
Journal: onDemand(
() => import('@/features/journal/JournalPage'),
(m) => m.JournalPage,
File diff suppressed because it is too large. Load diff
+138
View File
@@ -0,0 +1,138 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA: `inbuxa:Relay` and `inbuxa:RelayMessage` over JMAP (relay spec,
* "Registry objects"). The console never talks to a relay: the server
* pushes what is saved here, and reads a relay's queues over its own link.
*/
import { getAccountId, jmapRequest } from '@/services/jmap/client';
import type { Relay, RelayMessage } from './model';
const CAPABILITY = 'urn:inbuxa:jmap';
/** The server has no relays (an older server). */
export class RelaysUnavailable extends Error {}
/** This server couldn't reach the relay to read or change its queues. */
export class RelayUnreachable extends Error {}
function failure(name: string | undefined, result: unknown): Error {
const r = result as { type?: string; description?: string } | undefined;
if (r?.type === 'unknownMethod' || r?.type === 'unknownCapability') return new RelaysUnavailable(r.type);
if (r?.type === 'serverUnavailable') return new RelayUnreachable(r.description ?? r.type);
return new Error(r?.description ?? r?.type ?? `${name ?? 'Request'} failed`);
}
type Call = [string, Record<string, unknown>, string];
async function calls(list: Call[], signal?: AbortSignal): Promise<Record<string, unknown>[]> {
const accountId = getAccountId('x:Account');
const responses = await jmapRequest(
list.map(([method, args, id]) => [method, { accountId, ...args }, id]),
signal,
[CAPABILITY],
);
return list.map(([method], i) => {
const [name, result] = responses[i] ?? [];
if (name !== method) throw failure(name, result);
return result as Record<string, unknown>;
});
}
async function call(method: string, args: Record<string, unknown>, signal?: AbortSignal) {
return (await calls([[method, args, '0']], signal))[0];
}
/** The error the server gave one object of a set call, if it gave one. */
function setFailure(result: Record<string, unknown>, bucket: string, key: string): Error | null {
const failed = (
result[bucket] as Record<string, { type?: string; description?: string; properties?: string[] }> | undefined
)?.[key];
if (!failed) return null;
const where = failed.properties?.length ? ` (${failed.properties.join(', ')})` : '';
return new Error(`${failed.description ?? failed.type ?? 'Not saved'}${where}`);
}
function withReason(reason?: string): Record<string, unknown> {
return reason?.trim() ? { reason: reason.trim() } : {};
}
// --- Relays -----------------------------------------------------------------
export async function fetchRelays(signal?: AbortSignal): Promise<Relay[]> {
const result = await call('inbuxa:Relay/get', { ids: null }, signal);
return ((result.list as Relay[] | undefined) ?? []).sort((a, b) => a.name.localeCompare(b.name));
}
export interface NewRelay {
name: string;
address: string;
linkPort: number;
hostname: string;
}
/** Adds a relay; its id and its join token, which is never shown again. */
export async function createRelay(relay: NewRelay): Promise<{ id: string; joinToken: string }> {
const result = await call('inbuxa:Relay/set', {
create: {
r: {
name: relay.name.trim(),
address: relay.address.trim(),
linkPort: relay.linkPort,
settings: { hostname: relay.hostname.trim() },
},
},
});
const failed = setFailure(result, 'notCreated', 'r');
if (failed) throw failed;
const created = (result.created as Record<string, { id: string; joinToken: string }>).r;
return { id: created.id, joinToken: created.joinToken };
}
export async function updateRelay(id: string, patch: Record<string, unknown>, reason?: string): Promise<void> {
const result = await call('inbuxa:Relay/set', { ...withReason(reason), update: { [id]: patch } });
const failed = setFailure(result, 'notUpdated', id);
if (failed) throw failed;
}
/** Starts a new join, for a relay that was reset: the new token. */
export async function rejoinRelay(id: string, reason?: string): Promise<string> {
const result = await call('inbuxa:Relay/set', { ...withReason(reason), update: { [id]: { rejoin: true } } });
const failed = setFailure(result, 'notUpdated', id);
if (failed) throw failed;
const token = (result.updated as Record<string, { joinToken?: string } | null> | undefined)?.[id]?.joinToken;
if (!token) throw new Error('The server gave no join token.');
return token;
}
export async function deleteRelay(id: string, reason?: string): Promise<void> {
const result = await call('inbuxa:Relay/set', { ...withReason(reason), destroy: [id] });
const failed = setFailure(result, 'notDestroyed', id);
if (failed) throw failed;
}
// --- Its queues ---------------------------------------------------------------
/** What waits at one relay, newest first, read over the server's link. */
export async function fetchRelayMessages(relayId: string, signal?: AbortSignal): Promise<RelayMessage[]> {
const [, got] = await calls(
[
['inbuxa:RelayMessage/query', { filter: { relayId } }, 'q'],
['inbuxa:RelayMessage/get', { '#ids': { resultOf: 'q', name: 'inbuxa:RelayMessage/query', path: '/ids' } }, 'g'],
],
signal,
);
return (got.list as RelayMessage[] | undefined) ?? [];
}
/** Discards held mail, or cancels an outbound message, with no DSN. */
export async function destroyRelayMessage(id: string, reason: string): Promise<void> {
const result = await call('inbuxa:RelayMessage/set', { ...withReason(reason), destroy: [id] });
const failed = setFailure(result, 'notDestroyed', id);
if (failed) throw failed;
}
+240
View File
@@ -0,0 +1,240 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import {
attention,
envelopeLine,
formatDuration,
joinCommand,
parseDuration,
parseDurations,
settingsPatch,
toForm,
waiting,
type Relay,
type RelayLink,
type RelayMessage,
type RelaySettings,
} from './model';
const SETTINGS: RelaySettings = {
hostname: 'mx1.example.com',
smtpListen: ['[::]:25'],
domains: ['example.com'],
maxMessageSize: 50 * 1024 * 1024,
maxRecipients: 100,
rateLimits: { connectionsPerMinute: 60, messagesPerMinute: 60, concurrentSessions: 10 },
dnsbl: [],
liveVerdictTimeout: 30,
heldMaxAge: 30,
spoolMaxBytes: 2 * 1024 * 1024 * 1024,
outboundRetry: [120, 300, 600, 1800, 3600, 7200, 14400],
outboundExpiry: 3 * 86400,
outboundTls: 'opportunistic',
};
const NOW = new Date('2026-10-30T12:00:00Z');
function link(nodeId: string, extra: Partial<RelayLink> = {}): RelayLink {
return {
nodeId,
state: 'linked',
since: '2026-10-30T10:00:00Z',
updatedAt: '2026-10-30T11:59:00Z',
recipients: 12,
relay: {
startedAt: '2026-10-30T09:00:00Z',
relayVersion: '27.1.0',
linkClients: 1,
mode: 'live',
spool: { inbound: 0, outbound: 2, bytes: 1024 },
recipientState: 's1',
selfUpgrade: true,
},
...extra,
};
}
function relay(extra: Partial<Relay> = {}): Relay {
return {
id: 'b',
name: 'Edge',
description: '',
address: '10.0.0.2',
linkPort: 443,
enabled: true,
sendThrough: false,
inboundOnly: false,
settings: SETTINGS,
joinState: 'joined',
links: [link('1')],
createdBy: 'admin',
createdAt: '2026-10-30T08:00:00Z',
updatedAt: '2026-10-30T08:00:00Z',
...extra,
};
}
describe('attention', () => {
it('says nothing when everything is fine, or the relay is off', () => {
expect(attention(relay(), NOW)).toEqual([]);
expect(attention(relay({ enabled: false, links: [] }), NOW)).toEqual([]);
});
it('waits for a join, or says it ran out', () => {
expect(attention(relay({ joinState: 'waiting', joinExpiresAt: 'x', links: [] }), NOW)).toEqual([
{ kind: 'joinWaiting', until: 'x' },
]);
expect(attention(relay({ joinState: 'expired', links: [] }), NOW)).toEqual([{ kind: 'joinExpired' }]);
});
it('names each node whose link is down', () => {
const found = attention(
relay({
links: [link('1'), link('2', { state: 'down', lastError: 'refused' }), link('3', { state: 'joinFailed' })],
}),
NOW,
);
expect(found).toContainEqual({ kind: 'linkDown', nodeId: '2', since: '2026-10-30T10:00:00Z', error: 'refused' });
expect(found).toContainEqual({ kind: 'joinFailed', nodeId: '3', error: undefined });
expect(attention(relay({ links: [] }), NOW)).toEqual([{ kind: 'notLinked' }]);
});
it('warns of a filling spool, a certificate near its end, and the relay’s own error', () => {
const status = link('1').relay!;
const found = attention(
relay({
links: [
link('1', {
relay: {
...status,
spool: { ...status.spool, bytes: SETTINGS.spoolMaxBytes * 0.9 },
certificateNotAfter: '2026-11-05T00:00:00Z',
lastError: 'disk full',
},
}),
],
}),
NOW,
);
expect(found).toEqual([
{ kind: 'spoolFilling', percent: 90 },
{ kind: 'certificateExpiring', notAfter: '2026-11-05T00:00:00Z' },
{ kind: 'relayError', error: 'disk full' },
]);
const expired = attention(
relay({ links: [link('1', { relay: { ...status, certificateNotAfter: '2026-10-01T00:00:00Z' } })] }),
NOW,
);
expect(expired).toEqual([{ kind: 'certificateExpired', notAfter: '2026-10-01T00:00:00Z' }]);
});
it('reads the status the relay sent last', () => {
const status = link('1').relay!;
const found = attention(
relay({
links: [
link('1', { updatedAt: '2026-10-30T11:00:00Z', relay: { ...status, lastError: 'old' } }),
link('2', { updatedAt: '2026-10-30T11:59:00Z' }),
],
}),
NOW,
);
expect(found).toEqual([]);
});
it('notices nodes that sent different recipient sets', () => {
expect(attention(relay({ links: [link('1'), link('2', { recipients: 11 })] }), NOW)).toEqual([
{ kind: 'recipientsDiffer' },
]);
});
});
describe('plain forms', () => {
it('reads and writes durations', () => {
expect(formatDuration(300)).toBe('5m');
expect(formatDuration(259200)).toBe('3d');
expect(formatDuration(90)).toBe('90s');
expect(formatDuration(0)).toBe('0s');
expect(parseDuration('5m')).toBe(300);
expect(parseDuration(' 2H ')).toBe(7200);
expect(parseDuration('45')).toBe(45);
expect(parseDuration('5 minutes')).toBeNull();
expect(parseDurations('2m, 5m,1h')).toEqual([120, 300, 3600]);
expect(parseDurations('2m, soon')).toBeNull();
});
it('sends only what changed', () => {
const before = toForm(SETTINGS);
expect(before.outboundRetry).toBe('2m, 5m, 10m, 30m, 1h, 2h, 4h');
expect(before.maxMessageSizeMb).toBe('50');
expect(settingsPatch(before, before, SETTINGS)).toEqual({ patch: {} });
expect(
settingsPatch(
before,
{ ...before, maxRecipients: '20', outboundRetry: '1m, 1h', dnsbl: 'zen.example.org\nbl.example.net' },
SETTINGS,
),
).toEqual({
patch: {
'settings/maxRecipients': 20,
'settings/outboundRetry': [60, 3600],
'settings/dnsbl': ['zen.example.org', 'bl.example.net'],
},
});
expect(settingsPatch(before, { ...before, messagesPerMinute: '30' }, SETTINGS)).toEqual({
patch: {
'settings/rateLimits': { connectionsPerMinute: 60, messagesPerMinute: 30, concurrentSessions: 10 },
},
});
});
it('keeps a size it shows rounded until it is changed', () => {
const odd = { ...SETTINGS, maxMessageSize: 12_345_678 };
const before = toForm(odd);
expect(before.maxMessageSizeMb).toBe('11.77');
expect(settingsPatch(before, { ...before, hostname: 'mx2.example.com' }, odd)).toEqual({
patch: { 'settings/hostname': 'mx2.example.com' },
});
expect(settingsPatch(before, { ...before, maxMessageSizeMb: '25' }, odd)).toEqual({
patch: { 'settings/maxMessageSize': 25 * 1024 * 1024 },
});
});
it('names the field that doesn’t read', () => {
const before = toForm(SETTINGS);
expect(settingsPatch(before, { ...before, maxRecipients: 'many' }, SETTINGS)).toEqual({
invalid: 'maxRecipients',
});
expect(settingsPatch(before, { ...before, outboundRetry: '' }, SETTINGS)).toEqual({ invalid: 'outboundRetry' });
});
});
describe('queues', () => {
it('lists who it is from and to, and who still waits', () => {
const message: RelayMessage = {
id: 'm',
relayId: 'b',
direction: 'outbound',
relayMessageId: 'o1',
createdAt: '2026-10-30T11:00:00Z',
size: 2048,
envelope: { mailFrom: { email: '[email protected]' }, rcptTo: [{ email: '[email protected]' }, { email: '[email protected]' }] },
deliveryStatus: {
'[email protected]': { smtpReply: '250 2.0.0 Ok', delivered: 'yes' },
'[email protected]': { smtpReply: '451 4.7.1 Try again later', delivered: 'queued' },
},
};
expect(envelopeLine(message)).toEqual({ from: '[email protected]', to: ['[email protected]', '[email protected]'] });
expect(waiting(message)).toEqual([{ to: '[email protected]', reply: '451 4.7.1 Try again later' }]);
expect(envelopeLine({ ...message, envelope: { mailFrom: { email: '' }, rcptTo: [] } }).from).toBe('<>');
});
it('gives the join command', () => {
expect(joinCommand('abc')).toBe('inbuxa-relay join abc');
});
});
+336
View File
@@ -0,0 +1,336 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA: inbuxa Relays as the Relays page sees them (relay spec,
* "inbuxa-admin"): the `inbuxa:Relay` and `inbuxa:RelayMessage` shapes the
* server sends, what needs attention on a relay, and the plain forms the
* page edits its settings in.
*/
export type LinkState = 'connecting' | 'joining' | 'linked' | 'down' | 'joinFailed';
/** What a relay last said of itself, over one node's link. */
export interface RelayStatus {
startedAt: string;
relayVersion: string;
linkClients: number;
mode: 'unconfigured' | 'live' | 'held';
spool: { inbound: number; outbound: number; bytes: number };
recipientState: string;
certificateNotAfter?: string | null;
lastError?: string | null;
selfUpgrade: boolean;
}
/** One node's link to a relay. */
export interface RelayLink {
nodeId: string;
state: LinkState;
since: string;
lastSeen?: string | null;
lastError?: string | null;
relay?: RelayStatus | null;
recipients?: number | null;
updatedAt: string;
}
export interface RelaySettings {
hostname: string;
smtpListen: string[];
domains: string[];
maxMessageSize: number;
maxRecipients: number;
rateLimits: { connectionsPerMinute: number; messagesPerMinute: number; concurrentSessions: number };
dnsbl: string[];
liveVerdictTimeout: number;
heldMaxAge: number;
spoolMaxBytes: number;
outboundRetry: number[];
outboundExpiry: number;
outboundTls: 'opportunistic' | 'required';
}
export interface Relay {
id: string;
name: string;
description: string;
address: string;
linkPort: number;
enabled: boolean;
sendThrough: boolean;
inboundOnly: boolean;
settings: RelaySettings;
joinState: 'waiting' | 'expired' | 'joined';
joinExpiresAt?: string | null;
joinedAt?: string | null;
links: RelayLink[];
createdBy: string;
createdAt: string;
updatedAt: string;
}
export interface EnvelopeAddress {
email: string;
parameters?: Record<string, string | null> | null;
}
export interface DeliveryStatus {
smtpReply: string;
delivered: 'queued' | 'yes' | 'no' | 'unknown';
mx?: string | null;
}
/** A message waiting at a relay (`inbuxa:RelayMessage`). Never its content. */
export interface RelayMessage {
id: string;
relayId: string;
direction: 'inbound' | 'outbound';
relayMessageId: string;
createdAt: string;
size: number;
envelope: { mailFrom: EnvelopeAddress; rcptTo: EnvelopeAddress[] };
state?: 'pending' | 'held' | 'claimed' | null;
claimedUntil?: string | null;
session?: { remoteIp?: string; helo?: string } | null;
deliveryStatus?: Record<string, DeliveryStatus> | null;
nextAttemptAt?: string | null;
attempts?: number | null;
expiresAt?: string | null;
queueId?: string | null;
}
// --- Attention ----------------------------------------------------------------
/**
* Something about a relay an administrator should look at. The list shows
* these and nothing else: no line at all when everything is fine.
*/
export type Attention =
| { kind: 'joinWaiting'; until?: string | null }
| { kind: 'joinExpired' }
| { kind: 'notLinked' }
| { kind: 'linkDown'; nodeId: string; since: string; error?: string | null }
| { kind: 'joinFailed'; nodeId: string; error?: string | null }
| { kind: 'spoolFilling'; percent: number }
| { kind: 'certificateExpiring'; notAfter: string }
| { kind: 'certificateExpired'; notAfter: string }
| { kind: 'relayError'; error: string }
| { kind: 'recipientsDiffer' };
/** A spool this full is worth a line. */
export const SPOOL_WARN = 0.8;
/** A certificate this close to expiring is worth a line. */
export const CERTIFICATE_WARN_DAYS = 14;
/** What the relay said last, over whichever link heard from it most recently. */
export function latestStatus(relay: Relay): RelayStatus | null {
let best: RelayLink | null = null;
for (const link of relay.links) {
if (link.relay && (!best || link.updatedAt > best.updatedAt)) best = link;
}
return best?.relay ?? null;
}
export function attention(relay: Relay, now: Date = new Date()): Attention[] {
if (!relay.enabled) return [];
if (relay.joinState === 'waiting') return [{ kind: 'joinWaiting', until: relay.joinExpiresAt }];
if (relay.joinState === 'expired') return [{ kind: 'joinExpired' }];
const out: Attention[] = [];
if (relay.links.length === 0) out.push({ kind: 'notLinked' });
for (const link of relay.links) {
if (link.state === 'joinFailed') out.push({ kind: 'joinFailed', nodeId: link.nodeId, error: link.lastError });
else if (link.state !== 'linked')
out.push({ kind: 'linkDown', nodeId: link.nodeId, since: link.since, error: link.lastError });
}
const status = latestStatus(relay);
if (status) {
const max = relay.settings.spoolMaxBytes;
if (max > 0 && status.spool.bytes / max >= SPOOL_WARN) {
out.push({ kind: 'spoolFilling', percent: Math.min(100, Math.round((status.spool.bytes / max) * 100)) });
}
if (status.certificateNotAfter) {
const left = new Date(status.certificateNotAfter).getTime() - now.getTime();
if (left <= 0) out.push({ kind: 'certificateExpired', notAfter: status.certificateNotAfter });
else if (left < CERTIFICATE_WARN_DAYS * 86_400_000)
out.push({ kind: 'certificateExpiring', notAfter: status.certificateNotAfter });
}
if (status.lastError) out.push({ kind: 'relayError', error: status.lastError });
}
// Every node sends the same recipients; nodes that disagree have a stale set
const counts = new Set(
relay.links.filter((l) => l.state === 'linked' && l.recipients != null).map((l) => l.recipients),
);
if (counts.size > 1) out.push({ kind: 'recipientsDiffer' });
return out;
}
/** The one command to run on the relay host, with the join token. */
export function joinCommand(token: string): string {
return `inbuxa-relay join ${token}`;
}
// --- Plain forms --------------------------------------------------------------
const UNITS: [string, number][] = [
['d', 86_400],
['h', 3_600],
['m', 60],
['s', 1],
];
/** Seconds as the largest unit that divides them exactly: 300 → "5m". */
export function formatDuration(seconds: number): string {
if (seconds === 0) return '0s';
for (const [unit, size] of UNITS) {
if (seconds % size === 0) return `${seconds / size}${unit}`;
}
return `${seconds}s`;
}
/** "5m", "2h", "3d", "90s" or a bare number of seconds; null if it isn't one. */
export function parseDuration(text: string): number | null {
const match = /^\s*(\d+)\s*([dhms]?)\s*$/i.exec(text);
if (!match) return null;
const size = UNITS.find(([unit]) => unit === (match[2] || 's').toLowerCase())?.[1] ?? 1;
return Number(match[1]) * size;
}
/** A list in a text box: one item per comma or line, blanks dropped. */
export function parseList(text: string): string[] {
return text
.split(/[\n,]/)
.map((s) => s.trim())
.filter(Boolean);
}
/** A comma list of durations, or null if one isn't. */
export function parseDurations(text: string): number[] | null {
const out: number[] = [];
for (const item of parseList(text)) {
const seconds = parseDuration(item);
if (seconds === null) return null;
out.push(seconds);
}
return out;
}
const MB = 1024 * 1024;
const GB = 1024 * MB;
/** The settings as the form shows them, every value as text. */
export interface SettingsForm {
hostname: string;
smtpListen: string;
maxMessageSizeMb: string;
maxRecipients: string;
connectionsPerMinute: string;
messagesPerMinute: string;
concurrentSessions: string;
dnsbl: string;
liveVerdictTimeout: string;
heldMaxAge: string;
spoolMaxGb: string;
outboundRetry: string;
outboundExpiry: string;
outboundTls: 'opportunistic' | 'required';
}
function trimmedNumber(value: number): string {
return String(Number(value.toFixed(2)));
}
export function toForm(s: RelaySettings): SettingsForm {
return {
hostname: s.hostname,
smtpListen: s.smtpListen.join(', '),
maxMessageSizeMb: trimmedNumber(s.maxMessageSize / MB),
maxRecipients: String(s.maxRecipients),
connectionsPerMinute: String(s.rateLimits.connectionsPerMinute),
messagesPerMinute: String(s.rateLimits.messagesPerMinute),
concurrentSessions: String(s.rateLimits.concurrentSessions),
dnsbl: s.dnsbl.join(', '),
liveVerdictTimeout: formatDuration(s.liveVerdictTimeout),
heldMaxAge: String(s.heldMaxAge),
spoolMaxGb: trimmedNumber(s.spoolMaxBytes / GB),
outboundRetry: s.outboundRetry.map(formatDuration).join(', '),
outboundExpiry: formatDuration(s.outboundExpiry),
outboundTls: s.outboundTls,
};
}
function whole(text: string): number | null {
return /^\s*\d+\s*$/.test(text) ? Number(text) : null;
}
function amount(text: string, unit: number): number | null {
return /^\s*\d+(\.\d+)?\s*$/.test(text) ? Math.round(Number(text) * unit) : null;
}
/**
* What changed between the form as it was loaded and as it is now, as the
* server takes it (`settings/<name>`), or the first field that doesn't
* read. A field left as it was is never sent, so a value the form rounds
* (a size in MB) stays exactly as the server had it.
*/
export function settingsPatch(
before: SettingsForm,
after: SettingsForm,
current: RelaySettings,
): { patch: Record<string, unknown> } | { invalid: keyof SettingsForm } {
const patch: Record<string, unknown> = {};
const changed = (field: keyof SettingsForm) => before[field] !== after[field];
const read = <T>(field: keyof SettingsForm, value: T | null): T | undefined => {
if (value === null) throw field;
return value;
};
try {
if (changed('hostname')) patch['settings/hostname'] = after.hostname.trim();
if (changed('smtpListen')) patch['settings/smtpListen'] = parseList(after.smtpListen);
if (changed('maxMessageSizeMb'))
patch['settings/maxMessageSize'] = read('maxMessageSizeMb', amount(after.maxMessageSizeMb, MB));
if (changed('maxRecipients')) patch['settings/maxRecipients'] = read('maxRecipients', whole(after.maxRecipients));
if (changed('connectionsPerMinute') || changed('messagesPerMinute') || changed('concurrentSessions')) {
patch['settings/rateLimits'] = {
...current.rateLimits,
connectionsPerMinute: read('connectionsPerMinute', whole(after.connectionsPerMinute)),
messagesPerMinute: read('messagesPerMinute', whole(after.messagesPerMinute)),
concurrentSessions: read('concurrentSessions', whole(after.concurrentSessions)),
};
}
if (changed('dnsbl')) patch['settings/dnsbl'] = parseList(after.dnsbl);
if (changed('liveVerdictTimeout'))
patch['settings/liveVerdictTimeout'] = read('liveVerdictTimeout', parseDuration(after.liveVerdictTimeout));
if (changed('heldMaxAge')) patch['settings/heldMaxAge'] = read('heldMaxAge', whole(after.heldMaxAge));
if (changed('spoolMaxGb')) patch['settings/spoolMaxBytes'] = read('spoolMaxGb', amount(after.spoolMaxGb, GB));
if (changed('outboundRetry')) {
const retry = read('outboundRetry', parseDurations(after.outboundRetry));
if (!retry?.length) throw 'outboundRetry';
patch['settings/outboundRetry'] = retry;
}
if (changed('outboundExpiry'))
patch['settings/outboundExpiry'] = read('outboundExpiry', parseDuration(after.outboundExpiry));
if (changed('outboundTls')) patch['settings/outboundTls'] = after.outboundTls;
} catch (field) {
return { invalid: field as keyof SettingsForm };
}
return { patch };
}
/** Who sent it and to whom, for a line in the queue. */
export function envelopeLine(message: RelayMessage): { from: string; to: string[] } {
return {
from: message.envelope?.mailFrom?.email || '<>',
to: (message.envelope?.rcptTo ?? []).map((r) => r.email),
};
}
/** The outbound recipients still waiting, with the last reply each got. */
export function waiting(message: RelayMessage): { to: string; reply: string }[] {
return Object.entries(message.deliveryStatus ?? {})
.filter(([, status]) => status.delivered === 'queued')
.map(([to, status]) => ({ to, reply: status.smtpReply }));
}
+32
View File
@@ -18,6 +18,38 @@ import { SETTINGS_PAGE_HELP } from './settingsPages';
import { SECURITY_PAGE_HELP } from './securityChecks';
export const FIELD_HELP: Record<string, string> = {
// inbuxa Relays (relay spec, "inbuxa-admin")
'inbuxa:Relay.name': 'What you call this relay here. Routes name relays by it.',
'inbuxa:Relay.description': 'A note for administrators, such as where the relay runs.',
'inbuxa:Relay.address':
'The relay’s host name or IP address, as this server dials it for the link. A WireGuard address keeps the link off the internet.',
'inbuxa:Relay.linkPort': 'The port the relay listens on for this server’s link. 443 unless you chose another.',
'inbuxa:Relay.enabled':
'Turn off to drop the link: the relay closes port 25 and keeps what it holds until it’s turned back on.',
'inbuxa:Relay.sendThrough':
'Send all outgoing mail through relays instead of straight from this server. Mail waits here while no relay is linked.',
'inbuxa:Relay.inboundOnly':
'The relay takes mail in but sends none, for instance while its address is new to other providers or not in your SPF record yet.',
'inbuxa:Relay.hostname':
'The relay’s public name: what it says when other servers connect, and the name on its certificate. Needs an A record and matching reverse DNS.',
'inbuxa:Relay.smtpListen': 'The addresses the relay takes mail on, usually [::]:25 for every address on port 25.',
'inbuxa:Relay.maxMessageSize': 'The largest message the relay accepts, in either direction.',
'inbuxa:Relay.maxRecipients': 'How many recipients one message may have at the relay.',
'inbuxa:Relay.rateLimits':
'How fast one sending address may connect and send to the relay, so a flood stops at the edge.',
'inbuxa:Relay.dnsbl':
'Blocklists the relay checks connecting servers against while this server is unreachable. Empty checks none.',
'inbuxa:Relay.liveVerdictTimeout':
'How long the relay waits for this server’s answer about a message before keeping it to deliver later. Keep it above the spam check’s time limit.',
'inbuxa:Relay.heldMaxAge':
'How many days the relay keeps mail it couldn’t hand over before returning it to the sender.',
'inbuxa:Relay.spoolMaxBytes':
'How much disk the relay may use for mail it holds. Past it, it asks senders to try again later.',
'inbuxa:Relay.outboundRetry':
'How long the relay waits between attempts to deliver outgoing mail, in order; the last wait repeats.',
'inbuxa:Relay.outboundExpiry': 'How long the relay keeps trying before outgoing mail fails and the sender is told.',
'inbuxa:Relay.outboundTls':
'Opportunistic uses encryption when the other server offers it. Required delivers nothing unencrypted. DANE and MTA-STS are always enforced.',
// Domains
'x:Domain.name': 'The domain people’s addresses end in, like example.com.',
'x:Domain.aliases':
+127
View File
@@ -2342,5 +2342,132 @@
"connection": {
"unreachable": "Can't reach the server. You're still signed in, and it keeps trying.",
"retry": "Try now"
},
"relays": {
"att": {
"joinWaiting": "Waiting for the relay to join, until {{until}}.",
"joinWaitingNow": "Waiting for the relay to join.",
"joinExpired": "The join token ran out before the relay joined. Start a new join.",
"notLinked": "No node has linked to it yet.",
"linkDownWhy": "Node {{node}} has no link since {{since}}: {{error}}",
"linkDown": "Node {{node}} has no link since {{since}}.",
"joinFailed": "Node {{node}} couldn’t join it{{why}}. Start a new join.",
"spool": "Its spool is {{percent}}% full.",
"certSoon": "Its certificate runs out {{when}}.",
"certGone": "Its certificate ran out {{when}}.",
"relayError": "The relay reports: {{error}}",
"recipients": "Nodes sent it different recipient lists; one of them is out of date."
},
"copied": "Copied",
"tokenTitle": "Join {{name}}",
"tokenBody": "Run this on the relay host within 24 hours. The token is shown only now: copy it before closing.",
"copyCommand": "Copy the command",
"done": "Done",
"addFailed": "The relay wasn’t added",
"addTitle": "Add a relay",
"addBody": "Install inbuxa-relay on the edge host first. Then add it here, and run the command shown next on that host.",
"f": {
"name": "Name",
"address": "Link address",
"linkPort": "Link port",
"hostname": "Public name",
"description": "Description",
"enabled": "Linked",
"sendThrough": "Send all outgoing mail through relays",
"inboundOnly": "Takes mail in only",
"smtpListen": "Listens on",
"maxMessageSize": "Largest message (MB)",
"maxRecipients": "Recipients per message",
"rateLimits": "Per sending address",
"connectionsPerMinute": "Connections a minute",
"messagesPerMinute": "Messages a minute",
"concurrentSessions": "At once",
"dnsbl": "Blocklists while this server is unreachable",
"liveVerdictTimeout": "Wait for an answer",
"heldMaxAge": "Hold mail (days)",
"spoolMaxBytes": "Spool size (GB)",
"outboundRetry": "Waits between attempts",
"outboundExpiry": "Give up after",
"outboundTls": "Encryption"
},
"cancel": "Cancel",
"addGo": "Add",
"saved": "Saved. A linked relay gets it within seconds.",
"saveFailed": "Nothing was saved",
"settingsTitle": "{{name}} settings",
"settingsBody": "This server pushes these to the relay. The relay has no settings of its own.",
"s": {
"general": "The relay",
"receiving": "Taking mail in",
"sending": "Sending mail on"
},
"tls": {
"opportunistic": "When offered",
"required": "Always"
},
"invalid": "One value doesn’t read: check the field marked in red.",
"reason": "Reason, for the audit log (optional)",
"close": "Close",
"save": "Save",
"q": {
"in": "In",
"out": "Out",
"fromTo": "{{from}} to {{to}}",
"held": "waiting for this server",
"pending": "waiting for an answer",
"claimed": "being taken now",
"next": "next try {{when}}",
"reply": "{{to}}: {{reply}}",
"notTried": "{{to}}: not tried yet",
"discard": "Discard…",
"cancel": "Cancel…",
"discarded": "Discarded",
"cancelled": "Cancelled",
"destroyFailed": "Nothing was changed",
"discardTitle": "Discard this message?",
"cancelTitle": "Cancel this message?",
"discardBody": "It’s deleted at the relay and never reaches anyone here. The sender isn’t told.",
"cancelBody": "It isn’t delivered to the recipients still waiting, and the sender isn’t told. If the relay is sending it right now, it stops after this attempt.",
"reason": "Reason, for the audit log",
"discardGo": "Discard",
"cancelGo": "Cancel it",
"unreachable": "This server can’t reach the relay right now.",
"title": "Waiting at {{name}}",
"body": "Read from the relay just now. Mail coming in waits there while this server can’t take it; mail going out waits for the other side.",
"none": "Nothing is waiting.",
"refresh": "Read again"
},
"keep": "Keep it",
"b": {
"off": "Off",
"linked": "Linked on {{linked}} of {{nodes}} nodes",
"sendThrough": "Sends all mail",
"inboundOnly": "Takes mail in only"
},
"noHostname": "no public name yet",
"linkAt": "link at {{address}}:{{port}}",
"status": "Version {{version}} · {{mode}} · {{inbound}} in, {{outbound}} out ({{bytes}})",
"mode": {
"live": "live",
"held": "holding mail",
"unconfigured": "not set up"
},
"settings": "Settings…",
"queue": "Queue…",
"rejoin": "New join…",
"delete": "Remove…",
"unavailable": "This server doesn’t link to relays.",
"title": "Relays",
"subtitle": "Small edge servers that take mail in on port 25 for this server, and can send its mail out. Only this server talks to them.",
"add": "Add a relay…",
"none": "No relays. Mail comes straight to this server and leaves from it.",
"rejoinTitle": "Start a new join for {{name}}?",
"rejoinBody": "For a relay that was reset or reinstalled. Its link stops until it joins again with the new token.",
"rejoinGo": "Start a new join",
"rejoinFailed": "No new join was started",
"deleteTitle": "Remove {{name}}?",
"deleteBody": "This server stops linking to it. The relay keeps the mail it holds, and goes on taking mail in, until you run inbuxa-relay reset on its host. To stop it for a while, turn it off instead: it comes back without a new join.",
"deleteGo": "Remove",
"deleteFailed": "The relay wasn’t removed"
}
}
+1
View File
@@ -58,6 +58,7 @@ const PAGES: Record<string, PageRule> = {
'CustomComponent/Journal': readOnly('sysJournal'),
'CustomComponent/HeldMail': readOnly('sysDlpReview'),
'CustomComponent/MailFlowRules': readOnly('sysMailRule'),
'CustomComponent/Relays': readOnly('sysRelay'),
'CustomComponent/AccountLocks': readOnly('sysAccountLock'),
'CustomComponent/SharedMailboxes': readOnly('sysAccountLock'),
'CustomComponent/Deliverability': readOnly('sysDeliverability'),
+1
View File
@@ -92,6 +92,7 @@ describe('isLinkVisible for the console’s own pages', () => {
['CustomComponent/Journal', 'sysJournal'],
['CustomComponent/HeldMail', 'sysDlpReview'],
['CustomComponent/MailFlowRules', 'sysMailRule'],
['CustomComponent/Relays', 'sysRelay'],
['CustomComponent/AccountLocks', 'sysAccountLock'],
['CustomComponent/SharedMailboxes', 'sysAccountLock'],
['CustomComponent/Deliverability', 'sysDeliverability'],
+2 -1
View File
@@ -60,7 +60,8 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [
name: 'Sending',
pages: [
p('x:MtaOutboundStrategy', 'Delivery strategy'),
p('x:MtaRoute', 'Routes and relays'),
p('x:MtaRoute', 'Routes'),
p('CustomComponent/Relays', 'Relays'),
p('x:MtaDeliverySchedule', 'Retry schedules'),
p('x:MtaVirtualQueue', 'Queues'),
],