About: name the webmail node and the mail server node
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m26s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 53s

Settings > About shows which webmail node answered (NODE_NAME, else the
container hostname) and which inbuxa node it talks to: the address the
server's name resolves to from the webmail, named by its PTR record. The
server only tells administrators its node name, so the webmail works it
out itself. Fetched from /api/about/nodes on every visit, cached for a
minute server-side, for troubleshooting a cluster.

Fork-only: upstream ihasmail runs one webmail against one server.
4 new strings, translated in all 9 catalogues.
This commit is contained in:
jcoffey-dev committed 2026-09-25 20:46:04 -07:00
1 parent 119548090e
commit 6b44705bfd
14 files changed
+189 -1

No files matched your search

+7
View File
@@ -44,6 +44,7 @@ import { imageProxyHandler } from "./imageproxy.js";
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js"; import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js";
import { icsProxyHandler } from "./icsproxy.js"; import { icsProxyHandler } from "./icsproxy.js";
import { staticHandler } from "./static.js"; import { staticHandler } from "./static.js";
import { mailNode, webmailNode } from "./nodes.js";
type Env = { Variables: { session: LiveSession } }; type Env = { Variables: { session: LiveSession } };
@@ -609,6 +610,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
return c.json({ ok: true }); return c.json({ ok: true });
}); });
/* ihasmail-inbuxa: which webmail node this is, and which mail node it talks to (nodes.ts). */
api.get("/about/nodes", requireSession, async (c) => {
const session = c.get("session");
return c.json({ webmail: webmailNode(), mailServer: await mailNode(upstreamFor(session.username)) });
});
api.get("/auth/sessions", requireSession, (c) => { api.get("/auth/sessions", requireSession, (c) => {
const session = c.get("session"); const session = c.get("session");
return c.json({ current: session.id, sessions: sessions.listForUser(session.account) }); return c.json({ current: session.id, sessions: sessions.listForUser(session.account) });
+6
View File
@@ -296,6 +296,12 @@ export const config = {
* such a tree, so the default is INBUXA's fork. * such a tree, so the default is INBUXA's fork.
*/ */
sourceUrl: env("SOURCE_URL", "https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa"), sourceUrl: env("SOURCE_URL", "https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa"),
/**
* ihasmail-inbuxa: this webmail's own name, shown in Settings > About next
* to the mail node it talks to. Set per host by the deploy; empty means the
* container's hostname. See nodes.ts.
*/
nodeName: env("NODE_NAME", ""),
host: env("HOST", "0.0.0.0"), host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080), port: int("PORT", 8080),
/** /**
+40
View File
@@ -0,0 +1,40 @@
import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { clearNodeCache, mailNode } from "./nodes.js";
/**
* About names the mail node by the PTR of the address the server's name
* resolves to from the webmail -- the local node, where a host entry pins it.
*/
beforeEach(() => clearNodeCache());
const lookup = (address: string) => async () => ({ address });
test("the node is named by its address's PTR, without the trailing dot", async () => {
const n = await mailNode("https://mail.example.com", lookup("192.0.2.2"), async () => ["mx2.example.com."]);
assert.deepEqual(n, { host: "mail.example.com", address: "192.0.2.2", name: "mx2.example.com" });
});
test("an address without a PTR still shows the address", async () => {
const n = await mailNode("https://mail.example.com", lookup("192.0.2.3"), async () => { throw new Error("ENOTFOUND"); });
assert.deepEqual(n, { host: "mail.example.com", address: "192.0.2.3", name: null });
});
test("a name that doesn't resolve says so instead of failing", async () => {
let reversed = false;
const n = await mailNode("https://mail.example.com", async () => { throw new Error("ENOTFOUND"); }, async () => { reversed = true; return []; });
assert.deepEqual(n, { host: "mail.example.com", address: null, name: null });
assert.equal(reversed, false);
});
test("the answer is cached for a minute, then looked up again", async () => {
let calls = 0;
const count = async () => { calls++; return { address: "192.0.2.1" }; };
const rev = async () => ["mail.example.com"];
await mailNode("https://mail.example.com", count, rev, 0);
await mailNode("https://mail.example.com", count, rev, 59_000);
assert.equal(calls, 1);
await mailNode("https://mail.example.com", count, rev, 61_000);
assert.equal(calls, 2);
});
+68
View File
@@ -0,0 +1,68 @@
/**
* ihasmail-inbuxa: which webmail node answered, and which inbuxa node it talks
* to -- shown in Settings > About, for troubleshooting a cluster.
*
* Not for upstream ihasmail: it only makes sense where one webmail runs per
* host, each pinned to its own host's mail node.
*
* The webmail node is NODE_NAME, set per host by the deploy; without it, the
* container's hostname, which is at least distinct. The mail node is worked
* out from the address the server's name resolves to *here*, where the host
* entry pins it to the local node, named by that address's PTR record (every
* node has one: mail/mx2/mx3). The server offers its node name only to
* administrators, so asking it would leave everyone else with nothing.
*/
import { lookup as dnsLookup, reverse as dnsReverse } from "node:dns/promises";
import { hostname } from "node:os";
import { config } from "./config.js";
export interface MailNode {
/** The server name the webmail connects to, from its configured URL. */
host: string;
/** What that name resolves to from this container, or null if it doesn't. */
address: string | null;
/** The address's PTR name: the node's own name. Null without a PTR. */
name: string | null;
}
export interface Nodes {
webmail: string;
mailServer: MailNode;
}
type Lookup = (host: string) => Promise<{ address: string }>;
type Reverse = (address: string) => Promise<string[]>;
const CACHE_MS = 60_000;
const cache = new Map<string, { node: MailNode; at: number }>();
export function webmailNode(): string {
return config.nodeName || hostname();
}
export async function mailNode(base: string, lookup: Lookup = dnsLookup, reverse: Reverse = dnsReverse, now = Date.now()): Promise<MailNode> {
const host = new URL(base).hostname;
const hit = cache.get(host);
if (hit && now - hit.at < CACHE_MS) return hit.node;
let address: string | null = null;
let name: string | null = null;
try {
address = (await lookup(host)).address;
} catch {
/* unresolvable: say so rather than fail the page */
}
if (address) {
try {
name = (await reverse(address))[0]?.replace(/\.$/, "") ?? null;
} catch {
/* no PTR */
}
}
const node = { host, address, name };
cache.set(host, { node, at: now });
return node;
}
export function clearNodeCache(): void {
cache.clear();
}
+5
View File
@@ -1740,6 +1740,11 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Zur Bestätigung {phrase} eingeben", "To confirm, type {phrase}": "Zur Bestätigung {phrase} eingeben",
"Turn off legacy protocols": "Ältere Mailprotokolle ausschalten", "Turn off legacy protocols": "Ältere Mailprotokolle ausschalten",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Ältere Mailprotokolle sind für Ihre Organisation ausgeschaltet. Nur {app} und JMAP-Apps können sich anmelden.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Ältere Mailprotokolle sind für Ihre Organisation ausgeschaltet. Nur {app} und JMAP-Apps können sich anmelden.",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Webmail-Knoten",
"Mail server node": "Mailserver-Knoten",
"{host} does not resolve": "{host} lässt sich nicht auflösen",
"unavailable": "nicht verfügbar",
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Einschätzung des Sprachmodells", "Language model's opinion": "Einschätzung des Sprachmodells",
"One of several signals the spam filter weighed": "Eines von mehreren Signalen, die der Spamfilter berücksichtigt hat", "One of several signals the spam filter weighed": "Eines von mehreren Signalen, die der Spamfilter berücksichtigt hat",
+5
View File
@@ -1713,6 +1713,11 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Para confirmar, escriba {phrase}", "To confirm, type {phrase}": "Para confirmar, escriba {phrase}",
"Turn off legacy protocols": "Desactivar los protocolos de correo heredados", "Turn off legacy protocols": "Desactivar los protocolos de correo heredados",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Los protocolos de correo heredados están desactivados para su organización. Solo {app} y las aplicaciones JMAP pueden iniciar sesión.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Los protocolos de correo heredados están desactivados para su organización. Solo {app} y las aplicaciones JMAP pueden iniciar sesión.",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Nodo del webmail",
"Mail server node": "Nodo del servidor de correo",
"{host} does not resolve": "{host} no se resuelve",
"unavailable": "no disponible",
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Opinión del modelo de lenguaje", "Language model's opinion": "Opinión del modelo de lenguaje",
"One of several signals the spam filter weighed": "Una de varias señales que el filtro de spam ha tenido en cuenta", "One of several signals the spam filter weighed": "Una de varias señales que el filtro de spam ha tenido en cuenta",
+5
View File
@@ -1718,6 +1718,11 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Pour confirmer, saisissez {phrase}", "To confirm, type {phrase}": "Pour confirmer, saisissez {phrase}",
"Turn off legacy protocols": "Désactiver les protocoles de messagerie historiques", "Turn off legacy protocols": "Désactiver les protocoles de messagerie historiques",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Les protocoles de messagerie historiques sont désactivés pour votre organisation. Seuls {app} et les applications JMAP peuvent se connecter.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Les protocoles de messagerie historiques sont désactivés pour votre organisation. Seuls {app} et les applications JMAP peuvent se connecter.",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Nœud du webmail",
"Mail server node": "Nœud du serveur de messagerie",
"{host} does not resolve": "{host} ne se résout pas",
"unavailable": "indisponible",
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Avis du modèle de langage", "Language model's opinion": "Avis du modèle de langage",
"One of several signals the spam filter weighed": "Un signal parmi d'autres pris en compte par le filtre antispam", "One of several signals the spam filter weighed": "Un signal parmi d'autres pris en compte par le filtre antispam",
+5
View File
@@ -1721,6 +1721,11 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "確認のため {phrase} と入力してください", "To confirm, type {phrase}": "確認のため {phrase} と入力してください",
"Turn off legacy protocols": "従来のメールプロトコルをオフにする", "Turn off legacy protocols": "従来のメールプロトコルをオフにする",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "組織では従来のメールプロトコルがオフになっています。サインインできるのは {app} と JMAP アプリのみです。", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "組織では従来のメールプロトコルがオフになっています。サインインできるのは {app} と JMAP アプリのみです。",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Webメールのノード",
"Mail server node": "メールサーバーのノード",
"{host} does not resolve": "{host} の名前解決ができません",
"unavailable": "利用できません",
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "言語モデルの見解", "Language model's opinion": "言語モデルの見解",
"One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ", "One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ",
+5
View File
@@ -1710,6 +1710,11 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Typ ter bevestiging {phrase}", "To confirm, type {phrase}": "Typ ter bevestiging {phrase}",
"Turn off legacy protocols": "Verouderde mailprotocollen uitschakelen", "Turn off legacy protocols": "Verouderde mailprotocollen uitschakelen",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Verouderde mailprotocollen zijn uitgeschakeld voor uw organisatie. Alleen {app} en JMAP-apps kunnen inloggen.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Verouderde mailprotocollen zijn uitgeschakeld voor uw organisatie. Alleen {app} en JMAP-apps kunnen inloggen.",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Webmailnode",
"Mail server node": "Mailservernode",
"{host} does not resolve": "{host} wordt niet omgezet",
"unavailable": "niet beschikbaar",
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Oordeel van het taalmodel", "Language model's opinion": "Oordeel van het taalmodel",
"One of several signals the spam filter weighed": "Een van meerdere signalen die het spamfilter heeft meegewogen", "One of several signals the spam filter weighed": "Een van meerdere signalen die het spamfilter heeft meegewogen",
+5
View File
@@ -1716,6 +1716,11 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Para confirmar, digite {phrase}", "To confirm, type {phrase}": "Para confirmar, digite {phrase}",
"Turn off legacy protocols": "Desativar os protocolos de e-mail legados", "Turn off legacy protocols": "Desativar os protocolos de e-mail legados",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Os protocolos de e-mail legados estão desativados para sua organização. Só {app} e aplicativos JMAP podem entrar.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Os protocolos de e-mail legados estão desativados para sua organização. Só {app} e aplicativos JMAP podem entrar.",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Nó do webmail",
"Mail server node": "Nó do servidor de e-mail",
"{host} does not resolve": "{host} não resolve",
"unavailable": "indisponível",
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Opinião do modelo de linguagem", "Language model's opinion": "Opinião do modelo de linguagem",
"One of several signals the spam filter weighed": "Um dos vários sinais considerados pelo filtro de spam", "One of several signals the spam filter weighed": "Um dos vários sinais considerados pelo filtro de spam",
+5
View File
@@ -1715,6 +1715,11 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Для подтверждения введите {phrase}", "To confirm, type {phrase}": "Для подтверждения введите {phrase}",
"Turn off legacy protocols": "Отключить устаревшие почтовые протоколы", "Turn off legacy protocols": "Отключить устаревшие почтовые протоколы",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Устаревшие почтовые протоколы отключены для вашей организации. Входить могут только {app} и приложения JMAP.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Устаревшие почтовые протоколы отключены для вашей организации. Входить могут только {app} и приложения JMAP.",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Узел веб-почты",
"Mail server node": "Узел почтового сервера",
"{host} does not resolve": "{host} не разрешается",
"unavailable": "недоступно",
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Мнение языковой модели", "Language model's opinion": "Мнение языковой модели",
"One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр", "One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр",
+5
View File
@@ -1709,6 +1709,11 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Для підтвердження введіть {phrase}", "To confirm, type {phrase}": "Для підтвердження введіть {phrase}",
"Turn off legacy protocols": "Вимкнути застарілі поштові протоколи", "Turn off legacy protocols": "Вимкнути застарілі поштові протоколи",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Застарілі поштові протоколи вимкнено для вашої організації. Входити можуть лише {app} і програми JMAP.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Застарілі поштові протоколи вимкнено для вашої організації. Входити можуть лише {app} і програми JMAP.",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "Вузол вебпошти",
"Mail server node": "Вузол поштового сервера",
"{host} does not resolve": "{host} не розпізнається",
"unavailable": "недоступно",
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Думка мовної моделі", "Language model's opinion": "Думка мовної моделі",
"One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр", "One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр",
+5
View File
@@ -1720,6 +1720,11 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "请输入 {phrase} 以确认", "To confirm, type {phrase}": "请输入 {phrase} 以确认",
"Turn off legacy protocols": "关闭传统邮件协议", "Turn off legacy protocols": "关闭传统邮件协议",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "您的组织已关闭传统邮件协议。只有 {app} 和 JMAP 应用可以登录。", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "您的组织已关闭传统邮件协议。只有 {app} 和 JMAP 应用可以登录。",
// ── About: webmail and mail server nodes (inbuxa) ──────────────
"Webmail node": "网页邮箱节点",
"Mail server node": "邮件服务器节点",
"{host} does not resolve": "无法解析 {host}",
"unavailable": "不可用",
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "语言模型的判断", "Language model's opinion": "语言模型的判断",
"One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一", "One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一",
+23 -1
View File
@@ -1,15 +1,35 @@
import { useEffect, useState } from "react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { useAppName } from "@/lib/brand"; import { useAppName } from "@/lib/brand";
import { client } from "@/jmap/client"; import { apiFetch, client } from "@/jmap/client";
import { APP_VERSION } from "@/lib/version"; import { APP_VERSION } from "@/lib/version";
import { DEFAULT_SOURCE_URL } from "@/lib/source"; import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { withBase } from "@/lib/basePath"; import { withBase } from "@/lib/basePath";
import { t, tNode } from "@/lib/i18n"; import { t, tNode } from "@/lib/i18n";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
/** ihasmail-inbuxa: which webmail node answered and which mail node it talks to (server/src/nodes.ts). */
interface Nodes {
webmail: string;
mailServer: { host: string; address: string | null; name: string | null };
}
function mailNodeLabel(m: Nodes["mailServer"]): string {
if (!m.address) return t("{host} does not resolve", { host: m.host });
return m.name ? `${m.name} (${m.address})` : m.address;
}
export function AboutSettings() { export function AboutSettings() {
const appName = useAppName(); const appName = useAppName();
const session = useSession((s) => s.session); const session = useSession((s) => s.session);
// Fetched on every visit, not cached with the session: when troubleshooting,
// the question is which nodes are in use now.
const [nodes, setNodes] = useState<Nodes | null | "error">(null);
useEffect(() => {
apiFetch<Nodes>("/api/about/nodes").then(setNodes).catch(() => setNodes("error"));
}, []);
const nodeCell = (v: (n: Nodes) => string) =>
nodes === null ? t("Loading…") : nodes === "error" ? t("unavailable") : <span className="mono notranslate" translate="no">{v(nodes)}</span>;
const caps = Object.keys(session?.capabilities ?? {}); const caps = Object.keys(session?.capabilities ?? {});
// A deployment running modified code should offer its own source, not ours. // A deployment running modified code should offer its own source, not ours.
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL; const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
@@ -36,6 +56,8 @@ export function AboutSettings() {
<tbody> <tbody>
<tr><td>{t("Signed in as")}</td><td>{session?.username}</td></tr> <tr><td>{t("Signed in as")}</td><td>{session?.username}</td></tr>
<tr><td>{t("Mail server")}</td><td className="notranslate" translate="no">inbuxa</td></tr> <tr><td>{t("Mail server")}</td><td className="notranslate" translate="no">inbuxa</td></tr>
<tr><td>{t("Webmail node")}</td><td>{nodeCell((n) => n.webmail)}</td></tr>
<tr><td>{t("Mail server node")}</td><td>{nodeCell((n) => mailNodeLabel(n.mailServer))}</td></tr>
<tr><td>{t("Accounts")}</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr> <tr><td>{t("Accounts")}</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
<tr><td>{t("Max upload")}</td><td>{t("{size} MB", { size: Math.round(client.maxSizeUpload / 1048576) })}</td></tr> <tr><td>{t("Max upload")}</td><td>{t("{size} MB", { size: Math.round(client.maxSizeUpload / 1048576) })}</td></tr>
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr> <tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr>