Relays: the Relays page #147
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user