About: name the webmail node and the mail server node #16

Merged
jcoffey-dev merged 1 commits from fork/about-node-identity into main 2026-09-26 04:05:46 +00:00
14 changed files with 189 additions and 1 deletions

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>