Compare commits

..
16 Commits
Author SHA1 Message Date
jcoffey-dev 119548090e Merge pull request 'Show the language model's opinion on a message' (#15) from feature/llm-opinion into main
ci / version (push) Skipped
ci / node (push) Successful in 2m22s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 2m43s
Reviewed-on: #15
2026-09-23 05:39:19 +00:00
jcoffey-dev e4926cfa7d Show the language model's opinion on a message
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m23s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 1m24s
When inbuxa-server's AI spam classification is on, it records the model's
answer in an X-Spam-LLM header: a tag (LLM_<category>[_<confidence>]) and,
in parentheses, the model's explanation. The full message now asks for it,
and where it's there:

- the message details show "Language model's opinion" beside the spam
  filter's own working, with category, confidence and explanation;
- a message in Junk carries a banner saying the same.

Both say it's one of several signals the spam filter weighed, never the
reason on its own, as the server's spec requires. The explanation is model
output and is only ever rendered as text. Nothing shows without the header,
so a server without the feature, or with it off, looks as before.

Translations: two new strings, "Language model's opinion" and "One of
several signals the spam filter weighed", in all eight catalogues (16
entries). Category and confidence come from the server and aren't
translated.
2026-09-22 22:03:29 -07:00
jcoffey-dev c0c892dd33 Merge pull request 'Use the server's renamed registry capability, urn:inbuxa:jmap:registry' (#14) from fork/rename-upstream-identifiers into main
ci / version (push) Skipped
ci / node (push) Successful in 1m25s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 16s
Reviewed-on: #14
2026-09-23 04:25:12 +00:00
jcoffey-dev f5dd4e5537 Use the server's renamed registry capability, urn:inbuxa:jmap:registry
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m47s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 3m55s
inbuxa-server renames the identifiers that carried the upstream name (its
SPEC.md §2.4). Upstream's capability for the registry (x:) objects is now
urn:inbuxa:jmap:registry, beside the fork's own urn:inbuxa:jmap, which is
unchanged. There's no alias, so this lands with the server change and
deploys with it. The mock advertises the new name too. No user-visible
strings change.
2026-09-22 19:00:19 -07:00
jcoffey-dev e7ee09d228 Merge pull request 'Lowercase the name in the page title' (#13) from brand/lowercase-title into main
ci / version (push) Skipped
ci / node (push) Successful in 2m23s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 1m17s
2026-09-23 00:16:23 +00:00
jcoffey-dev 1752276229 Lowercase the name in the page title
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m37s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 40s
The browser tab, and anything that takes its name from the document title,
read INBUXA. The manifest, the server's app name and the sign-in card all
have it lowercase; the title was the one place left in caps.

Prod's APP_NAME override was set to inbuxa at the same time; the code
default already was.
2026-09-22 17:13:39 -07:00
jcoffey-dev acd9cff4ea Merge pull request 'Take the upstream name out of the mock's sample data' (#12) from mock/brand-sample-data into main
ci / version (push) Skipped
ci / node (push) Successful in 1m19s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 15s
2026-09-22 23:06:53 +00:00
jcoffey-dev 413ece3bca Take the upstream name out of the mock's sample data
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m16s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 33s
The mock inbox showed a sender called "Stalwart Labs" at [email protected],
a "Welcome to Stalwart!" subject, a link to stalw.art in the sample HTML
and a start-up banner tagged [mock-stalwart]. None of that belongs in this
fork, and it turns up in any screenshot taken from the mock.

Sample senders and subjects now name inbuxa, the sample link points at
inbuxa.org, and the banner says [mock-server].
2026-09-22 16:03:31 -07:00
jcoffey-dev 4bbfd7d455 Merge pull request 'Lowercase the tab title fallback' (#11) from fix/brand-lowercase-title into main
ci / version (push) Skipped
ci / node (push) Successful in 2m20s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 17s
2026-09-22 22:24:12 +00:00
jcoffey-dev d0832013fe Lowercase the tab title's fallback name
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m46s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 37s
The title the tab falls back to before the session names the app was the one
user-visible string the brand pass missed; setBaseTitle overwrites it as soon
as the session arrives, so it shows only for that first moment.
2026-09-22 15:21:35 -07:00
jcoffey-dev b79fdb8bab Merge pull request 'Write the name in lowercase where people see it' (#10) from fix/brand-lowercase into main
ci / version (push) Skipped
ci / node (push) Successful in 2m28s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 16s
2026-09-22 22:18:06 +00:00
jcoffey-dev 8d717e0037 Write the name in lowercase where people see it
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m21s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 1m10s
The brand is lowercase inbuxa. This changes what the app calls itself by
default, the wordmark's accessible name, the sign-in card's version line, the
installed app's name in the manifest, and the four translated strings that
name the console or the mail server.

Those four are source strings, so their catalog keys changed with them in all
nine languages; the translations keep their text with the name corrected. No
key was left behind, and no language falls back on more strings than before:
1643/1662 translated, 19 falling back, in each of the nine, unchanged.

Code identifiers, capability URNs, env var names and comments are untouched.
2026-09-22 15:13:53 -07:00
jcoffey-dev c2f13d6a1a Merge pull request 'ci: publish tagged releases as INBUXA's own image' (#9) from ci/inbuxa-release into main
ci / version (push) Successful in 1m24s
ci / node (push) Successful in 2m33s
ci / docker-build (push) Skipped
ci / publish (push) Successful in 5m41s
2026-09-22 16:58:24 +00:00
jcoffey-dev afb39fac20 ci: publish tagged releases as INBUXA's own image
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m15s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 28s
A tag named inbuxa-v<version> (the tagged commit's own version from
scripts/version.mjs, '+' as '-') now builds a linux/amd64 + linux/arm64
image at <REGISTRY>/inbuxa/ihasmail-inbuxa, tagged with the version and
latest, links the package to the repository and creates the release.

The inbuxa- prefix keeps upstream ihasmail's v* tags, which this
repository carries on shared commits, from ever publishing under the
INBUXA name. The tag must name its commit's version and the commit must
be on main. No schedule yet: releases are cut by hand.
2026-09-22 09:56:09 -07:00
jcoffey-dev b514dab62a Merge pull request 'Point links at the new git host' (#8) from fix/links-after-move into main
ci / node (push) Successful in 3m15s
ci / docker-build (push) Successful in 58s
2026-09-22 16:09:58 +00:00
jcoffey-dev 2d7d8952ab Point links at the new git host
ci / docker-build (pull_request) Successful in 1m13s
ci / node (pull_request) Successful in 4m42s
GitHub went dark with the account suspension on 2026-09-20 and GitLab was
retired on 2026-09-22, so links to either no longer resolve. Repository,
file, release and download links now point at git.coffeylabs.org, images
at registry.coffeylabs.org, and old GitHub issue and pull request links at
coffey-labs/ihasmail-github-archive, whose numbers match GitHub's.
2026-09-22 09:03:00 -07:00
48 changed files with 498 additions and 87 deletions
+1 -1
View File
@@ -75,7 +75,7 @@ APP_NAME=ihasmail
# you have patched it, point this at your own tree. Shown on the sign-in page # you have patched it, point this at your own tree. Shown on the sign-in page
# and in Settings > About. INBUXA's webmail is itself a modified ihasmail, so # and in Settings > About. INBUXA's webmail is itself a modified ihasmail, so
# the default is this fork. # the default is this fork.
SOURCE_URL=https://github.com/inbuxa/ihasmail-inbuxa SOURCE_URL=https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa
# ---- Settings this installation decides (all optional) ---- # ---- Settings this installation decides (all optional) ----
# #
+100 -8
View File
@@ -2,14 +2,15 @@
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once # GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
# this directory exists; .github/workflows stays as it was for GitHub. # this directory exists; .github/workflows stays as it was for GitHub.
# #
# There is deliberately no publish job, although publish.yml is in the tree. # Releases are cut by pushing a tag named `inbuxa-v<version>`, where
# Every tag in this repository is one of ihasmail's own upstream tags, the # <version> is what scripts/version.mjs says for the tagged commit with the
# same commits, and at those tags publish.yml pushed to ihasmail's image, not # `+` turned into `-` (e.g. inbuxa-v2026.9.22-g1a2b3c4). The prefix matters:
# an INBUXA one. A tag-driven publish here would ship plain ihasmail under the # this repository carries upstream ihasmail's own `v...` tags, on commits it
# INBUXA name the moment upstream tags reached this project -- which happened # shares with upstream, and a publish keyed on `v*` would ship plain ihasmail
# once, by hand, and was deleted. Add one back only with a release scheme that # under the INBUXA name the moment one arrived. Only `inbuxa-v` tags publish.
# produces tags this repository alone has. # A tag publishes only if it names its own commit's version and that commit is
# # on main. There is no release schedule yet; tags are cut by hand.
# Every job runs in an image pinned by digest (tag in the trailing comment), # Every job runs in an image pinned by digest (tag in the trailing comment),
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The # and the only action used is coffey-labs/actions/checkout pinned by SHA. The
# instance resolves short `uses:` against itself, never GitHub, so nothing # instance resolves short `uses:` against itself, never GitHub, so nothing
@@ -85,3 +86,94 @@ jobs:
tag="ihasmail:ci-$(echo "$GITHUB_SHA" | cut -c1-8)" tag="ihasmail:ci-$(echo "$GITHUB_SHA" | cut -c1-8)"
docker build -t "$tag" . docker build -t "$tag" .
docker image rm "$tag" docker image rm "$tag"
# ----------------------------------------------------------- release ------
# Only for `inbuxa-v` tags (see the top of this file). The tag has to name
# its own commit's version, so the image, the release and the About screen
# all agree, and the commit has to be on main, so a release never describes
# code that was not reviewed onto the default branch.
version:
if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }}
runs-on: light
container:
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
outputs:
version: ${{ steps.v.outputs.VERSION }}
docker_tag: ${{ steps.v.outputs.DOCKER_TAG }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
- id: v
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
V="$(node scripts/version.mjs)"
want="inbuxa-v${V/+/-}"
[ "$TAG" = "$want" ] || { echo "!! $TAG does not name this commit's version; expected $want"; exit 1; }
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|| { echo "!! $TAG is not on main"; exit 1; }
echo "VERSION=$V" >> "$GITHUB_OUTPUT"
echo "DOCKER_TAG=${V/+/-}" >> "$GITHUB_OUTPUT"
echo "VERSION=$V DOCKER_TAG=${V/+/-}"
# Multi-arch image at <REGISTRY>/inbuxa/ihasmail-inbuxa, then the release.
# arm64 is built under QEMU on this amd64 host, which is slow but fine for
# a hand-cut release. PACKAGE_TOKEN (jcoffey-dev, write:package) logs in:
# the job's own token is refused by the container registry. The release is
# created last, so a release on the page always has its image behind it.
publish:
if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }}
needs: [node, version]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
DOCKER_BUILDKIT: "1"
REGISTRY: ${{ vars.REGISTRY }}
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
VERSION: ${{ needs.version.outputs.version }}
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: |
test -n "$REGISTRY" && test -n "$VERSION" && test -n "$DOCKER_TAG"
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
- run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg IHASMAIL_VERSION="$VERSION" \
--provenance=false --sbom=false \
--tag "$IMAGE:$DOCKER_TAG" \
--tag "$IMAGE:latest" \
--push .
docker buildx imagetools inspect "$IMAGE:$DOCKER_TAG"
# Show the package on the repository's Packages tab. Idempotent.
- run: |
apk add --no-cache -q curl
curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \
"$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \
|| echo "package already linked (or link refused); not fatal"
# The release, on the internal address. The job's own token may create
# releases; a tag it creates would not start a workflow, but this one
# already exists.
- env:
TAG: ${{ github.ref_name }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
body="INBUXA webmail $VERSION.\n\nImage: \`$IMAGE:$DOCKER_TAG\` (linux/amd64, linux/arm64), also tagged \`latest\`."
curl -fsS -o /dev/null -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
--data "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"$body\"}" \
"$CI_SERVER_INTERNAL/api/v1/repos/$GITHUB_REPOSITORY/releases"
echo "release $TAG created"
- if: always()
run: docker logout "$REGISTRY" || true
+1 -1
View File
@@ -16,7 +16,7 @@ By participating in this project, you agree to treat other contributors with res
### Reporting Bugs ### Reporting Bugs
Before opening a new issue, please search [existing issues](https://github.com/Coffey-Labs/ihasmail/issues) to see if it's already been reported. When filing a bug report, include: Before opening a new issue, please search [existing issues](https://git.coffeylabs.org/coffey-labs/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
- A clear, descriptive title - A clear, descriptive title
- Steps to reproduce the issue - Steps to reproduce the issue
+2 -2
View File
@@ -70,7 +70,7 @@ docker compose up --build -d
## Source code ## Source code
INBUXA webmail is a modified ihasmail, so the AGPL's offer is this fork: INBUXA webmail is a modified ihasmail, so the AGPL's offer is this fork:
<https://github.com/inbuxa/ihasmail-inbuxa>. The sign-in page and Settings <https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa>. The sign-in page and Settings
About link there, beside the version, which names the commit the running build About link there, beside the version, which names the commit the running build
came from. came from.
@@ -94,7 +94,7 @@ Architecture, the mock's switches and how versions are numbered are in
## Built on ihasmail ## Built on ihasmail
The INBUXA webmail is built on [ihasmail](https://github.com/Coffey-Labs/ihasmail), The INBUXA webmail is built on [ihasmail](https://git.coffeylabs.org/coffey-labs/ihasmail),
Coffey Labs' own webmail, which stays an independent product. The public Coffey Labs' own webmail, which stays an independent product. The public
repository is the remote `ihasmail`, fetch-only, and its `main` is merged in to repository is the remote `ihasmail`, fetch-only, and its `main` is merged in to
keep up. Nothing here is pushed there. keep up. Nothing here is pushed there.
+1 -1
View File
@@ -29,7 +29,7 @@ services:
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)} APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
APP_NAME: ${APP_NAME:-ihasmail} APP_NAME: ${APP_NAME:-ihasmail}
BASE_PATH: ${BASE_PATH:-} BASE_PATH: ${BASE_PATH:-}
SOURCE_URL: ${SOURCE_URL:-https://github.com/inbuxa/ihasmail-inbuxa} SOURCE_URL: ${SOURCE_URL:-https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa}
TRUST_PROXY: "1" TRUST_PROXY: "1"
IMAGE_PROXY: "1" IMAGE_PROXY: "1"
volumes: volumes:
+3 -3
View File
@@ -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 * 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 * 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 * 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"); const res = await call("/api/auth/session");
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.equal(res.body.ihasmail.server.edition, "oss"); 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.equal(res.body.capabilities["urn:inbuxa:jmap:registry"], 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.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 () => { test("the registry reports an account with nothing set up yet", async () => {
+1 -1
View File
@@ -12,7 +12,7 @@ import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.
* the registry is known to be there. * 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"; const JMAP_CORE = "urn:ietf:params:jmap:core";
/** Stalwart's id for a singleton object; the number it encodes spells this. */ /** Stalwart's id for a singleton object; the number it encodes spells this. */
const SINGLETON = "singleton"; const SINGLETON = "singleton";
+3 -3
View File
@@ -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 () => { test("a server without the registry is not asked for anything", async () => {
// Sign-in refuses these, so getAccountInfo should never reach the wire for // 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. // 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 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); 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 * 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 * 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 * 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. * 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": {} }; 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", () => { test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => {
+2 -2
View File
@@ -278,7 +278,7 @@ if (oauthClientSecret && !publicUrl) {
export const config = { export const config = {
isProd, isProd,
appName: env("APP_NAME", "INBUXA"), appName: env("APP_NAME", "inbuxa"),
settingsPolicy: readSettingsPolicy(), settingsPolicy: readSettingsPolicy(),
/** /**
* What this build calls itself: `2.16.57`. Set by the image build from * What this build calls itself: `2.16.57`. Set by the image build from
@@ -295,7 +295,7 @@ export const config = {
* ihasmail should point this at their own tree. ihasmail-inbuxa is itself * ihasmail should point this at their own tree. ihasmail-inbuxa is itself
* such a tree, so the default is INBUXA's fork. * such a tree, so the default is INBUXA's fork.
*/ */
sourceUrl: env("SOURCE_URL", "https://github.com/inbuxa/ihasmail-inbuxa"), sourceUrl: env("SOURCE_URL", "https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa"),
host: env("HOST", "0.0.0.0"), host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080), port: int("PORT", 8080),
/** /**
+1 -1
View File
@@ -18,7 +18,7 @@ const PORT = 18799;
process.env.MOCK_PORT = String(PORT); process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]"; process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password"; 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.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-login-guard"; process.env.APP_SECRET = "test-secret-for-login-guard";
+1 -1
View File
@@ -5,7 +5,7 @@ export const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../we
export const PORT = Number(process.env.MOCK_PORT ?? 8788); 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 * 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 * the mock still behaves like 0.16. Emulating 0.15 properly went with the
* support for it. * support for it.
+4 -4
View File
@@ -40,10 +40,10 @@ export function putBlob(data: Buffer | string, type: string): string {
export const people = [ export const people = [
["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"], ["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"],
["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[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 = [ 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?", "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", "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 }) { 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 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 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 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 ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag &amp; 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 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 &amp; 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 textBlob = putBlob(text, "text/plain");
const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html"); const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html");
const attachments: Obj[] = []; const attachments: Obj[] = [];
+5 -5
View File
@@ -60,8 +60,8 @@ const session = () => ({
* the only way this stays honest about what can be inferred from a * the only way this stays honest about what can be inferred from a
* capability, which is nothing. * 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": {} }) } } }, 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:stalwart:jmap": ACCOUNT }) }, 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, username: USER,
apiUrl: `http://127.0.0.1:${PORT}/jmap/`, apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, 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. // 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 // 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: // 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. // 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 known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]);
const unknown = (body.using ?? []).find((u) => !known.has(u)); 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.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" })); res.end(JSON.stringify({ error: "not found" }));
}).listen(PORT, "127.0.0.1", () => { }).listen(PORT, "127.0.0.1", () => {
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`); console.log(`[mock-server] 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] 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 // Periodically inject a new inbox email to demo push
+2 -2
View File
@@ -178,14 +178,14 @@ export function forgetUpstreamSession(sessionId: string): void {
/* Account locale */ /* Account locale */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const STALWART_CAP = "urn:stalwart:jmap"; const STALWART_CAP = "urn:inbuxa:jmap:registry";
const JMAP_CORE = "urn:ietf:params:jmap:core"; const JMAP_CORE = "urn:ietf:params:jmap:core";
/** /**
* Whether this server has Stalwart's JMAP registry — the `x:` objects that * Whether this server has Stalwart's JMAP registry — the `x:` objects that
* carry credentials, account settings and the newer FileNode shape. * 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 * `capabilities`, which is where a JMAP client would naturally look. Stalwart
* builds that list from a fixed set that has never included this capability; * 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 * it hands it out per-account instead, so it turns up in `primaryAccounts` and
+1 -1
View File
@@ -23,7 +23,7 @@
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" /> <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="apple-touch-icon" href="/img/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<title>INBUXA</title> <title>inbuxa</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "INBUXA", "name": "inbuxa",
"short_name": "INBUXA", "short_name": "inbuxa",
"description": "INBUXA webmail", "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": "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.", "_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", "start_url": "mail",
+2 -2
View File
@@ -20,7 +20,7 @@ export const CAP = {
} as const; } as const;
/** Stalwart's own capability, which carries its `x:` registry methods. */ /** 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. */ /** INBUXA's own capability (contract C-1), on the signed-in account. */
export const INBUXA_CAP = "urn:inbuxa:jmap"; 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 * Whether the server carries a capability at all, wherever it chose to
* advertise it. * 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 * in the session-level `capabilities`, so `hasCapability` alone reports every
* real 0.16 server as though it were older. Look in all three places. * real 0.16 server as though it were older. Look in all three places.
*/ */
+2
View File
@@ -268,6 +268,8 @@ export interface Email {
"header:Received:asText:all"?: string[] | null; "header:Received:asText:all"?: string[] | null;
"header:X-Spam-Status:asText"?: string | null; "header:X-Spam-Status:asText"?: string | null;
"header:X-Spam-Result: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 { export interface Thread {
+52
View File
@@ -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();
});
});
+2 -2
View File
@@ -12,9 +12,9 @@ import { useSession } from "@/store/session";
* One constant rather than the string written out at each of them, because * 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. * 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. // 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. * What this instance calls itself, right now.
* *
+1 -1
View File
@@ -49,7 +49,7 @@ export const UI_LANGUAGES: readonly UiLanguage[] = [
]; ];
/** Where to report a bad translation. Beta languages depend on it. */ /** Where to report a bad translation. Beta languages depend on it. */
export const TRANSLATION_ISSUE_URL = "https://github.com/Coffey-Labs/ihasmail/issues/new?title=Translation%3A%20"; export const TRANSLATION_ISSUE_URL = "https://git.coffeylabs.org/coffey-labs/ihasmail/issues/new?title=Translation%3A%20";
export const DEFAULT_UI_LANGUAGE = "en"; export const DEFAULT_UI_LANGUAGE = "en";
+80
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
import { withBase } from "../basePath"; import { withBase } from "../basePath";
let baseTitle = "INBUXA"; let baseTitle = "inbuxa";
let faviconCanvas: HTMLCanvasElement | null = null; let faviconCanvas: HTMLCanvasElement | null = null;
let baseFavicon: HTMLImageElement | null = null; let baseFavicon: HTMLImageElement | null = null;
+1 -1
View File
@@ -8,4 +8,4 @@
* ihasmail-inbuxa: INBUXA runs a modified ihasmail, so the offer is INBUXA's * ihasmail-inbuxa: INBUXA runs a modified ihasmail, so the offer is INBUXA's
* fork and not the project it came from. * fork and not the project it came from.
*/ */
export const DEFAULT_SOURCE_URL = "https://github.com/inbuxa/ihasmail-inbuxa"; export const DEFAULT_SOURCE_URL = "https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa";
+6 -3
View File
@@ -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.", "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", "Nothing to show": "Nichts anzuzeigen",
"Could not be loaded": "Konnte nicht geladen werden", "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.", "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", "Open inbuxa Admin": "inbuxa Admin öffnen",
"Default group role": "Standardrolle für Gruppen", "Default group role": "Standardrolle für Gruppen",
"A group needs an address.": "Eine Gruppe braucht eine Adresse.", "A group needs an address.": "Eine Gruppe braucht eine Adresse.",
"New group": "Neue Gruppe", "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.", "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", "Builds on": "Baut auf",
"Permissions": "Berechtigungen", "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.", "This role carries permissions yours doesn't.": "Diese Rolle hat Berechtigungen, die Ihre nicht hat.",
"Create role": "Rolle anlegen", "Create role": "Rolle anlegen",
"builds on this one": "baut auf dieser auf", "builds on this one": "baut auf dieser auf",
@@ -1740,6 +1740,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Zur Bestätigung {phrase} eingeben", "To confirm, type {phrase}": "Zur Bestätigung {phrase} eingeben",
"Turn off legacy protocols": "Ältere Mailprotokolle ausschalten", "Turn off legacy protocols": "Ältere Mailprotokolle ausschalten",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Ältere Mailprotokolle sind für Ihre Organisation ausgeschaltet. Nur {app} und JMAP-Apps können sich anmelden.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Ältere Mailprotokolle sind für Ihre Organisation ausgeschaltet. Nur {app} und JMAP-Apps können sich anmelden.",
// ── 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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+6 -3
View File
@@ -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.", "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", "Nothing to show": "Nada que mostrar",
"Could not be loaded": "No se pudo cargar", "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.", "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", "Open inbuxa Admin": "Abrir inbuxa Admin",
"Default group role": "Rol de grupo predeterminado", "Default group role": "Rol de grupo predeterminado",
"A group needs an address.": "Un grupo necesita una dirección.", "A group needs an address.": "Un grupo necesita una dirección.",
"New group": "Nuevo grupo", "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í.", "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", "Builds on": "Se basa en",
"Permissions": "Permisos", "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.", "This role carries permissions yours doesn't.": "Este rol tiene permisos que el suyo no tiene.",
"Create role": "Crear rol", "Create role": "Crear rol",
"builds on this one": "se basa en este", "builds on this one": "se basa en este",
@@ -1713,6 +1713,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Para confirmar, escriba {phrase}", "To confirm, type {phrase}": "Para confirmar, escriba {phrase}",
"Turn off legacy protocols": "Desactivar los protocolos de correo heredados", "Turn off legacy protocols": "Desactivar los protocolos de correo heredados",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Los protocolos de correo heredados están desactivados para su organización. Solo {app} y las aplicaciones JMAP pueden iniciar sesión.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Los protocolos de correo heredados están desactivados para su organización. Solo {app} y las aplicaciones JMAP pueden iniciar sesión.",
// ── 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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+6 -3
View File
@@ -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.", "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", "Nothing to show": "Rien à afficher",
"Could not be loaded": "Chargement impossible", "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.", "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", "Open inbuxa Admin": "Ouvrir inbuxa Admin",
"Default group role": "Rôle de groupe par défaut", "Default group role": "Rôle de groupe par défaut",
"A group needs an address.": "Un groupe a besoin dune adresse.", "A group needs an address.": "Un groupe a besoin dune adresse.",
"New group": "Nouveau groupe", "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 sapplique à tous ceux qui lont ainsi.", "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 sapplique à tous ceux qui lont ainsi.",
"Builds on": "Sappuie sur", "Builds on": "Sappuie sur",
"Permissions": "Autorisations", "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 dabord 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 dabord 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 na pas.", "This role carries permissions yours doesn't.": "Ce rôle comporte des autorisations que le vôtre na pas.",
"Create role": "Créer le rôle", "Create role": "Créer le rôle",
"builds on this one": "sappuie sur celui-ci", "builds on this one": "sappuie sur celui-ci",
@@ -1718,6 +1718,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Pour confirmer, saisissez {phrase}", "To confirm, type {phrase}": "Pour confirmer, saisissez {phrase}",
"Turn off legacy protocols": "Désactiver les protocoles de messagerie historiques", "Turn off legacy protocols": "Désactiver les protocoles de messagerie historiques",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Les protocoles de messagerie historiques sont désactivés pour votre organisation. Seuls {app} et les applications JMAP peuvent se connecter.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Les protocoles de messagerie historiques sont désactivés pour votre organisation. Seuls {app} et les applications JMAP peuvent se connecter.",
// ── 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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+6 -3
View File
@@ -138,8 +138,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。", "The numbers your role can see, as the server reports them.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。",
"Nothing to show": "表示するものはありません", "Nothing to show": "表示するものはありません",
"Could not be loaded": "読み込めませんでした", "Could not be loaded": "読み込めませんでした",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "詳細なメトリクス、配信キュー、ログ、サーバー設定は INBUXA Admin にあります。", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "詳細なメトリクス、配信キュー、ログ、サーバー設定は inbuxa Admin にあります。",
"Open INBUXA Admin": "INBUXA Admin を開く", "Open inbuxa Admin": "inbuxa Admin を開く",
"Default group role": "グループの既定ロール", "Default group role": "グループの既定ロール",
"A group needs an address.": "グループにはアドレスが必要です。", "A group needs an address.": "グループにはアドレスが必要です。",
"New group": "新しいグループ", "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} に付与します。ここでの変更は、この方法でロールを持つ全員に反映されます。", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "メールサーバーはこのロールを既定で {kinds} に付与します。ここでの変更は、この方法でロールを持つ全員に反映されます。",
"Builds on": "継承元", "Builds on": "継承元",
"Permissions": "権限", "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.": "このロールにはあなたのロールにない権限があります。", "This role carries permissions yours doesn't.": "このロールにはあなたのロールにない権限があります。",
"Create role": "ロールを作成", "Create role": "ロールを作成",
"builds on this one": "このロールを継承しています", "builds on this one": "このロールを継承しています",
@@ -1721,6 +1721,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "確認のため {phrase} と入力してください", "To confirm, type {phrase}": "確認のため {phrase} と入力してください",
"Turn off legacy protocols": "従来のメールプロトコルをオフにする", "Turn off legacy protocols": "従来のメールプロトコルをオフにする",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "組織では従来のメールプロトコルがオフになっています。サインインできるのは {app} と JMAP アプリのみです。", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "組織では従来のメールプロトコルがオフになっています。サインインできるのは {app} と JMAP アプリのみです。",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "言語モデルの見解",
"One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+6 -3
View File
@@ -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.", "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", "Nothing to show": "Niets om te tonen",
"Could not be loaded": "Kon niet worden geladen", "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.", "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", "Open inbuxa Admin": "inbuxa Admin openen",
"Default group role": "Standaardrol voor groepen", "Default group role": "Standaardrol voor groepen",
"A group needs an address.": "Een groep heeft een adres nodig.", "A group needs an address.": "Een groep heeft een adres nodig.",
"New group": "Nieuwe groep", "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.", "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", "Builds on": "Bouwt voort op",
"Permissions": "Rechten", "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.", "This role carries permissions yours doesn't.": "Deze rol heeft rechten die uw rol niet heeft.",
"Create role": "Rol aanmaken", "Create role": "Rol aanmaken",
"builds on this one": "bouwt voort op deze", "builds on this one": "bouwt voort op deze",
@@ -1710,6 +1710,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Typ ter bevestiging {phrase}", "To confirm, type {phrase}": "Typ ter bevestiging {phrase}",
"Turn off legacy protocols": "Verouderde mailprotocollen uitschakelen", "Turn off legacy protocols": "Verouderde mailprotocollen uitschakelen",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Verouderde mailprotocollen zijn uitgeschakeld voor uw organisatie. Alleen {app} en JMAP-apps kunnen inloggen.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Verouderde mailprotocollen zijn uitgeschakeld voor uw organisatie. Alleen {app} en JMAP-apps kunnen inloggen.",
// ── 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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+6 -3
View File
@@ -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.", "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", "Nothing to show": "Nada para mostrar",
"Could not be loaded": "Não foi possível carregar", "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.", "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", "Open inbuxa Admin": "Abrir o inbuxa Admin",
"Default group role": "Função padrão de grupo", "Default group role": "Função padrão de grupo",
"A group needs an address.": "Um grupo precisa de um endereço.", "A group needs an address.": "Um grupo precisa de um endereço.",
"New group": "Novo grupo", "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.", "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", "Builds on": "Baseia-se em",
"Permissions": "Permissões", "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.", "This role carries permissions yours doesn't.": "Esta função tem permissões que a sua não tem.",
"Create role": "Criar função", "Create role": "Criar função",
"builds on this one": "baseia-se nesta", "builds on this one": "baseia-se nesta",
@@ -1716,6 +1716,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Para confirmar, digite {phrase}", "To confirm, type {phrase}": "Para confirmar, digite {phrase}",
"Turn off legacy protocols": "Desativar os protocolos de e-mail legados", "Turn off legacy protocols": "Desativar os protocolos de e-mail legados",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Os protocolos de e-mail legados estão desativados para sua organização. Só {app} e aplicativos JMAP podem entrar.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Os protocolos de e-mail legados estão desativados para sua organização. Só {app} e aplicativos JMAP podem entrar.",
// ── 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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+6 -3
View File
@@ -141,8 +141,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.", "The numbers your role can see, as the server reports them.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.",
"Nothing to show": "Нечего показать", "Nothing to show": "Нечего показать",
"Could not be loaded": "Не удалось загрузить", "Could not be loaded": "Не удалось загрузить",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в INBUXA Admin.", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в inbuxa Admin.",
"Open INBUXA Admin": "Открыть INBUXA Admin", "Open inbuxa Admin": "Открыть inbuxa Admin",
"Default group role": "Роль группы по умолчанию", "Default group role": "Роль группы по умолчанию",
"A group needs an address.": "Группе нужен адрес.", "A group needs an address.": "Группе нужен адрес.",
"New group": "Новая группа", "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}. Изменение здесь затронет всех, кто получил её так.", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Почтовый сервер по умолчанию выдаёт эту роль: {kinds}. Изменение здесь затронет всех, кто получил её так.",
"Builds on": "Основана на", "Builds on": "Основана на",
"Permissions": "Разрешения", "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.": "У этой роли есть разрешения, которых нет у вашей.", "This role carries permissions yours doesn't.": "У этой роли есть разрешения, которых нет у вашей.",
"Create role": "Создать роль", "Create role": "Создать роль",
"builds on this one": "основана на этой", "builds on this one": "основана на этой",
@@ -1715,6 +1715,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Для подтверждения введите {phrase}", "To confirm, type {phrase}": "Для подтверждения введите {phrase}",
"Turn off legacy protocols": "Отключить устаревшие почтовые протоколы", "Turn off legacy protocols": "Отключить устаревшие почтовые протоколы",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Устаревшие почтовые протоколы отключены для вашей организации. Входить могут только {app} и приложения JMAP.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Устаревшие почтовые протоколы отключены для вашей организации. Входить могут только {app} и приложения JMAP.",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Мнение языковой модели",
"One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+6 -3
View File
@@ -135,8 +135,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.", "The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.",
"Nothing to show": "Нічого показати", "Nothing to show": "Нічого показати",
"Could not be loaded": "Не вдалося завантажити", "Could not be loaded": "Не вдалося завантажити",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Докладні метрики, черга доставлення, журнали й налаштування сервера є в INBUXA Admin.", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Докладні метрики, черга доставлення, журнали й налаштування сервера є в inbuxa Admin.",
"Open INBUXA Admin": "Відкрити INBUXA Admin", "Open inbuxa Admin": "Відкрити inbuxa Admin",
"Default group role": "Роль групи за замовчуванням", "Default group role": "Роль групи за замовчуванням",
"A group needs an address.": "Групі потрібна адреса.", "A group needs an address.": "Групі потрібна адреса.",
"New group": "Нова група", "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}. Зміна тут стосується всіх, хто отримав її так.", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Поштовий сервер типово надає цю роль: {kinds}. Зміна тут стосується всіх, хто отримав її так.",
"Builds on": "Базується на", "Builds on": "Базується на",
"Permissions": "Дозволи", "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.": "Ця роль має дозволи, яких немає у вашої.", "This role carries permissions yours doesn't.": "Ця роль має дозволи, яких немає у вашої.",
"Create role": "Створити роль", "Create role": "Створити роль",
"builds on this one": "базується на цій", "builds on this one": "базується на цій",
@@ -1709,6 +1709,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Для підтвердження введіть {phrase}", "To confirm, type {phrase}": "Для підтвердження введіть {phrase}",
"Turn off legacy protocols": "Вимкнути застарілі поштові протоколи", "Turn off legacy protocols": "Вимкнути застарілі поштові протоколи",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Застарілі поштові протоколи вимкнено для вашої організації. Входити можуть лише {app} і програми JMAP.", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Застарілі поштові протоколи вимкнено для вашої організації. Входити можуть лише {app} і програми JMAP.",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Думка мовної моделі",
"One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+6 -3
View File
@@ -137,8 +137,8 @@ export const catalog: Catalog = {
"The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。", "The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。",
"Nothing to show": "没有可显示的内容", "Nothing to show": "没有可显示的内容",
"Could not be loaded": "无法加载", "Could not be loaded": "无法加载",
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "详细指标、投递队列、日志和服务器设置都在 INBUXA Admin 中。", "Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "详细指标、投递队列、日志和服务器设置都在 inbuxa Admin 中。",
"Open INBUXA Admin": "打开 INBUXA Admin", "Open inbuxa Admin": "打开 inbuxa Admin",
"Default group role": "默认群组角色", "Default group role": "默认群组角色",
"A group needs an address.": "群组需要一个地址。", "A group needs an address.": "群组需要一个地址。",
"New group": "新建群组", "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}。在此处的更改会影响所有以此方式拥有该角色的人。", "The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "邮件服务器默认将此角色授予{kinds}。在此处的更改会影响所有以此方式拥有该角色的人。",
"Builds on": "基于", "Builds on": "基于",
"Permissions": "权限", "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.": "此角色拥有您的角色所没有的权限。", "This role carries permissions yours doesn't.": "此角色拥有您的角色所没有的权限。",
"Create role": "创建角色", "Create role": "创建角色",
"builds on this one": "基于此角色", "builds on this one": "基于此角色",
@@ -1720,6 +1720,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "请输入 {phrase} 以确认", "To confirm, type {phrase}": "请输入 {phrase} 以确认",
"Turn off legacy protocols": "关闭传统邮件协议", "Turn off legacy protocols": "关闭传统邮件协议",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "您的组织已关闭传统邮件协议。只有 {app} 和 JMAP 应用可以登录。", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "您的组织已关闭传统邮件协议。只有 {app} 和 JMAP 应用可以登录。",
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "语言模型的判断",
"One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一",
}, },
plurals: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+1 -1
View File
@@ -108,7 +108,7 @@ export function isOccurrence(event: CalendarEvent): boolean {
* before it is sent — rejected properties throw, inherited ones are reported to * before it is sent — rejected properties throw, inherited ones are reported to
* the caller — rather than being posted hopefully and believed. * the caller — rather than being posted hopefully and believed.
* *
* [#26]: https://github.com/Coffey-Labs/ihasmail/issues/26 * [#26]: https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/26
*/ */
const OCCURRENCE_REJECTED = new Set([ const OCCURRENCE_REJECTED = new Set([
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd", "baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
+1 -1
View File
@@ -26,7 +26,7 @@ import { useMail } from "./mail";
* `ContactCard/set` down they are the same" was always claiming and is now * `ContactCard/set` down they are the same" was always claiming and is now
* true of. * true of.
* *
* [#173]: https://github.com/Coffey-Labs/ihasmail/issues/173 * [#173]: https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/173
*/ */
/** /**
* The UIDs an address book already holds. * The UIDs an address book already holds.
+3
View File
@@ -1,4 +1,5 @@
import { SPAM_HEADER_PROPS } from "@/lib/spamScore"; 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:Precedence:asText",
"header:Authentication-Results:asText", "header:Authentication-Results:asText",
...SPAM_HEADER_PROPS, ...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"]; export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"];
+7
View File
@@ -2387,6 +2387,13 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.spam-weight.bad { color: var(--danger); } .spam-weight.bad { color: var(--danger); }
.spam-weight.good { color: var(--success); } .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. */ /* The placeholder reference under a template's body. */
.placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; } .placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; }
.placeholder-row { display: contents; } .placeholder-row { display: contents; }
+1 -1
View File
@@ -15,7 +15,7 @@ export function InbuxaWordmark({ className, height }: { className?: string; heig
height={height} height={height}
width={(height * 488) / 112} width={(height * 488) / 112}
role="img" role="img"
aria-label="INBUXA" aria-label="inbuxa"
className={`notranslate ${className ?? ""}`} className={`notranslate ${className ?? ""}`}
fill="currentColor" fill="currentColor"
> >
+1 -1
View File
@@ -101,7 +101,7 @@ export interface DialogChoice {
* which is what "Discard changes" was, on a guard whose whole purpose is to * which is what "Discard changes" was, on a guard whose whole purpose is to
* stop you losing work ([#175]). * stop you losing work ([#175]).
* *
* [#175]: https://github.com/Coffey-Labs/ihasmail/issues/175 * [#175]: https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/175
*/ */
primary?: boolean; primary?: boolean;
} }
+1 -1
View File
@@ -153,7 +153,7 @@ export function LoginPage() {
margin-top, which a second paragraph would repeat as a gap. 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. */} {/* 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 /> <br />
<a href="https://inbuxa.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">inbuxa.org</a> <a href="https://inbuxa.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">inbuxa.org</a>
{" · "} {" · "}
+2 -2
View File
@@ -126,12 +126,12 @@ export function AdminDashboard() {
more numbers will be: this is a glance, and operating the server is 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. */} Stalwart's own administration. The link is the operator's to give. */}
<p className="hint admin-dashboard-note"> <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 && ( {adminUrl && (
<> <>
{" "} {" "}
<a href={adminUrl} target="_blank" rel="noopener noreferrer"> <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> </a>
</> </>
)} )}
+1 -1
View File
@@ -186,7 +186,7 @@ export function RoleSheet({ role, roles, defaults, entries, permissionsError, on
{!creating && can(perms, "Role", "Destroy") && ( {!creating && can(perms, "Role", "Destroy") && (
<DeleteRole <DeleteRole
role={role} 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} onDeleted={onDeleted}
/> />
)} )}
@@ -15,7 +15,7 @@ const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
function signIn(permissions: string[], username = "[email protected]") { function signIn(permissions: string[], username = "[email protected]") {
useSession.setState({ 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 host: HTMLDivElement;
let root: Root; let root: Root;
beforeEach(() => { 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 () => { it("names it, and links it where the operator has said where it is", async () => {
await renderWith("https://admin.example.com"); await renderWith("https://admin.example.com");
const note = host.querySelector(".admin-dashboard-note")!; const note = host.querySelector(".admin-dashboard-note")!;
expect(note.textContent).toContain("INBUXA Admin"); expect(note.textContent).toContain("inbuxa Admin");
const link = note.querySelector("a")!; const link = note.querySelector("a")!;
expect(link.getAttribute("href")).toBe("https://admin.example.com"); expect(link.getAttribute("href")).toBe("https://admin.example.com");
expect(link.getAttribute("rel")).toBe("noopener noreferrer"); 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 () => { it("names it without a link where nobody has", async () => {
await renderWith(null); 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(); expect(host.querySelector(".admin-dashboard-note a")).toBeNull();
}); });
}); });
+67
View File
@@ -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;
}
+8
View File
@@ -15,6 +15,8 @@ import { emlFilename } from "@/lib/text/emlName";
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef"; import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings"; import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
import { spamReport, type SpamReport } from "@/lib/spamScore"; 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 { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, domainOf, formatAddress } from "@/lib/address"; import { displayName, domainOf, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html"; 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 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 authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
const spam = useMemo(() => spamReport(e), [e]); 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); const identities = useMail((st) => st.identities);
/* /*
* Only computed when the warning is on, because the domains it compares * 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></>} {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> <dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</dd>
{spam && <><dt>{translate("Spam filter")}</dt><dd><SpamSummary report={spam} /></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></>} {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> </dl>
)} )}
@@ -425,6 +432,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
</div> </div>
)} )}
<SignatureBanner state={signature} /> <SignatureBanner state={signature} />
{llmBanner && <LlmOpinionBanner opinion={llmBanner} />}
{externalSender && ( {externalSender && (
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}> <div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
<ShieldAlert size={16} /> <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();
});
});
+2 -2
View File
@@ -35,13 +35,13 @@ export function AboutSettings() {
<table className="sessions-table"> <table className="sessions-table">
<tbody> <tbody>
<tr><td>{t("Signed in as")}</td><td>{session?.username}</td></tr> <tr><td>{t("Signed in as")}</td><td>{session?.username}</td></tr>
<tr><td>{t("Mail server")}</td><td className="notranslate" translate="no">INBUXA</td></tr> <tr><td>{t("Mail server")}</td><td className="notranslate" translate="no">inbuxa</td></tr>
<tr><td>{t("Accounts")}</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr> <tr><td>{t("Accounts")}</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
<tr><td>{t("Max upload")}</td><td>{t("{size} MB", { size: Math.round(client.maxSizeUpload / 1048576) })}</td></tr> <tr><td>{t("Max upload")}</td><td>{t("{size} MB", { size: Math.round(client.maxSizeUpload / 1048576) })}</td></tr>
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr> <tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr>
</tbody> </tbody>
</table> </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> <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> <h2>{t("Server capabilities")}</h2>
<div className="row wrap gap-4"> <div className="row wrap gap-4">