About: name the webmail node and the mail server node
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:
1 parent
119548090e
commit
6b44705bfd
14 files changed
+189
-1
No files matched your search
@@ -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<Env> {
|
||||
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) });
|
||||
|
||||
@@ -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),
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "迷惑メールフィルターが考慮した複数の判断材料のひとつ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Один из нескольких признаков, которые учёл спам-фильтр",
|
||||
|
||||
@@ -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": "Одна з кількох ознак, які врахував спам-фільтр",
|
||||
|
||||
@@ -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": "垃圾邮件过滤考虑的多个信号之一",
|
||||
|
||||
@@ -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<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 ?? {});
|
||||
// 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() {
|
||||
<tbody>
|
||||
<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("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("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>
|
||||
|
||||
Reference in new issue
Block a user