diff --git a/server/src/app.ts b/server/src/app.ts index 11603ef..ceb85c2 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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 { icsProxyHandler } from "./icsproxy.js"; import { staticHandler } from "./static.js"; +import { mailNode, webmailNode } from "./nodes.js"; type Env = { Variables: { session: LiveSession } }; @@ -609,6 +610,12 @@ export function createApp(basePath = config.basePath): Hono { 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) => { const session = c.get("session"); return c.json({ current: session.id, sessions: sessions.listForUser(session.account) }); diff --git a/server/src/config.ts b/server/src/config.ts index 5b6b4cd..fbeedbb 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -296,6 +296,12 @@ export const config = { * such a tree, so the default is INBUXA's fork. */ 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"), port: int("PORT", 8080), /** diff --git a/server/src/nodes.test.ts b/server/src/nodes.test.ts new file mode 100644 index 0000000..1542376 --- /dev/null +++ b/server/src/nodes.test.ts @@ -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); +}); diff --git a/server/src/nodes.ts b/server/src/nodes.ts new file mode 100644 index 0000000..d1c2c87 --- /dev/null +++ b/server/src/nodes.ts @@ -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; + +const CACHE_MS = 60_000; +const cache = new Map(); + +export function webmailNode(): string { + return config.nodeName || hostname(); +} + +export async function mailNode(base: string, lookup: Lookup = dnsLookup, reverse: Reverse = dnsReverse, now = Date.now()): Promise { + 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(); +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 75992a2..27cc21a 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1740,6 +1740,11 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Zur Bestätigung {phrase} eingeben", "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.", + // ── 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) ────────── "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", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 0db2617..531ac1f 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1713,6 +1713,11 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Para confirmar, escriba {phrase}", "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.", + // ── 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) ────────── "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", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 5836f0a..5985751 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1718,6 +1718,11 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Pour confirmer, saisissez {phrase}", "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.", + // ── 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) ────────── "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", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index dc4f986..eb400b9 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1721,6 +1721,11 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "確認のため {phrase} と入力してください", "Turn off legacy protocols": "従来のメールプロトコルをオフにする", "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) ────────── "Language model's opinion": "言語モデルの見解", "One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 9e73caa..9e2c028 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1710,6 +1710,11 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Typ ter bevestiging {phrase}", "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.", + // ── 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) ────────── "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", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 3dea47e..c851fc6 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1716,6 +1716,11 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Para confirmar, digite {phrase}", "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.", + // ── 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) ────────── "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", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 2e31394..623d52c 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1715,6 +1715,11 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Для подтверждения введите {phrase}", "Turn off legacy protocols": "Отключить устаревшие почтовые протоколы", "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) ────────── "Language model's opinion": "Мнение языковой модели", "One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 98908ed..a4347b6 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1709,6 +1709,11 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Для підтвердження введіть {phrase}", "Turn off legacy protocols": "Вимкнути застарілі поштові протоколи", "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) ────────── "Language model's opinion": "Думка мовної моделі", "One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 850799a..db593c7 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1720,6 +1720,11 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "请输入 {phrase} 以确认", "Turn off legacy protocols": "关闭传统邮件协议", "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) ────────── "Language model's opinion": "语言模型的判断", "One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一", diff --git a/web/src/views/settings/AboutSettings.tsx b/web/src/views/settings/AboutSettings.tsx index 432f425..357df1e 100644 --- a/web/src/views/settings/AboutSettings.tsx +++ b/web/src/views/settings/AboutSettings.tsx @@ -1,15 +1,35 @@ +import { useEffect, useState } from "react"; import { useSession } from "@/store/session"; import { useAppName } from "@/lib/brand"; -import { client } from "@/jmap/client"; +import { apiFetch, client } from "@/jmap/client"; import { APP_VERSION } from "@/lib/version"; import { DEFAULT_SOURCE_URL } from "@/lib/source"; import { withBase } from "@/lib/basePath"; import { t, tNode } from "@/lib/i18n"; 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() { const appName = useAppName(); 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(null); + useEffect(() => { + apiFetch("/api/about/nodes").then(setNodes).catch(() => setNodes("error")); + }, []); + const nodeCell = (v: (n: Nodes) => string) => + nodes === null ? t("Loading…") : nodes === "error" ? t("unavailable") : {v(nodes)}; const caps = Object.keys(session?.capabilities ?? {}); // A deployment running modified code should offer its own source, not ours. const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL; @@ -36,6 +56,8 @@ export function AboutSettings() { {t("Signed in as")}{session?.username} {t("Mail server")}inbuxa + {t("Webmail node")}{nodeCell((n) => n.webmail)} + {t("Mail server node")}{nodeCell((n) => mailNodeLabel(n.mailServer))} {t("Accounts")}{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")} {t("Max upload")}{t("{size} MB", { size: Math.round(client.maxSizeUpload / 1048576) })} {t("Image privacy proxy")}{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}