Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
654d298a18 | ||
|
|
dfbfc38258 | ||
|
|
8fef2c208d | ||
|
|
66673bc9d1 | ||
|
|
41fb7875d0 | ||
|
|
3507a21599 | ||
|
|
6b44705bfd | ||
|
|
119548090e | ||
|
|
e4926cfa7d | ||
|
|
c0c892dd33 | ||
|
|
f5dd4e5537 | ||
|
|
e7ee09d228 | ||
|
|
1752276229 | ||
|
|
acd9cff4ea | ||
|
|
413ece3bca | ||
|
|
4bbfd7d455 | ||
|
|
d0832013fe | ||
|
|
b79fdb8bab | ||
|
|
8d717e0037 |
No files matched your search
@@ -47,7 +47,7 @@ after(() => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Stalwart advertises `urn:stalwart:jmap` only per-account, never in the
|
||||
* Stalwart advertises `urn:inbuxa:jmap:registry` only per-account, never in the
|
||||
* session-level capabilities. Looking for it at the top level alone reported
|
||||
* every real 0.16 server as older than 0.16 — and now that the same check
|
||||
* decides whether a sign-in is allowed at all, that mistake would lock
|
||||
@@ -57,8 +57,8 @@ test("the session is accepted on a server that advertises the registry per-accou
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ihasmail.server.edition, "oss");
|
||||
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
|
||||
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
|
||||
assert.equal(res.body.capabilities["urn:inbuxa:jmap:registry"], undefined, "not where a client would first look");
|
||||
assert.ok("urn:inbuxa:jmap:registry" in res.body.primaryAccounts, "but here, as on a real server");
|
||||
});
|
||||
|
||||
test("the registry reports an account with nothing set up yet", async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.
|
||||
* the registry is known to be there.
|
||||
*/
|
||||
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
const STALWART_CAP = "urn:inbuxa:jmap:registry";
|
||||
const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||
/** Stalwart's id for a singleton object; the number it encodes spells this. */
|
||||
const SINGLETON = "singleton";
|
||||
|
||||
@@ -44,7 +44,7 @@ test("locales that carry no language are dropped, not passed through", () => {
|
||||
|
||||
test("a server without the registry is not asked for anything", async () => {
|
||||
// Sign-in refuses these, so getAccountInfo should never reach the wire for
|
||||
// one - and must not, since a server that cannot parse `urn:stalwart:jmap`
|
||||
// one - and must not, since a server that cannot parse `urn:inbuxa:jmap:registry`
|
||||
// fails the whole request rather than the one call.
|
||||
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
||||
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
|
||||
@@ -57,7 +57,7 @@ test("no capabilities at all is treated the same way", async () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Where Stalwart actually advertises `urn:stalwart:jmap`.
|
||||
* Where Stalwart actually advertises `urn:inbuxa:jmap:registry`.
|
||||
*
|
||||
* Not in the session-level `capabilities`: `Session::new` builds those from a
|
||||
* fixed list that has never carried this capability, in any 0.16.x. It is
|
||||
@@ -70,7 +70,7 @@ test("no capabilities at all is treated the same way", async () => {
|
||||
* This check now decides whether a sign-in is allowed at all, so getting it
|
||||
* wrong would lock every user out of a perfectly good server.
|
||||
*/
|
||||
const STALWART = "urn:stalwart:jmap";
|
||||
const STALWART = "urn:inbuxa:jmap:registry";
|
||||
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
|
||||
|
||||
test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => {
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -278,7 +278,7 @@ if (oauthClientSecret && !publicUrl) {
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "INBUXA"),
|
||||
appName: env("APP_NAME", "inbuxa"),
|
||||
settingsPolicy: readSettingsPolicy(),
|
||||
/**
|
||||
* What this build calls itself: `2.16.57`. Set by the image build from
|
||||
@@ -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),
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@ const PORT = 18799;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap
|
||||
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:inbuxa:jmap:registry
|
||||
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-login-guard";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../we
|
||||
|
||||
export const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
/**
|
||||
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
|
||||
* Omit `urn:inbuxa:jmap:registry` from the session, so a sign-in can be tested
|
||||
* against a server ihasmail does not support. This is only that: the rest of
|
||||
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
|
||||
* support for it.
|
||||
|
||||
@@ -40,10 +40,10 @@ export function putBlob(data: Buffer | string, type: string): string {
|
||||
export const people = [
|
||||
["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"],
|
||||
["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"],
|
||||
["Stalwart Labs", "hello@stalw.art"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
|
||||
["inbuxa", "hello@inbuxa.org"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
|
||||
];
|
||||
export const subjects = [
|
||||
"Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to Stalwart!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff",
|
||||
"Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to inbuxa!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff",
|
||||
"Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?",
|
||||
"Reminder: dentist appointment", "Flight confirmation – BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in",
|
||||
];
|
||||
@@ -169,8 +169,8 @@ export const STYLED_MARKETING_HTML = `<html><head><style>
|
||||
export function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
|
||||
const id = `e${seq.counter++}`;
|
||||
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
|
||||
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag & drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
|
||||
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
|
||||
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://inbuxa.org">Drag & drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
|
||||
const textBlob = putBlob(text, "text/plain");
|
||||
const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html");
|
||||
const attachments: Obj[] = [];
|
||||
|
||||
@@ -60,8 +60,8 @@ const session = () => ({
|
||||
* the only way this stays honest about what can be inferred from a
|
||||
* capability, which is nothing.
|
||||
*/
|
||||
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
|
||||
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:inbuxa:jmap:registry": {} }) } } },
|
||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:inbuxa:jmap:registry": ACCOUNT }) },
|
||||
username: USER,
|
||||
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
||||
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
|
||||
@@ -103,7 +103,7 @@ export const server = createServer(async (req, res) => {
|
||||
// call that wanted it - which is why an over-eager `using` is so damaging.
|
||||
// Stalwart decides this by parsing the urn, not by looking it up in the
|
||||
// session, so a capability it hands out per-account is still usable here:
|
||||
// `urn:stalwart:jmap` never appears in the session-level capabilities and
|
||||
// `urn:inbuxa:jmap:registry` never appears in the session-level capabilities and
|
||||
// the registry calls that name it work all the same.
|
||||
const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]);
|
||||
const unknown = (body.using ?? []).find((u) => !known.has(u));
|
||||
@@ -204,8 +204,8 @@ export const server = createServer(async (req, res) => {
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
}).listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
|
||||
console.log(`[mock-stalwart] run the app with: MAIL_SERVER_URL=http://127.0.0.1:${PORT} npm run dev`);
|
||||
console.log(`[mock-server] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
|
||||
console.log(`[mock-server] run the app with: MAIL_SERVER_URL=http://127.0.0.1:${PORT} npm run dev`);
|
||||
});
|
||||
|
||||
// Periodically inject a new inbox email to demo push
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { clearNodeCache, mailNode, ptrName } 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);
|
||||
});
|
||||
|
||||
test("the PTR name is built for IPv4 and IPv6 alike", () => {
|
||||
assert.equal(ptrName("192.0.2.52"), "52.2.0.192.in-addr.arpa");
|
||||
assert.equal(
|
||||
ptrName("2001:db8::25"),
|
||||
"5.2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa",
|
||||
);
|
||||
assert.equal(ptrName("::1"), "1" + ".0".repeat(31) + ".ip6.arpa");
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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, resolvePtr } from "node:dns/promises";
|
||||
import { isIPv4 } from "node:net";
|
||||
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[]>;
|
||||
|
||||
/**
|
||||
* The reverse-lookup name for an address: 1.2.0.192.in-addr.arpa, or the
|
||||
* nibble form under ip6.arpa. Asked for directly, because `dns.reverse` came
|
||||
* back empty in the image while the resolver answered the PTR (2026-09-26).
|
||||
*/
|
||||
export function ptrName(address: string): string {
|
||||
if (isIPv4(address)) return `${address.split(".").reverse().join(".")}.in-addr.arpa`;
|
||||
const [head = "", tail = ""] = address.split("::");
|
||||
const groups = (part: string) => (part ? part.split(":") : []);
|
||||
const h = groups(head), t = groups(tail);
|
||||
const full = [...h, ...Array(8 - h.length - t.length).fill("0"), ...t];
|
||||
return `${full.map((g) => g.padStart(4, "0")).join("").split("").reverse().join(".")}.ip6.arpa`;
|
||||
}
|
||||
|
||||
const dnsReverse: Reverse = (address) => resolvePtr(ptrName(address));
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -178,14 +178,14 @@ export function forgetUpstreamSession(sessionId: string): void {
|
||||
/* Account locale */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
const STALWART_CAP = "urn:inbuxa:jmap:registry";
|
||||
const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||
|
||||
/**
|
||||
* Whether this server has Stalwart's JMAP registry — the `x:` objects that
|
||||
* carry credentials, account settings and the newer FileNode shape.
|
||||
*
|
||||
* `urn:stalwart:jmap` is the marker, but **not** in the session-level
|
||||
* `urn:inbuxa:jmap:registry` is the marker, but **not** in the session-level
|
||||
* `capabilities`, which is where a JMAP client would naturally look. Stalwart
|
||||
* builds that list from a fixed set that has never included this capability;
|
||||
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" />
|
||||
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>INBUXA</title>
|
||||
<title>inbuxa</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "INBUXA",
|
||||
"short_name": "INBUXA",
|
||||
"description": "INBUXA webmail",
|
||||
"name": "inbuxa",
|
||||
"short_name": "inbuxa",
|
||||
"description": "inbuxa webmail",
|
||||
"_comment": "JSON has no comments, so: every URL below is relative on purpose. Manifest members resolve against the manifest's own address, so these follow BASE_PATH with nothing substituted into them at build time. Root-absolute values pinned the installed app, its scope and its shortcuts to the domain root whatever the mount was.",
|
||||
"_comment_id": "There is deliberately no `id`. It is the one member NOT resolved against this file's address -- the spec resolves it against the origin of start_url, so `./`, `mail` and `/mail` all mean the same thing at the domain root and none of them can name a subpath mount. Adding one would therefore break the same thing the note above describes. Worse, the default id IS start_url, which is already mount-correct: writing an id now would give every installed copy a new identity and orphan it as a second app rather than updating it. If one is ever wanted it has to be substituted at build time from BASE_PATH, and the changeover costs everybody their install.",
|
||||
"start_url": "mail",
|
||||
|
||||
@@ -20,7 +20,7 @@ export const CAP = {
|
||||
} as const;
|
||||
|
||||
/** Stalwart's own capability, which carries its `x:` registry methods. */
|
||||
export const STALWART_CAP = "urn:stalwart:jmap";
|
||||
export const STALWART_CAP = "urn:inbuxa:jmap:registry";
|
||||
|
||||
/** INBUXA's own capability (contract C-1), on the signed-in account. */
|
||||
export const INBUXA_CAP = "urn:inbuxa:jmap";
|
||||
@@ -155,7 +155,7 @@ export class JmapClient {
|
||||
* Whether the server carries a capability at all, wherever it chose to
|
||||
* advertise it.
|
||||
*
|
||||
* Stalwart hands `urn:stalwart:jmap` out per-account rather than putting it
|
||||
* Stalwart hands `urn:inbuxa:jmap:registry` out per-account rather than putting it
|
||||
* in the session-level `capabilities`, so `hasCapability` alone reports every
|
||||
* real 0.16 server as though it were older. Look in all three places.
|
||||
*/
|
||||
|
||||
@@ -268,6 +268,8 @@ export interface Email {
|
||||
"header:Received:asText:all"?: string[] | null;
|
||||
"header:X-Spam-Status:asText"?: string | null;
|
||||
"header:X-Spam-Result:asText"?: string | null;
|
||||
/** inbuxa: the language model's opinion, when AI spam classification is on (lib/llmOpinion). */
|
||||
"header:X-Spam-LLM:asText"?: string | null;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LLM_HEADER_PROP, llmOpinion, parseLlmOpinion } from "@/lib/llmOpinion";
|
||||
|
||||
/*
|
||||
* The header as inbuxa-server writes it (crates/features/src/ai/answer.rs):
|
||||
* `X-Spam-LLM: <TAG>`, optionally followed by the explanation in one pair of
|
||||
* parentheses, folded at 78 columns.
|
||||
*/
|
||||
describe("parseLlmOpinion", () => {
|
||||
it("reads category, confidence and explanation", () => {
|
||||
expect(parseLlmOpinion("LLM_UNSOLICITED_HIGH (Promotes a product the reader never asked about)")).toEqual({
|
||||
tag: "LLM_UNSOLICITED_HIGH",
|
||||
category: "Unsolicited",
|
||||
confidence: "High",
|
||||
explanation: "Promotes a product the reader never asked about",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a tag with no confidence and no explanation", () => {
|
||||
expect(parseLlmOpinion("LLM_LEGITIMATE")).toEqual({
|
||||
tag: "LLM_LEGITIMATE",
|
||||
category: "Legitimate",
|
||||
confidence: null,
|
||||
explanation: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an operator's multi-word category whole", () => {
|
||||
const o = parseLlmOpinion("LLM_COLD_OUTREACH_MEDIUM");
|
||||
expect(o?.category).toBe("Cold outreach");
|
||||
expect(o?.confidence).toBe("Medium");
|
||||
// An unknown last word is part of the category, not a confidence.
|
||||
expect(parseLlmOpinion("LLM_COLD_OUTREACH")?.category).toBe("Cold outreach");
|
||||
expect(parseLlmOpinion("LLM_COLD_OUTREACH")?.confidence).toBeNull();
|
||||
});
|
||||
|
||||
it("unfolds a folded header and keeps inner parentheses", () => {
|
||||
const o = parseLlmOpinion("LLM_HARMFUL_LOW (Asks for a password\r\n (urgently) via a link)");
|
||||
expect(o?.explanation).toBe("Asks for a password (urgently) via a link");
|
||||
});
|
||||
|
||||
it("returns null for anything that isn't the server's tag", () => {
|
||||
for (const raw of [null, undefined, "", " ", "Yes, score=6.7", "LLM_", "llm_unsolicited_high", "X LLM_SPAM"]) {
|
||||
expect(parseLlmOpinion(raw), String(raw)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the JMAP property a full message carries", () => {
|
||||
expect(llmOpinion({ [LLM_HEADER_PROP]: "LLM_LEGITIMATE_HIGH" })?.category).toBe("Legitimate");
|
||||
expect(llmOpinion({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,9 @@ import { useSession } from "@/store/session";
|
||||
* One constant rather than the string written out at each of them, because
|
||||
* three copies of a default is how two of them end up stale.
|
||||
*/
|
||||
// ihasmail-inbuxa: INBUXA's webmail goes by INBUXA, so it can't be taken for
|
||||
// ihasmail-inbuxa: inbuxa's webmail goes by inbuxa, so it can't be taken for
|
||||
// public ihasmail. APP_NAME still names a deployment whatever it likes.
|
||||
export const DEFAULT_APP_NAME = "INBUXA";
|
||||
export const DEFAULT_APP_NAME = "inbuxa";
|
||||
/**
|
||||
* What this instance calls itself, right now.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* inbuxa: the language model's opinion, read back off the message.
|
||||
*
|
||||
* When the server's AI spam classification is on (inbuxa-server,
|
||||
* docs/spec/features/ai-spam-classification.md), it writes the model's answer
|
||||
* into an `X-Spam-LLM` header at delivery:
|
||||
*
|
||||
* X-Spam-LLM: LLM_UNSOLICITED_HIGH (Promotes a product the reader never asked about)
|
||||
*
|
||||
* a tag, then optionally the model's explanation in parentheses. The tag is
|
||||
* `LLM_` + category, or `LLM_` + category + `_` + confidence, uppercased with
|
||||
* anything outside A-Z and 0-9 turned into `_`. The explanation is already
|
||||
* sanitized by the server and may arrive as encoded words, which the JMAP
|
||||
* `asText` form decodes.
|
||||
*
|
||||
* Like `spamScore`, nothing here judges anything: it only reads what the
|
||||
* server wrote. It is one signal the spam filter weighed among many, and the
|
||||
* UI says so.
|
||||
*/
|
||||
|
||||
/** The JMAP property that carries the header, decoded and unfolded. */
|
||||
export const LLM_HEADER_PROP = "header:X-Spam-LLM:asText" as const;
|
||||
|
||||
/**
|
||||
* Confidence words the fork's default prompt uses. A tag ending in one of
|
||||
* these is read as category + confidence; anything else is all category,
|
||||
* since an operator's own categories may contain underscores.
|
||||
*/
|
||||
const CONFIDENCES = new Set(["LOW", "MEDIUM", "HIGH"]);
|
||||
|
||||
export interface LlmOpinion {
|
||||
/** The tag as the server wrote it, e.g. `LLM_UNSOLICITED_HIGH`. */
|
||||
tag: string;
|
||||
/** Readable category, e.g. `Unsolicited`. */
|
||||
category: string;
|
||||
/** Readable confidence, e.g. `High`, where the tag carried one. */
|
||||
confidence: string | null;
|
||||
/** The model's own explanation, as plain text, where there is one. */
|
||||
explanation: string | null;
|
||||
}
|
||||
|
||||
/** `UNSOLICITED_BULK` -> `Unsolicited bulk`. */
|
||||
function readable(words: string[]): string {
|
||||
const s = words.join(" ").toLowerCase();
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/** Headers arrive folded, so tabs and newlines are whitespace like any other. */
|
||||
function flatten(v: string | null | undefined): string {
|
||||
return (v ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function parseLlmOpinion(raw: string | null | undefined): LlmOpinion | null {
|
||||
const s = flatten(raw);
|
||||
const m = /^(LLM_[A-Z0-9_]+)(?:\s+(.*))?$/.exec(s);
|
||||
if (!m) return null;
|
||||
const tag = m[1]!;
|
||||
const parts = tag.slice("LLM_".length).split("_").filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
let confidence: string | null = null;
|
||||
if (parts.length > 1 && CONFIDENCES.has(parts[parts.length - 1]!)) {
|
||||
confidence = readable([parts.pop()!]);
|
||||
}
|
||||
|
||||
let explanation: string | null = null;
|
||||
const rest = (m[2] ?? "").trim();
|
||||
if (rest) {
|
||||
// The server wraps the explanation in one pair of parentheses.
|
||||
const inner = rest.startsWith("(") && rest.endsWith(")") ? rest.slice(1, -1).trim() : rest;
|
||||
explanation = inner || null;
|
||||
}
|
||||
|
||||
return { tag, category: readable(parts), confidence, explanation };
|
||||
}
|
||||
|
||||
/** The opinion on a message, if the server recorded one. */
|
||||
export function llmOpinion(email: { [LLM_HEADER_PROP]?: string | null }): LlmOpinion | null {
|
||||
return parseLlmOpinion(email[LLM_HEADER_PROP]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { withBase } from "../basePath";
|
||||
|
||||
let baseTitle = "INBUXA";
|
||||
let baseTitle = "inbuxa";
|
||||
let faviconCanvas: HTMLCanvasElement | null = null;
|
||||
let baseFavicon: HTMLImageElement | null = null;
|
||||
|
||||
|
||||
+16
-4
@@ -147,8 +147,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "Die Zahlen, die Ihre Rolle sehen darf, so wie der Server sie meldet.",
|
||||
"Nothing to show": "Nichts anzuzeigen",
|
||||
"Could not be loaded": "Konnte nicht geladen werden",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Detaillierte Metriken, die Zustellwarteschlange, Protokolle und Servereinstellungen finden Sie in INBUXA Admin.",
|
||||
"Open INBUXA Admin": "INBUXA Admin öffnen",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Detaillierte Metriken, die Zustellwarteschlange, Protokolle und Servereinstellungen finden Sie in inbuxa Admin.",
|
||||
"Open inbuxa Admin": "inbuxa Admin öffnen",
|
||||
"Default group role": "Standardrolle für Gruppen",
|
||||
"A group needs an address.": "Eine Gruppe braucht eine Adresse.",
|
||||
"New group": "Neue Gruppe",
|
||||
@@ -220,7 +220,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Der Mailserver vergibt diese Rolle standardmäßig an {kinds}. Eine Änderung betrifft alle, die sie auf diesem Weg haben.",
|
||||
"Builds on": "Baut auf",
|
||||
"Permissions": "Berechtigungen",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Der Mailserver vergibt diese Rolle standardmäßig, daher kann sie nicht gelöscht werden. Ändern Sie zuerst die Standardwerte in INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Der Mailserver vergibt diese Rolle standardmäßig, daher kann sie nicht gelöscht werden. Ändern Sie zuerst die Standardwerte in inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "Diese Rolle hat Berechtigungen, die Ihre nicht hat.",
|
||||
"Create role": "Rolle anlegen",
|
||||
"builds on this one": "baut auf dieser auf",
|
||||
@@ -1059,7 +1059,6 @@ export const catalog: Catalog = {
|
||||
"Select a conversation to read it here · Press {key} for shortcuts": "Wählen Sie eine Konversation, um sie hier zu lesen · {key} für Tastenkürzel",
|
||||
"Select a message to read it here · Press {key} for shortcuts": "Wählen Sie eine Nachricht, um sie hier zu lesen · {key} für Tastenkürzel",
|
||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tipp: Drücken Sie {key} auf einer Konversation, um Labels zu vergeben. Suchen Sie mit {operator}.",
|
||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Eine schnelle, freundliche Open-Source-Webmail für {server}, auf JMAP aufgebaut.",
|
||||
"Defaults for the calendar views and new events.": "Vorgaben für die Kalenderansichten und neue Termine.",
|
||||
"Replies will go to this address instead of the From address": "Antworten gehen an diese Adresse statt an die Absenderadresse",
|
||||
"Replies to mail sent from this identity go here instead of the From address.": "Antworten auf Nachrichten von dieser Identität gehen hierhin statt an die Absenderadresse.",
|
||||
@@ -1740,6 +1739,19 @@ 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: inbuxa, the suite ────────────────────────────────────
|
||||
"{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.": "{inbuxa} ist eine komplette Mail-Suite: ein Mailserver, die Konsole, mit der er verwaltet wird, und dieses Webmail – jedes ein eigenes Programm, gemeinsam installiert und frei unter der AGPL.",
|
||||
"The suite": "Die Suite",
|
||||
"Administration console": "Verwaltungskonsole",
|
||||
"Webmail": "Webmail",
|
||||
// ── 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",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
+16
-4
@@ -139,8 +139,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "Las cifras que su rol puede ver, tal como las informa el servidor.",
|
||||
"Nothing to show": "Nada que mostrar",
|
||||
"Could not be loaded": "No se pudo cargar",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Las métricas detalladas, la cola de entrega, los registros y los ajustes del servidor están en INBUXA Admin.",
|
||||
"Open INBUXA Admin": "Abrir INBUXA Admin",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Las métricas detalladas, la cola de entrega, los registros y los ajustes del servidor están en inbuxa Admin.",
|
||||
"Open inbuxa Admin": "Abrir inbuxa Admin",
|
||||
"Default group role": "Rol de grupo predeterminado",
|
||||
"A group needs an address.": "Un grupo necesita una dirección.",
|
||||
"New group": "Nuevo grupo",
|
||||
@@ -212,7 +212,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "El servidor de correo asigna este rol de forma predeterminada a {kinds}. Un cambio aquí afecta a todos los que lo tienen así.",
|
||||
"Builds on": "Se basa en",
|
||||
"Permissions": "Permisos",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "El servidor de correo asigna este rol de forma predeterminada, así que no se puede eliminar. Cambie primero los valores predeterminados en INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "El servidor de correo asigna este rol de forma predeterminada, así que no se puede eliminar. Cambie primero los valores predeterminados en inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "Este rol tiene permisos que el suyo no tiene.",
|
||||
"Create role": "Crear rol",
|
||||
"builds on this one": "se basa en este",
|
||||
@@ -1121,7 +1121,6 @@ export const catalog: Catalog = {
|
||||
"Select a conversation to read it here · Press {key} for shortcuts": "Seleccione una conversación para leerla aquí · {key} para los atajos",
|
||||
"Select a message to read it here · Press {key} for shortcuts": "Seleccione un mensaje para leerlo aquí · {key} para los atajos",
|
||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Consejo: pulse {key} sobre una conversación para aplicar etiquetas. Busque con {operator}.",
|
||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Un webmail libre, rápido y agradable para {server}, construido sobre JMAP.",
|
||||
"Defaults for the calendar views and new events.": "Valores predeterminados de las vistas del calendario y de los eventos nuevos.",
|
||||
"Replies will go to this address instead of the From address": "Las respuestas irán a esta dirección en lugar de a la del remitente",
|
||||
"Replies to mail sent from this identity go here instead of the From address.": "Las respuestas al correo enviado desde esta identidad llegan aquí en lugar de a la dirección del remitente.",
|
||||
@@ -1713,6 +1712,19 @@ 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: inbuxa, the suite ────────────────────────────────────
|
||||
"{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.": "{inbuxa} es una suite de correo completa: un servidor de correo, la consola que lo administra y este webmail, cada uno un programa propio, instalados juntos y libres bajo la AGPL.",
|
||||
"The suite": "La suite",
|
||||
"Administration console": "Consola de administración",
|
||||
"Webmail": "Webmail",
|
||||
// ── 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",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
+16
-4
@@ -144,8 +144,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "Les chiffres que votre rôle permet de voir, tels que le serveur les indique.",
|
||||
"Nothing to show": "Rien à afficher",
|
||||
"Could not be loaded": "Chargement impossible",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Les métriques détaillées, la file de distribution, les journaux et les réglages du serveur se trouvent dans INBUXA Admin.",
|
||||
"Open INBUXA Admin": "Ouvrir INBUXA Admin",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Les métriques détaillées, la file de distribution, les journaux et les réglages du serveur se trouvent dans inbuxa Admin.",
|
||||
"Open inbuxa Admin": "Ouvrir inbuxa Admin",
|
||||
"Default group role": "Rôle de groupe par défaut",
|
||||
"A group needs an address.": "Un groupe a besoin d’une adresse.",
|
||||
"New group": "Nouveau groupe",
|
||||
@@ -217,7 +217,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Le serveur de messagerie attribue ce rôle par défaut à {kinds}. Une modification ici s’applique à tous ceux qui l’ont ainsi.",
|
||||
"Builds on": "S’appuie sur",
|
||||
"Permissions": "Autorisations",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Le serveur de messagerie attribue ce rôle par défaut : il ne peut donc pas être supprimé. Modifiez d’abord les valeurs par défaut dans INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Le serveur de messagerie attribue ce rôle par défaut : il ne peut donc pas être supprimé. Modifiez d’abord les valeurs par défaut dans inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "Ce rôle comporte des autorisations que le vôtre n’a pas.",
|
||||
"Create role": "Créer le rôle",
|
||||
"builds on this one": "s’appuie sur celui-ci",
|
||||
@@ -1126,7 +1126,6 @@ export const catalog: Catalog = {
|
||||
"Select a conversation to read it here · Press {key} for shortcuts": "Sélectionnez une conversation pour la lire ici · {key} pour les raccourcis",
|
||||
"Select a message to read it here · Press {key} for shortcuts": "Sélectionnez un message pour le lire ici · {key} pour les raccourcis",
|
||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Astuce : appuyez sur {key} sur une conversation pour appliquer des libellés. Recherchez avec {operator}.",
|
||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Un webmail libre, rapide et agréable pour {server}, bâti sur JMAP.",
|
||||
"Defaults for the calendar views and new events.": "Valeurs par défaut des vues d'agenda et des nouveaux événements.",
|
||||
"Replies will go to this address instead of the From address": "Les réponses iront à cette adresse plutôt qu'à l'adresse d'expédition",
|
||||
"Replies to mail sent from this identity go here instead of the From address.": "Les réponses aux messages envoyés depuis cette identité arrivent ici plutôt qu'à l'adresse d'expédition.",
|
||||
@@ -1718,6 +1717,19 @@ 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: inbuxa, the suite ────────────────────────────────────
|
||||
"{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.": "{inbuxa} est une suite de messagerie complète : un serveur de messagerie, la console qui l’administre et ce webmail, chacun étant un programme à part entière, installés ensemble et libres sous licence AGPL.",
|
||||
"The suite": "La suite",
|
||||
"Administration console": "Console d’administration",
|
||||
"Webmail": "Webmail",
|
||||
// ── 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",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
+16
-4
@@ -138,8 +138,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。",
|
||||
"Nothing to show": "表示するものはありません",
|
||||
"Could not be loaded": "読み込めませんでした",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "詳細なメトリクス、配信キュー、ログ、サーバー設定は INBUXA Admin にあります。",
|
||||
"Open INBUXA Admin": "INBUXA Admin を開く",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "詳細なメトリクス、配信キュー、ログ、サーバー設定は inbuxa Admin にあります。",
|
||||
"Open inbuxa Admin": "inbuxa Admin を開く",
|
||||
"Default group role": "グループの既定ロール",
|
||||
"A group needs an address.": "グループにはアドレスが必要です。",
|
||||
"New group": "新しいグループ",
|
||||
@@ -211,7 +211,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "メールサーバーはこのロールを既定で {kinds} に付与します。ここでの変更は、この方法でロールを持つ全員に反映されます。",
|
||||
"Builds on": "継承元",
|
||||
"Permissions": "権限",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "メールサーバーがこのロールを既定で付与するため、削除できません。先に INBUXA Admin で既定値を変更してください。",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "メールサーバーがこのロールを既定で付与するため、削除できません。先に inbuxa Admin で既定値を変更してください。",
|
||||
"This role carries permissions yours doesn't.": "このロールにはあなたのロールにない権限があります。",
|
||||
"Create role": "ロールを作成",
|
||||
"builds on this one": "このロールを継承しています",
|
||||
@@ -1046,7 +1046,6 @@ export const catalog: Catalog = {
|
||||
"Select a conversation to read it here · Press {key} for shortcuts": "スレッドを選ぶとここに表示されます · {key} でショートカット一覧",
|
||||
"Select a message to read it here · Press {key} for shortcuts": "メールを選ぶとここに表示されます · {key} でショートカット一覧",
|
||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "ヒント: スレッド上で {key} を押すとラベルを付けられます。検索には {operator} が使えます。",
|
||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "{server} のための、軽快で使いやすいオープンソースのウェブメール。JMAP で動作します。",
|
||||
|
||||
// ── Filters, vacation, capability notices ──────────────────────────
|
||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "メールをありがとうございます。……まで不在にしており、戻り次第ご返信いたします。",
|
||||
@@ -1721,6 +1720,19 @@ 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: inbuxa, the suite ────────────────────────────────────
|
||||
"{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.": "{inbuxa} は完全なメールスイートです。メールサーバー、それを管理するコンソール、そしてこのWebメール。それぞれ独立したプログラムで、まとめてインストールでき、AGPLのもとで自由に使えます。",
|
||||
"The suite": "スイート",
|
||||
"Administration console": "管理コンソール",
|
||||
"Webmail": "Webメール",
|
||||
// ── 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": "迷惑メールフィルターが考慮した複数の判断材料のひとつ",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
+16
-4
@@ -134,8 +134,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "De cijfers die uw rol mag zien, zoals de server ze meldt.",
|
||||
"Nothing to show": "Niets om te tonen",
|
||||
"Could not be loaded": "Kon niet worden geladen",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in INBUXA Admin.",
|
||||
"Open INBUXA Admin": "INBUXA Admin openen",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in inbuxa Admin.",
|
||||
"Open inbuxa Admin": "inbuxa Admin openen",
|
||||
"Default group role": "Standaardrol voor groepen",
|
||||
"A group needs an address.": "Een groep heeft een adres nodig.",
|
||||
"New group": "Nieuwe groep",
|
||||
@@ -207,7 +207,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "De mailserver geeft deze rol standaard aan {kinds}. Een wijziging hier geldt voor iedereen die hem zo heeft.",
|
||||
"Builds on": "Bouwt voort op",
|
||||
"Permissions": "Rechten",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "De mailserver geeft standaard deze rol, dus hij kan niet worden verwijderd. Wijzig eerst de standaardwaarden in INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "De mailserver geeft standaard deze rol, dus hij kan niet worden verwijderd. Wijzig eerst de standaardwaarden in inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "Deze rol heeft rechten die uw rol niet heeft.",
|
||||
"Create role": "Rol aanmaken",
|
||||
"builds on this one": "bouwt voort op deze",
|
||||
@@ -1118,7 +1118,6 @@ export const catalog: Catalog = {
|
||||
"Select a conversation to read it here · Press {key} for shortcuts": "Selecteer een gesprek om het hier te lezen · {key} voor sneltoetsen",
|
||||
"Select a message to read it here · Press {key} for shortcuts": "Selecteer een bericht om het hier te lezen · {key} voor sneltoetsen",
|
||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tip: druk op {key} bij een gesprek om labels toe te wijzen. Zoek met {operator}.",
|
||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Een snelle, prettige, opensource webmail voor {server}, gebouwd op JMAP.",
|
||||
"Defaults for the calendar views and new events.": "Standaardwaarden voor de agendaweergaven en nieuwe afspraken.",
|
||||
"Replies will go to this address instead of the From address": "Antwoorden gaan naar dit adres in plaats van naar het afzenderadres",
|
||||
"Replies to mail sent from this identity go here instead of the From address.": "Antwoorden op post die vanaf deze identiteit is verzonden, komen hier aan in plaats van bij het afzenderadres.",
|
||||
@@ -1710,6 +1709,19 @@ 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: inbuxa, the suite ────────────────────────────────────
|
||||
"{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.": "{inbuxa} is een complete mailsuite: een mailserver, de console waarmee je hem beheert en deze webmail, elk een eigen programma, samen geïnstalleerd en vrij onder de AGPL.",
|
||||
"The suite": "De suite",
|
||||
"Administration console": "Beheerconsole",
|
||||
"Webmail": "Webmail",
|
||||
// ── 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",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -142,8 +142,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "Os números que sua função pode ver, como o servidor os informa.",
|
||||
"Nothing to show": "Nada para mostrar",
|
||||
"Could not be loaded": "Não foi possível carregar",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Métricas detalhadas, a fila de entrega, os registros e as configurações do servidor ficam no INBUXA Admin.",
|
||||
"Open INBUXA Admin": "Abrir o INBUXA Admin",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Métricas detalhadas, a fila de entrega, os registros e as configurações do servidor ficam no inbuxa Admin.",
|
||||
"Open inbuxa Admin": "Abrir o inbuxa Admin",
|
||||
"Default group role": "Função padrão de grupo",
|
||||
"A group needs an address.": "Um grupo precisa de um endereço.",
|
||||
"New group": "Novo grupo",
|
||||
@@ -215,7 +215,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "O servidor de e-mail atribui esta função por padrão a {kinds}. Uma alteração aqui vale para todos que a têm dessa forma.",
|
||||
"Builds on": "Baseia-se em",
|
||||
"Permissions": "Permissões",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "O servidor de e-mail atribui esta função por padrão, então ela não pode ser excluída. Altere primeiro os padrões no INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "O servidor de e-mail atribui esta função por padrão, então ela não pode ser excluída. Altere primeiro os padrões no inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "Esta função tem permissões que a sua não tem.",
|
||||
"Create role": "Criar função",
|
||||
"builds on this one": "baseia-se nesta",
|
||||
@@ -1124,7 +1124,6 @@ export const catalog: Catalog = {
|
||||
"Select a conversation to read it here · Press {key} for shortcuts": "Selecione uma conversa para lê-la aqui · {key} para os atalhos",
|
||||
"Select a message to read it here · Press {key} for shortcuts": "Selecione uma mensagem para lê-la aqui · {key} para os atalhos",
|
||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Dica: pressione {key} em uma conversa para aplicar marcadores. Pesquise com {operator}.",
|
||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Um webmail livre, rápido e agradável para {server}, feito sobre JMAP.",
|
||||
"Defaults for the calendar views and new events.": "Padrões das visualizações da agenda e dos eventos novos.",
|
||||
"Replies will go to this address instead of the From address": "As respostas irão para este endereço em vez do endereço do remetente",
|
||||
"Replies to mail sent from this identity go here instead of the From address.": "As respostas às mensagens enviadas por esta identidade chegam aqui em vez do endereço do remetente.",
|
||||
@@ -1716,6 +1715,19 @@ 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: inbuxa, the suite ────────────────────────────────────
|
||||
"{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.": "{inbuxa} é uma suíte de e-mail completa: um servidor de e-mail, o console que o administra e este webmail, cada um um programa próprio, instalados juntos e livres sob a AGPL.",
|
||||
"The suite": "A suíte",
|
||||
"Administration console": "Console de administração",
|
||||
"Webmail": "Webmail",
|
||||
// ── 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",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
+16
-4
@@ -141,8 +141,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.",
|
||||
"Nothing to show": "Нечего показать",
|
||||
"Could not be loaded": "Не удалось загрузить",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в INBUXA Admin.",
|
||||
"Open INBUXA Admin": "Открыть INBUXA Admin",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в inbuxa Admin.",
|
||||
"Open inbuxa Admin": "Открыть inbuxa Admin",
|
||||
"Default group role": "Роль группы по умолчанию",
|
||||
"A group needs an address.": "Группе нужен адрес.",
|
||||
"New group": "Новая группа",
|
||||
@@ -214,7 +214,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Почтовый сервер по умолчанию выдаёт эту роль: {kinds}. Изменение здесь затронет всех, кто получил её так.",
|
||||
"Builds on": "Основана на",
|
||||
"Permissions": "Разрешения",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Почтовый сервер выдаёт эту роль по умолчанию, поэтому её нельзя удалить. Сначала измените значения по умолчанию в INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Почтовый сервер выдаёт эту роль по умолчанию, поэтому её нельзя удалить. Сначала измените значения по умолчанию в inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "У этой роли есть разрешения, которых нет у вашей.",
|
||||
"Create role": "Создать роль",
|
||||
"builds on this one": "основана на этой",
|
||||
@@ -1123,7 +1123,6 @@ export const catalog: Catalog = {
|
||||
"Select a conversation to read it here · Press {key} for shortcuts": "Выберите цепочку, чтобы прочитать её здесь · {key} — сочетания клавиш",
|
||||
"Select a message to read it here · Press {key} for shortcuts": "Выберите письмо, чтобы прочитать его здесь · {key} — сочетания клавиш",
|
||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Совет: нажмите {key} на цепочке, чтобы присвоить ярлыки. Ищите через {operator}.",
|
||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Быстрая и удобная веб-почта с открытым кодом для {server}, построенная на JMAP.",
|
||||
"Defaults for the calendar views and new events.": "Значения по умолчанию для видов календаря и новых событий.",
|
||||
"Replies will go to this address instead of the From address": "Ответы будут приходить на этот адрес, а не на адрес отправителя",
|
||||
"Replies to mail sent from this identity go here instead of the From address.": "Ответы на письма из этого профиля приходят сюда, а не на адрес отправителя.",
|
||||
@@ -1715,6 +1714,19 @@ 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: inbuxa, the suite ────────────────────────────────────
|
||||
"{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.": "{inbuxa} — полноценный почтовый пакет: почтовый сервер, консоль для его администрирования и эта веб-почта. Каждый компонент — отдельная программа; устанавливаются они вместе и свободно распространяются по лицензии AGPL.",
|
||||
"The suite": "Пакет",
|
||||
"Administration console": "Консоль администрирования",
|
||||
"Webmail": "Веб-почта",
|
||||
// ── 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": "Один из нескольких признаков, которые учёл спам-фильтр",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
+16
-4
@@ -135,8 +135,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.",
|
||||
"Nothing to show": "Нічого показати",
|
||||
"Could not be loaded": "Не вдалося завантажити",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Докладні метрики, черга доставлення, журнали й налаштування сервера є в INBUXA Admin.",
|
||||
"Open INBUXA Admin": "Відкрити INBUXA Admin",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Докладні метрики, черга доставлення, журнали й налаштування сервера є в inbuxa Admin.",
|
||||
"Open inbuxa Admin": "Відкрити inbuxa Admin",
|
||||
"Default group role": "Роль групи за замовчуванням",
|
||||
"A group needs an address.": "Групі потрібна адреса.",
|
||||
"New group": "Нова група",
|
||||
@@ -208,7 +208,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Поштовий сервер типово надає цю роль: {kinds}. Зміна тут стосується всіх, хто отримав її так.",
|
||||
"Builds on": "Базується на",
|
||||
"Permissions": "Дозволи",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Поштовий сервер типово надає цю роль, тому її не можна видалити. Спершу змініть типові значення в INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Поштовий сервер типово надає цю роль, тому її не можна видалити. Спершу змініть типові значення в inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "Ця роль має дозволи, яких немає у вашої.",
|
||||
"Create role": "Створити роль",
|
||||
"builds on this one": "базується на цій",
|
||||
@@ -1117,7 +1117,6 @@ export const catalog: Catalog = {
|
||||
"Select a conversation to read it here · Press {key} for shortcuts": "Виберіть листування, щоб прочитати його тут · {key} — сполучення клавіш",
|
||||
"Select a message to read it here · Press {key} for shortcuts": "Виберіть лист, щоб прочитати його тут · {key} — сполучення клавіш",
|
||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Порада: натисніть {key} на листуванні, щоб додати мітки. Шукайте через {operator}.",
|
||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Швидка та зручна вебпошта з відкритим кодом для {server}, побудована на JMAP.",
|
||||
"Defaults for the calendar views and new events.": "Значення за замовчуванням для виглядів календаря та нових подій.",
|
||||
"Replies will go to this address instead of the From address": "Відповіді надходитимуть на цю адресу, а не на адресу відправника",
|
||||
"Replies to mail sent from this identity go here instead of the From address.": "Відповіді на листи з цього профілю надходять сюди, а не на адресу відправника.",
|
||||
@@ -1709,6 +1708,19 @@ 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: inbuxa, the suite ────────────────────────────────────
|
||||
"{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.": "{inbuxa} — повноцінний поштовий пакет: поштовий сервер, консоль для його адміністрування і ця вебпошта. Кожен компонент — окрема програма; встановлюються вони разом і вільно поширюються за ліцензією AGPL.",
|
||||
"The suite": "Пакет",
|
||||
"Administration console": "Консоль адміністрування",
|
||||
"Webmail": "Вебпошта",
|
||||
// ── 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": "Одна з кількох ознак, які врахував спам-фільтр",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -137,8 +137,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。",
|
||||
"Nothing to show": "没有可显示的内容",
|
||||
"Could not be loaded": "无法加载",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "详细指标、投递队列、日志和服务器设置都在 INBUXA Admin 中。",
|
||||
"Open INBUXA Admin": "打开 INBUXA Admin",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "详细指标、投递队列、日志和服务器设置都在 inbuxa Admin 中。",
|
||||
"Open inbuxa Admin": "打开 inbuxa Admin",
|
||||
"Default group role": "默认群组角色",
|
||||
"A group needs an address.": "群组需要一个地址。",
|
||||
"New group": "新建群组",
|
||||
@@ -210,7 +210,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "邮件服务器默认将此角色授予{kinds}。在此处的更改会影响所有以此方式拥有该角色的人。",
|
||||
"Builds on": "基于",
|
||||
"Permissions": "权限",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "邮件服务器默认授予此角色,因此无法删除。请先在 INBUXA Admin 中更改默认设置。",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "邮件服务器默认授予此角色,因此无法删除。请先在 inbuxa Admin 中更改默认设置。",
|
||||
"This role carries permissions yours doesn't.": "此角色拥有您的角色所没有的权限。",
|
||||
"Create role": "创建角色",
|
||||
"builds on this one": "基于此角色",
|
||||
@@ -1045,7 +1045,6 @@ export const catalog: Catalog = {
|
||||
"Select a conversation to read it here · Press {key} for shortcuts": "选择一个会话即可在此阅读 · 按 {key} 查看快捷键",
|
||||
"Select a message to read it here · Press {key} for shortcuts": "选择一封邮件即可在此阅读 · 按 {key} 查看快捷键",
|
||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "提示:在会话上按 {key} 可添加标签。使用 {operator} 搜索。",
|
||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "一款面向 {server} 的快速、友好的开源网页邮箱,基于 JMAP 构建。",
|
||||
|
||||
// ── Filters, vacation, capability notices ──────────────────────────
|
||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "感谢您的来信。我将外出至……,回来后会尽快回复。",
|
||||
@@ -1720,6 +1719,19 @@ 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: inbuxa, the suite ────────────────────────────────────
|
||||
"{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.": "{inbuxa} 是一套完整的邮件套件:邮件服务器、管理它的控制台,以及这个网页邮箱。三者各自是独立的程序,一起安装,并以 AGPL 许可证自由使用。",
|
||||
"The suite": "套件",
|
||||
"Administration console": "管理控制台",
|
||||
"Webmail": "网页邮箱",
|
||||
// ── 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": "垃圾邮件过滤考虑的多个信号之一",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SPAM_HEADER_PROPS } from "@/lib/spamScore";
|
||||
import { LLM_HEADER_PROP } from "@/lib/llmOpinion";
|
||||
|
||||
|
||||
/*
|
||||
@@ -67,6 +68,8 @@ export const FULL_PROPS = [
|
||||
"header:Precedence:asText",
|
||||
"header:Authentication-Results:asText",
|
||||
...SPAM_HEADER_PROPS,
|
||||
// inbuxa: the language model's opinion, when the server wrote one
|
||||
LLM_HEADER_PROP,
|
||||
];
|
||||
|
||||
export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"];
|
||||
@@ -1682,6 +1682,8 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
||||
.shortcut-grid h3 { margin: 0 0 6px; font-size: .9em; text-transform: uppercase; letter-spacing: .05em; color: var(--fg-faint); }
|
||||
.shortcut-row { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; gap: 12px; }
|
||||
.shortcut-row .keys { display: flex; gap: 4px; }
|
||||
/* ihasmail-inbuxa: the product name in a sentence, set apart in the brand teal. */
|
||||
.brand-inbuxa { color: #14b8a6; font-weight: 700; }
|
||||
.sessions-table { width: 100%; border-collapse: collapse; font-size: .92em; }
|
||||
.sessions-table th, .sessions-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
.sessions-table th { color: var(--fg-muted); font-weight: 600; font-size: .85em; }
|
||||
@@ -2387,6 +2389,13 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
||||
.spam-weight.bad { color: var(--danger); }
|
||||
.spam-weight.good { color: var(--success); }
|
||||
|
||||
/* inbuxa: the language model's opinion, in the details and above a message in
|
||||
Junk (views/mail/LlmOpinion.tsx). The explanation is the model's own words,
|
||||
so it keeps its line breaks out and wraps rather than widening the pane. */
|
||||
.llm-opinion { display: flex; flex-direction: column; gap: 4px; }
|
||||
.llm-heading { display: inline-flex; flex-wrap: wrap; gap: .4em; align-items: baseline; }
|
||||
.llm-explanation { overflow-wrap: anywhere; }
|
||||
|
||||
/* The placeholder reference under a template's body. */
|
||||
.placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; }
|
||||
.placeholder-row { display: contents; }
|
||||
|
||||
@@ -15,7 +15,7 @@ export function InbuxaWordmark({ className, height }: { className?: string; heig
|
||||
height={height}
|
||||
width={(height * 488) / 112}
|
||||
role="img"
|
||||
aria-label="INBUXA"
|
||||
aria-label="inbuxa"
|
||||
className={`notranslate ${className ?? ""}`}
|
||||
fill="currentColor"
|
||||
>
|
||||
|
||||
@@ -153,7 +153,7 @@ export function LoginPage() {
|
||||
margin-top, which a second paragraph would repeat as a gap.
|
||||
*/}
|
||||
{/* ihasmail-inbuxa: this build is INBUXA's webmail, and its site is INBUXA's. */}
|
||||
<span className="notranslate" translate="no">INBUXA webmail v{APP_VERSION}</span>
|
||||
<span className="notranslate" translate="no">inbuxa webmail v{APP_VERSION}</span>
|
||||
<br />
|
||||
<a href="https://inbuxa.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">inbuxa.org</a>
|
||||
{" · "}
|
||||
|
||||
@@ -126,12 +126,12 @@ export function AdminDashboard() {
|
||||
more numbers will be: this is a glance, and operating the server is
|
||||
Stalwart's own administration. The link is the operator's to give. */}
|
||||
<p className="hint admin-dashboard-note">
|
||||
{t("Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.")}
|
||||
{t("Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.")}
|
||||
{adminUrl && (
|
||||
<>
|
||||
{" "}
|
||||
<a href={adminUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t("Open INBUXA Admin")} <ExternalLink size={13} aria-hidden="true" />
|
||||
{t("Open inbuxa Admin")} <ExternalLink size={13} aria-hidden="true" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -186,7 +186,7 @@ export function RoleSheet({ role, roles, defaults, entries, permissionsError, on
|
||||
{!creating && can(perms, "Role", "Destroy") && (
|
||||
<DeleteRole
|
||||
role={role}
|
||||
blocked={kinds.length ? t("The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.") : locked ? t("This role carries permissions yours doesn't.") : null}
|
||||
blocked={kinds.length ? t("The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.") : locked ? t("This role carries permissions yours doesn't.") : null}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
|
||||
|
||||
function signIn(permissions: string[], username = "[email protected]") {
|
||||
useSession.setState({
|
||||
session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:stalwart:jmap": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession,
|
||||
session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:inbuxa:jmap:registry": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("the Administration dashboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the pointer to INBUXA Admin", () => {
|
||||
describe("the pointer to inbuxa Admin", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
beforeEach(() => {
|
||||
@@ -136,7 +136,7 @@ describe("the pointer to INBUXA Admin", () => {
|
||||
it("names it, and links it where the operator has said where it is", async () => {
|
||||
await renderWith("https://admin.example.com");
|
||||
const note = host.querySelector(".admin-dashboard-note")!;
|
||||
expect(note.textContent).toContain("INBUXA Admin");
|
||||
expect(note.textContent).toContain("inbuxa Admin");
|
||||
const link = note.querySelector("a")!;
|
||||
expect(link.getAttribute("href")).toBe("https://admin.example.com");
|
||||
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
@@ -144,7 +144,7 @@ describe("the pointer to INBUXA Admin", () => {
|
||||
|
||||
it("names it without a link where nobody has", async () => {
|
||||
await renderWith(null);
|
||||
expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("INBUXA Admin");
|
||||
expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("inbuxa Admin");
|
||||
expect(host.querySelector(".admin-dashboard-note a")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Bot } from "lucide-react";
|
||||
import type { LlmOpinion } from "@/lib/llmOpinion";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
|
||||
/*
|
||||
* inbuxa: the language model's opinion on a message, where the server's AI
|
||||
* spam classification recorded one (lib/llmOpinion).
|
||||
*
|
||||
* Two rules, both from the server's spec. It is always labeled as one signal
|
||||
* the spam filter weighed among several, never as the reason a message was
|
||||
* filed where it was: the model can add a bounded amount to the score and no
|
||||
* more. And the explanation is the model's own output, so it is only ever
|
||||
* rendered as text.
|
||||
*
|
||||
* The category and confidence come from the server's configuration and aren't
|
||||
* translated; only the two framing strings are.
|
||||
*/
|
||||
|
||||
function Verdict({ opinion }: { opinion: LlmOpinion }) {
|
||||
return (
|
||||
<>
|
||||
<strong>{opinion.category}</strong>
|
||||
{opinion.confidence && <span className="hint">{` · ${opinion.confidence}`}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** In the message details, beside the spam filter's own working. */
|
||||
export function LlmOpinionDetail({ opinion }: { opinion: LlmOpinion }) {
|
||||
return (
|
||||
<div className="llm-opinion">
|
||||
<div>
|
||||
<Verdict opinion={opinion} />
|
||||
</div>
|
||||
{opinion.explanation && <div className="llm-explanation">{opinion.explanation}</div>}
|
||||
<div className="hint">{translate("One of several signals the spam filter weighed")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Above a message that's in Junk. */
|
||||
export function LlmOpinionBanner({ opinion }: { opinion: LlmOpinion }) {
|
||||
return (
|
||||
<div className="remote-banner llm-banner" role="note" style={{ margin: "0 16px 8px" }}>
|
||||
<Bot size={16} />
|
||||
<span className="grow llm-opinion">
|
||||
<span className="llm-heading">
|
||||
<span>{translate("Language model's opinion")}</span>
|
||||
<span>
|
||||
<Verdict opinion={opinion} />
|
||||
</span>
|
||||
</span>
|
||||
{opinion.explanation && <span className="llm-explanation">{opinion.explanation}</span>}
|
||||
<span className="hint">{translate("One of several signals the spam filter weighed")}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The banner shows only for a message in Junk that carries an opinion. */
|
||||
export function llmBannerOpinion(
|
||||
opinion: LlmOpinion | null,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
junkId: string | null | undefined,
|
||||
): LlmOpinion | null {
|
||||
return opinion && junkId && mailboxIds[junkId] ? opinion : null;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { emlFilename } from "@/lib/text/emlName";
|
||||
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
|
||||
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
|
||||
import { spamReport, type SpamReport } from "@/lib/spamScore";
|
||||
import { llmOpinion } from "@/lib/llmOpinion";
|
||||
import { LlmOpinionBanner, LlmOpinionDetail, llmBannerOpinion } from "./LlmOpinion";
|
||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||
import { displayName, domainOf, formatAddress } from "@/lib/address";
|
||||
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html";
|
||||
@@ -187,6 +189,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
const receiptRequested = Boolean(e["header:Disposition-Notification-To:asAddresses"]?.length);
|
||||
const authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
|
||||
const spam = useMemo(() => spamReport(e), [e]);
|
||||
// inbuxa: the language model's opinion, where the server's AI spam classification wrote one
|
||||
const llm = useMemo(() => llmOpinion(e), [e]);
|
||||
const junkId = useMail((st) => st.roleId("junk"));
|
||||
const llmBanner = llmBannerOpinion(llm, e.mailboxIds, junkId);
|
||||
const identities = useMail((st) => st.identities);
|
||||
/*
|
||||
* Only computed when the warning is on, because the domains it compares
|
||||
@@ -374,6 +380,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
{e["header:List-Id:asText"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
||||
<dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</dd>
|
||||
{spam && <><dt>{translate("Spam filter")}</dt><dd><SpamSummary report={spam} /></dd></>}
|
||||
{llm && <><dt>{translate("Language model's opinion")}</dt><dd><LlmOpinionDetail opinion={llm} /></dd></>}
|
||||
{receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}</dd></>}
|
||||
</dl>
|
||||
)}
|
||||
@@ -425,6 +432,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
</div>
|
||||
)}
|
||||
<SignatureBanner state={signature} />
|
||||
{llmBanner && <LlmOpinionBanner opinion={llmBanner} />}
|
||||
{externalSender && (
|
||||
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
|
||||
<ShieldAlert size={16} />
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { LlmOpinionBanner, LlmOpinionDetail, llmBannerOpinion } from "../LlmOpinion";
|
||||
import { parseLlmOpinion, type LlmOpinion } from "@/lib/llmOpinion";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/*
|
||||
* The framing is the feature: the model's opinion is always one signal among
|
||||
* several, never presented as why a message is where it is, and its
|
||||
* explanation is model output, so it must never be rendered as markup.
|
||||
*/
|
||||
const opinion = (raw: string) => parseLlmOpinion(raw) as LlmOpinion;
|
||||
|
||||
describe("the language model's opinion", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const render = async (node: React.ReactNode) => {
|
||||
await act(async () => {
|
||||
root.render(node);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("shows category, confidence and explanation, as one signal of several", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion("LLM_UNSOLICITED_HIGH (Sells something unasked)")} />);
|
||||
expect(host.textContent).toContain("Unsolicited");
|
||||
expect(host.textContent).toContain("High");
|
||||
expect(host.textContent).toContain("Sells something unasked");
|
||||
expect(host.textContent).toContain("One of several signals the spam filter weighed");
|
||||
});
|
||||
|
||||
it("renders the explanation as text, never markup", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion('LLM_HARMFUL_HIGH (<img src=x onerror="alert(1)"> <b>bold</b>)')} />);
|
||||
expect(host.querySelector("img")).toBeNull();
|
||||
expect(host.querySelector("b")).toBeNull();
|
||||
expect(host.textContent).toContain('<img src=x onerror="alert(1)">');
|
||||
});
|
||||
|
||||
it("leaves out what the header didn't carry", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion("LLM_LEGITIMATE")} />);
|
||||
expect(host.querySelector(".llm-explanation")).toBeNull();
|
||||
expect(host.textContent).not.toContain("·");
|
||||
});
|
||||
|
||||
it("banners a message in Junk with the same framing", async () => {
|
||||
await render(<LlmOpinionBanner opinion={opinion("LLM_UNSOLICITED_MEDIUM (Bulk newsletter)")} />);
|
||||
expect(host.textContent).toContain("Language model's opinion");
|
||||
expect(host.textContent).toContain("Unsolicited");
|
||||
expect(host.textContent).toContain("Bulk newsletter");
|
||||
expect(host.textContent).toContain("One of several signals the spam filter weighed");
|
||||
});
|
||||
|
||||
it("banners only a message that's in Junk and carries an opinion", () => {
|
||||
const o = opinion("LLM_UNSOLICITED_HIGH");
|
||||
expect(llmBannerOpinion(o, { junk1: true }, "junk1")).toBe(o);
|
||||
expect(llmBannerOpinion(o, { inbox1: true }, "junk1")).toBeNull();
|
||||
expect(llmBannerOpinion(o, { junk1: true }, null)).toBeNull();
|
||||
expect(llmBannerOpinion(null, { junk1: true }, "junk1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,39 @@
|
||||
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 });
|
||||
// The node's hostname; the bare address only when it has no PTR record.
|
||||
return m.name ?? 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 ?? {});
|
||||
// Only a session that may administer is told where the console is.
|
||||
const adminUrl = session?.ihasmail?.server?.adminUrl ?? null;
|
||||
// A deployment running modified code should offer its own source, not ours.
|
||||
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
|
||||
return (
|
||||
@@ -20,7 +43,9 @@ export function AboutSettings() {
|
||||
The name comes from APP_NAME now, so a renamed deployment is named
|
||||
here too. */}
|
||||
<h1>{t("About {app}", { app: appName })}</h1>
|
||||
<p className="lead">{tNode("A fast, friendly, open-source webmail for {server}, built on JMAP.", { server: <span className="notranslate" translate="no">{appName}</span> })}</p>
|
||||
{/* inbuxa is the suite, not only this webmail: the product name here is
|
||||
inbuxa's own, not APP_NAME. */}
|
||||
<p className="lead">{tNode("{inbuxa} is a complete mail suite: a mail server, the console that administers it, and this webmail, each its own program, installed together and free under the AGPL.", { inbuxa: <strong className="brand-inbuxa notranslate" translate="no">inbuxa</strong> })}</p>
|
||||
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
||||
<img src={withBase("/img/inbuxa-mark.png")} alt="" width={80} />
|
||||
<div>
|
||||
@@ -31,17 +56,29 @@ export function AboutSettings() {
|
||||
<div className="hint">{tNode("AGPL-3.0-or-later · {source}", { source: <a href={sourceUrl} target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">{sourceUrl.replace(/^https?:\/\//, "")}</a> })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>{t("The suite")}</h2>
|
||||
<table className="sessions-table">
|
||||
<tbody>
|
||||
<tr><td>{t("Mail server")}</td><td className="notranslate" translate="no">inbuxa</td></tr>
|
||||
{adminUrl && (
|
||||
<tr><td>{t("Administration console")}</td><td><a href={adminUrl} target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">{adminUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}</a></td></tr>
|
||||
)}
|
||||
<tr><td>{t("Webmail")}</td><td className="notranslate" translate="no">v{APP_VERSION}</td></tr>
|
||||
<tr><td>{t("Website")}</td><td><a href="https://inbuxa.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">inbuxa.org</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>{t("Server")}</h2>
|
||||
<table className="sessions-table">
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="hint" style={{ marginTop: 6 }}>{t("This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.")}</p>
|
||||
<p className="hint" style={{ marginTop: 6 }}>{t("This webmail works with the inbuxa mail server, and sign-in refuses a server that doesn't offer what it needs.")}</p>
|
||||
<p className="hint">{tNode("{app}'s own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.", { example: <strong className="notranslate" translate="no">v2026.8.30+pr129</strong>, sha: <code>+g1fa6578</code> }, { app: appName })}</p>
|
||||
<h2>{t("Server capabilities")}</h2>
|
||||
<div className="row wrap gap-4">
|
||||
|
||||
Reference in new issue
Block a user