Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4926cfa7d | ||
|
|
c0c892dd33 | ||
|
|
f5dd4e5537 | ||
|
|
e7ee09d228 | ||
|
|
1752276229 | ||
|
|
acd9cff4ea | ||
|
|
413ece3bca | ||
|
|
4bbfd7d455 | ||
|
|
d0832013fe | ||
|
|
b79fdb8bab | ||
|
|
8d717e0037 | ||
|
|
c2f13d6a1a | ||
|
|
afb39fac20 | ||
|
|
b514dab62a | ||
|
|
2d7d8952ab |
+1
-1
@@ -75,7 +75,7 @@ APP_NAME=ihasmail
|
||||
# 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
|
||||
# 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) ----
|
||||
#
|
||||
|
||||
+100
-8
@@ -2,14 +2,15 @@
|
||||
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
|
||||
# this directory exists; .github/workflows stays as it was for GitHub.
|
||||
#
|
||||
# There is deliberately no publish job, although publish.yml is in the tree.
|
||||
# Every tag in this repository is one of ihasmail's own upstream tags, the
|
||||
# same commits, and at those tags publish.yml pushed to ihasmail's image, not
|
||||
# an INBUXA one. A tag-driven publish here would ship plain ihasmail under the
|
||||
# INBUXA name the moment upstream tags reached this project -- which happened
|
||||
# once, by hand, and was deleted. Add one back only with a release scheme that
|
||||
# produces tags this repository alone has.
|
||||
#
|
||||
# Releases are cut by pushing a tag named `inbuxa-v<version>`, where
|
||||
# <version> is what scripts/version.mjs says for the tagged commit with the
|
||||
# `+` turned into `-` (e.g. inbuxa-v2026.9.22-g1a2b3c4). The prefix matters:
|
||||
# this repository carries upstream ihasmail's own `v...` tags, on commits it
|
||||
# shares with upstream, and a publish keyed on `v*` would ship plain ihasmail
|
||||
# under the INBUXA name the moment one arrived. Only `inbuxa-v` tags publish.
|
||||
# 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),
|
||||
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
|
||||
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||
@@ -85,3 +86,94 @@ jobs:
|
||||
tag="ihasmail:ci-$(echo "$GITHUB_SHA" | cut -c1-8)"
|
||||
docker build -t "$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
@@ -16,7 +16,7 @@ By participating in this project, you agree to treat other contributors with res
|
||||
|
||||
### 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
|
||||
- Steps to reproduce the issue
|
||||
|
||||
@@ -70,7 +70,7 @@ docker compose up --build -d
|
||||
## Source code
|
||||
|
||||
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
|
||||
came from.
|
||||
|
||||
@@ -94,7 +94,7 @@ Architecture, the mock's switches and how versions are numbered are in
|
||||
|
||||
## 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
|
||||
repository is the remote `ihasmail`, fetch-only, and its `main` is merged in to
|
||||
keep up. Nothing here is pushed there.
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ services:
|
||||
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
||||
APP_NAME: ${APP_NAME:-ihasmail}
|
||||
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"
|
||||
IMAGE_PROXY: "1"
|
||||
volumes:
|
||||
|
||||
@@ -47,7 +47,7 @@ after(() => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Stalwart advertises `urn:stalwart:jmap` only per-account, never in the
|
||||
* Stalwart advertises `urn:inbuxa:jmap:registry` only per-account, never in the
|
||||
* session-level capabilities. Looking for it at the top level alone reported
|
||||
* every real 0.16 server as older than 0.16 — and now that the same check
|
||||
* decides whether a sign-in is allowed at all, that mistake would lock
|
||||
@@ -57,8 +57,8 @@ test("the session is accepted on a server that advertises the registry per-accou
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ihasmail.server.edition, "oss");
|
||||
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
|
||||
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
|
||||
assert.equal(res.body.capabilities["urn:inbuxa:jmap:registry"], undefined, "not where a client would first look");
|
||||
assert.ok("urn:inbuxa:jmap:registry" in res.body.primaryAccounts, "but here, as on a real server");
|
||||
});
|
||||
|
||||
test("the registry reports an account with nothing set up yet", async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.
|
||||
* the registry is known to be there.
|
||||
*/
|
||||
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
const STALWART_CAP = "urn:inbuxa:jmap:registry";
|
||||
const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||
/** Stalwart's id for a singleton object; the number it encodes spells this. */
|
||||
const SINGLETON = "singleton";
|
||||
|
||||
@@ -44,7 +44,7 @@ test("locales that carry no language are dropped, not passed through", () => {
|
||||
|
||||
test("a server without the registry is not asked for anything", async () => {
|
||||
// Sign-in refuses these, so getAccountInfo should never reach the wire for
|
||||
// one - and must not, since a server that cannot parse `urn:stalwart:jmap`
|
||||
// one - and must not, since a server that cannot parse `urn:inbuxa:jmap:registry`
|
||||
// fails the whole request rather than the one call.
|
||||
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
||||
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
|
||||
@@ -57,7 +57,7 @@ test("no capabilities at all is treated the same way", async () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Where Stalwart actually advertises `urn:stalwart:jmap`.
|
||||
* Where Stalwart actually advertises `urn:inbuxa:jmap:registry`.
|
||||
*
|
||||
* Not in the session-level `capabilities`: `Session::new` builds those from a
|
||||
* fixed list that has never carried this capability, in any 0.16.x. It is
|
||||
@@ -70,7 +70,7 @@ test("no capabilities at all is treated the same way", async () => {
|
||||
* This check now decides whether a sign-in is allowed at all, so getting it
|
||||
* wrong would lock every user out of a perfectly good server.
|
||||
*/
|
||||
const STALWART = "urn:stalwart:jmap";
|
||||
const STALWART = "urn:inbuxa:jmap:registry";
|
||||
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
|
||||
|
||||
test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => {
|
||||
|
||||
@@ -278,7 +278,7 @@ if (oauthClientSecret && !publicUrl) {
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "INBUXA"),
|
||||
appName: env("APP_NAME", "inbuxa"),
|
||||
settingsPolicy: readSettingsPolicy(),
|
||||
/**
|
||||
* What this build calls itself: `2.16.57`. Set by the image build from
|
||||
@@ -295,7 +295,7 @@ export const config = {
|
||||
* ihasmail should point this at their own tree. ihasmail-inbuxa is itself
|
||||
* 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"),
|
||||
port: int("PORT", 8080),
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@ const PORT = 18799;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap
|
||||
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:inbuxa:jmap:registry
|
||||
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-login-guard";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../we
|
||||
|
||||
export const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
/**
|
||||
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
|
||||
* Omit `urn:inbuxa:jmap:registry` from the session, so a sign-in can be tested
|
||||
* against a server ihasmail does not support. This is only that: the rest of
|
||||
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
|
||||
* support for it.
|
||||
|
||||
@@ -40,10 +40,10 @@ export function putBlob(data: Buffer | string, type: string): string {
|
||||
export const people = [
|
||||
["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"],
|
||||
["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"],
|
||||
["Stalwart Labs", "hello@stalw.art"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
|
||||
["inbuxa", "hello@inbuxa.org"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
|
||||
];
|
||||
export const subjects = [
|
||||
"Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to Stalwart!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff",
|
||||
"Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to inbuxa!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff",
|
||||
"Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?",
|
||||
"Reminder: dentist appointment", "Flight confirmation – BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in",
|
||||
];
|
||||
@@ -169,8 +169,8 @@ export const STYLED_MARKETING_HTML = `<html><head><style>
|
||||
export function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
|
||||
const id = `e${seq.counter++}`;
|
||||
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
|
||||
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag & drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
|
||||
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
|
||||
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://inbuxa.org">Drag & drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
|
||||
const textBlob = putBlob(text, "text/plain");
|
||||
const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html");
|
||||
const attachments: Obj[] = [];
|
||||
|
||||
@@ -60,8 +60,8 @@ const session = () => ({
|
||||
* the only way this stays honest about what can be inferred from a
|
||||
* capability, which is nothing.
|
||||
*/
|
||||
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
|
||||
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:inbuxa:jmap:registry": {} }) } } },
|
||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:inbuxa:jmap:registry": ACCOUNT }) },
|
||||
username: USER,
|
||||
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
||||
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
|
||||
@@ -103,7 +103,7 @@ export const server = createServer(async (req, res) => {
|
||||
// call that wanted it - which is why an over-eager `using` is so damaging.
|
||||
// Stalwart decides this by parsing the urn, not by looking it up in the
|
||||
// session, so a capability it hands out per-account is still usable here:
|
||||
// `urn:stalwart:jmap` never appears in the session-level capabilities and
|
||||
// `urn:inbuxa:jmap:registry` never appears in the session-level capabilities and
|
||||
// the registry calls that name it work all the same.
|
||||
const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]);
|
||||
const unknown = (body.using ?? []).find((u) => !known.has(u));
|
||||
@@ -204,8 +204,8 @@ export const server = createServer(async (req, res) => {
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
}).listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
|
||||
console.log(`[mock-stalwart] run the app with: MAIL_SERVER_URL=http://127.0.0.1:${PORT} npm run dev`);
|
||||
console.log(`[mock-server] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
|
||||
console.log(`[mock-server] run the app with: MAIL_SERVER_URL=http://127.0.0.1:${PORT} npm run dev`);
|
||||
});
|
||||
|
||||
// Periodically inject a new inbox email to demo push
|
||||
|
||||
@@ -178,14 +178,14 @@ export function forgetUpstreamSession(sessionId: string): void {
|
||||
/* Account locale */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
const STALWART_CAP = "urn:inbuxa:jmap:registry";
|
||||
const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||
|
||||
/**
|
||||
* Whether this server has Stalwart's JMAP registry — the `x:` objects that
|
||||
* carry credentials, account settings and the newer FileNode shape.
|
||||
*
|
||||
* `urn:stalwart:jmap` is the marker, but **not** in the session-level
|
||||
* `urn:inbuxa:jmap:registry` is the marker, but **not** in the session-level
|
||||
* `capabilities`, which is where a JMAP client would naturally look. Stalwart
|
||||
* builds that list from a fixed set that has never included this capability;
|
||||
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" />
|
||||
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>INBUXA</title>
|
||||
<title>inbuxa</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "INBUXA",
|
||||
"short_name": "INBUXA",
|
||||
"description": "INBUXA webmail",
|
||||
"name": "inbuxa",
|
||||
"short_name": "inbuxa",
|
||||
"description": "inbuxa webmail",
|
||||
"_comment": "JSON has no comments, so: every URL below is relative on purpose. Manifest members resolve against the manifest's own address, so these follow BASE_PATH with nothing substituted into them at build time. Root-absolute values pinned the installed app, its scope and its shortcuts to the domain root whatever the mount was.",
|
||||
"_comment_id": "There is deliberately no `id`. It is the one member NOT resolved against this file's address -- the spec resolves it against the origin of start_url, so `./`, `mail` and `/mail` all mean the same thing at the domain root and none of them can name a subpath mount. Adding one would therefore break the same thing the note above describes. Worse, the default id IS start_url, which is already mount-correct: writing an id now would give every installed copy a new identity and orphan it as a second app rather than updating it. If one is ever wanted it has to be substituted at build time from BASE_PATH, and the changeover costs everybody their install.",
|
||||
"start_url": "mail",
|
||||
|
||||
@@ -20,7 +20,7 @@ export const CAP = {
|
||||
} as const;
|
||||
|
||||
/** Stalwart's own capability, which carries its `x:` registry methods. */
|
||||
export const STALWART_CAP = "urn:stalwart:jmap";
|
||||
export const STALWART_CAP = "urn:inbuxa:jmap:registry";
|
||||
|
||||
/** INBUXA's own capability (contract C-1), on the signed-in account. */
|
||||
export const INBUXA_CAP = "urn:inbuxa:jmap";
|
||||
@@ -155,7 +155,7 @@ export class JmapClient {
|
||||
* Whether the server carries a capability at all, wherever it chose to
|
||||
* advertise it.
|
||||
*
|
||||
* Stalwart hands `urn:stalwart:jmap` out per-account rather than putting it
|
||||
* Stalwart hands `urn:inbuxa:jmap:registry` out per-account rather than putting it
|
||||
* in the session-level `capabilities`, so `hasCapability` alone reports every
|
||||
* real 0.16 server as though it were older. Look in all three places.
|
||||
*/
|
||||
|
||||
@@ -268,6 +268,8 @@ export interface Email {
|
||||
"header:Received:asText:all"?: string[] | null;
|
||||
"header:X-Spam-Status:asText"?: string | null;
|
||||
"header:X-Spam-Result:asText"?: string | null;
|
||||
/** inbuxa: the language model's opinion, when AI spam classification is on (lib/llmOpinion). */
|
||||
"header:X-Spam-LLM:asText"?: string | null;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LLM_HEADER_PROP, llmOpinion, parseLlmOpinion } from "@/lib/llmOpinion";
|
||||
|
||||
/*
|
||||
* The header as inbuxa-server writes it (crates/features/src/ai/answer.rs):
|
||||
* `X-Spam-LLM: <TAG>`, optionally followed by the explanation in one pair of
|
||||
* parentheses, folded at 78 columns.
|
||||
*/
|
||||
describe("parseLlmOpinion", () => {
|
||||
it("reads category, confidence and explanation", () => {
|
||||
expect(parseLlmOpinion("LLM_UNSOLICITED_HIGH (Promotes a product the reader never asked about)")).toEqual({
|
||||
tag: "LLM_UNSOLICITED_HIGH",
|
||||
category: "Unsolicited",
|
||||
confidence: "High",
|
||||
explanation: "Promotes a product the reader never asked about",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a tag with no confidence and no explanation", () => {
|
||||
expect(parseLlmOpinion("LLM_LEGITIMATE")).toEqual({
|
||||
tag: "LLM_LEGITIMATE",
|
||||
category: "Legitimate",
|
||||
confidence: null,
|
||||
explanation: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an operator's multi-word category whole", () => {
|
||||
const o = parseLlmOpinion("LLM_COLD_OUTREACH_MEDIUM");
|
||||
expect(o?.category).toBe("Cold outreach");
|
||||
expect(o?.confidence).toBe("Medium");
|
||||
// An unknown last word is part of the category, not a confidence.
|
||||
expect(parseLlmOpinion("LLM_COLD_OUTREACH")?.category).toBe("Cold outreach");
|
||||
expect(parseLlmOpinion("LLM_COLD_OUTREACH")?.confidence).toBeNull();
|
||||
});
|
||||
|
||||
it("unfolds a folded header and keeps inner parentheses", () => {
|
||||
const o = parseLlmOpinion("LLM_HARMFUL_LOW (Asks for a password\r\n (urgently) via a link)");
|
||||
expect(o?.explanation).toBe("Asks for a password (urgently) via a link");
|
||||
});
|
||||
|
||||
it("returns null for anything that isn't the server's tag", () => {
|
||||
for (const raw of [null, undefined, "", " ", "Yes, score=6.7", "LLM_", "llm_unsolicited_high", "X LLM_SPAM"]) {
|
||||
expect(parseLlmOpinion(raw), String(raw)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the JMAP property a full message carries", () => {
|
||||
expect(llmOpinion({ [LLM_HEADER_PROP]: "LLM_LEGITIMATE_HIGH" })?.category).toBe("Legitimate");
|
||||
expect(llmOpinion({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,9 @@ import { useSession } from "@/store/session";
|
||||
* One constant rather than the string written out at each of them, because
|
||||
* three copies of a default is how two of them end up stale.
|
||||
*/
|
||||
// ihasmail-inbuxa: INBUXA's webmail goes by INBUXA, so it can't be taken for
|
||||
// ihasmail-inbuxa: inbuxa's webmail goes by inbuxa, so it can't be taken for
|
||||
// public ihasmail. APP_NAME still names a deployment whatever it likes.
|
||||
export const DEFAULT_APP_NAME = "INBUXA";
|
||||
export const DEFAULT_APP_NAME = "inbuxa";
|
||||
/**
|
||||
* What this instance calls itself, right now.
|
||||
*
|
||||
|
||||
@@ -49,7 +49,7 @@ export const UI_LANGUAGES: readonly UiLanguage[] = [
|
||||
];
|
||||
|
||||
/** 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";
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* inbuxa: the language model's opinion, read back off the message.
|
||||
*
|
||||
* When the server's AI spam classification is on (inbuxa-server,
|
||||
* docs/spec/features/ai-spam-classification.md), it writes the model's answer
|
||||
* into an `X-Spam-LLM` header at delivery:
|
||||
*
|
||||
* X-Spam-LLM: LLM_UNSOLICITED_HIGH (Promotes a product the reader never asked about)
|
||||
*
|
||||
* a tag, then optionally the model's explanation in parentheses. The tag is
|
||||
* `LLM_` + category, or `LLM_` + category + `_` + confidence, uppercased with
|
||||
* anything outside A-Z and 0-9 turned into `_`. The explanation is already
|
||||
* sanitized by the server and may arrive as encoded words, which the JMAP
|
||||
* `asText` form decodes.
|
||||
*
|
||||
* Like `spamScore`, nothing here judges anything: it only reads what the
|
||||
* server wrote. It is one signal the spam filter weighed among many, and the
|
||||
* UI says so.
|
||||
*/
|
||||
|
||||
/** The JMAP property that carries the header, decoded and unfolded. */
|
||||
export const LLM_HEADER_PROP = "header:X-Spam-LLM:asText" as const;
|
||||
|
||||
/**
|
||||
* Confidence words the fork's default prompt uses. A tag ending in one of
|
||||
* these is read as category + confidence; anything else is all category,
|
||||
* since an operator's own categories may contain underscores.
|
||||
*/
|
||||
const CONFIDENCES = new Set(["LOW", "MEDIUM", "HIGH"]);
|
||||
|
||||
export interface LlmOpinion {
|
||||
/** The tag as the server wrote it, e.g. `LLM_UNSOLICITED_HIGH`. */
|
||||
tag: string;
|
||||
/** Readable category, e.g. `Unsolicited`. */
|
||||
category: string;
|
||||
/** Readable confidence, e.g. `High`, where the tag carried one. */
|
||||
confidence: string | null;
|
||||
/** The model's own explanation, as plain text, where there is one. */
|
||||
explanation: string | null;
|
||||
}
|
||||
|
||||
/** `UNSOLICITED_BULK` -> `Unsolicited bulk`. */
|
||||
function readable(words: string[]): string {
|
||||
const s = words.join(" ").toLowerCase();
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/** Headers arrive folded, so tabs and newlines are whitespace like any other. */
|
||||
function flatten(v: string | null | undefined): string {
|
||||
return (v ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function parseLlmOpinion(raw: string | null | undefined): LlmOpinion | null {
|
||||
const s = flatten(raw);
|
||||
const m = /^(LLM_[A-Z0-9_]+)(?:\s+(.*))?$/.exec(s);
|
||||
if (!m) return null;
|
||||
const tag = m[1]!;
|
||||
const parts = tag.slice("LLM_".length).split("_").filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
let confidence: string | null = null;
|
||||
if (parts.length > 1 && CONFIDENCES.has(parts[parts.length - 1]!)) {
|
||||
confidence = readable([parts.pop()!]);
|
||||
}
|
||||
|
||||
let explanation: string | null = null;
|
||||
const rest = (m[2] ?? "").trim();
|
||||
if (rest) {
|
||||
// The server wraps the explanation in one pair of parentheses.
|
||||
const inner = rest.startsWith("(") && rest.endsWith(")") ? rest.slice(1, -1).trim() : rest;
|
||||
explanation = inner || null;
|
||||
}
|
||||
|
||||
return { tag, category: readable(parts), confidence, explanation };
|
||||
}
|
||||
|
||||
/** The opinion on a message, if the server recorded one. */
|
||||
export function llmOpinion(email: { [LLM_HEADER_PROP]?: string | null }): LlmOpinion | null {
|
||||
return parseLlmOpinion(email[LLM_HEADER_PROP]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { withBase } from "../basePath";
|
||||
|
||||
let baseTitle = "INBUXA";
|
||||
let baseTitle = "inbuxa";
|
||||
let faviconCanvas: HTMLCanvasElement | null = null;
|
||||
let baseFavicon: HTMLImageElement | null = null;
|
||||
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
* ihasmail-inbuxa: INBUXA runs a modified ihasmail, so the offer is INBUXA's
|
||||
* 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";
|
||||
|
||||
@@ -147,8 +147,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "Die Zahlen, die Ihre Rolle sehen darf, so wie der Server sie meldet.",
|
||||
"Nothing to show": "Nichts anzuzeigen",
|
||||
"Could not be loaded": "Konnte nicht geladen werden",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Detaillierte Metriken, die Zustellwarteschlange, Protokolle und Servereinstellungen finden Sie in INBUXA Admin.",
|
||||
"Open INBUXA Admin": "INBUXA Admin öffnen",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Detaillierte Metriken, die Zustellwarteschlange, Protokolle und Servereinstellungen finden Sie in inbuxa Admin.",
|
||||
"Open inbuxa Admin": "inbuxa Admin öffnen",
|
||||
"Default group role": "Standardrolle für Gruppen",
|
||||
"A group needs an address.": "Eine Gruppe braucht eine Adresse.",
|
||||
"New group": "Neue Gruppe",
|
||||
@@ -220,7 +220,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Der Mailserver vergibt diese Rolle standardmäßig an {kinds}. Eine Änderung betrifft alle, die sie auf diesem Weg haben.",
|
||||
"Builds on": "Baut auf",
|
||||
"Permissions": "Berechtigungen",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Der Mailserver vergibt diese Rolle standardmäßig, daher kann sie nicht gelöscht werden. Ändern Sie zuerst die Standardwerte in INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Der Mailserver vergibt diese Rolle standardmäßig, daher kann sie nicht gelöscht werden. Ändern Sie zuerst die Standardwerte in inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "Diese Rolle hat Berechtigungen, die Ihre nicht hat.",
|
||||
"Create role": "Rolle anlegen",
|
||||
"builds on this one": "baut auf dieser auf",
|
||||
@@ -1740,6 +1740,9 @@ export const catalog: Catalog = {
|
||||
"To confirm, type {phrase}": "Zur Bestätigung {phrase} eingeben",
|
||||
"Turn off legacy protocols": "Ältere Mailprotokolle ausschalten",
|
||||
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Ältere Mailprotokolle sind für Ihre Organisation ausgeschaltet. Nur {app} und JMAP-Apps können sich anmelden.",
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "Einschätzung des Sprachmodells",
|
||||
"One of several signals the spam filter weighed": "Eines von mehreren Signalen, die der Spamfilter berücksichtigt hat",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -139,8 +139,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "Las cifras que su rol puede ver, tal como las informa el servidor.",
|
||||
"Nothing to show": "Nada que mostrar",
|
||||
"Could not be loaded": "No se pudo cargar",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Las métricas detalladas, la cola de entrega, los registros y los ajustes del servidor están en INBUXA Admin.",
|
||||
"Open INBUXA Admin": "Abrir INBUXA Admin",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Las métricas detalladas, la cola de entrega, los registros y los ajustes del servidor están en inbuxa Admin.",
|
||||
"Open inbuxa Admin": "Abrir inbuxa Admin",
|
||||
"Default group role": "Rol de grupo predeterminado",
|
||||
"A group needs an address.": "Un grupo necesita una dirección.",
|
||||
"New group": "Nuevo grupo",
|
||||
@@ -212,7 +212,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "El servidor de correo asigna este rol de forma predeterminada a {kinds}. Un cambio aquí afecta a todos los que lo tienen así.",
|
||||
"Builds on": "Se basa en",
|
||||
"Permissions": "Permisos",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "El servidor de correo asigna este rol de forma predeterminada, así que no se puede eliminar. Cambie primero los valores predeterminados en INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "El servidor de correo asigna este rol de forma predeterminada, así que no se puede eliminar. Cambie primero los valores predeterminados en inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "Este rol tiene permisos que el suyo no tiene.",
|
||||
"Create role": "Crear rol",
|
||||
"builds on this one": "se basa en este",
|
||||
@@ -1713,6 +1713,9 @@ export const catalog: Catalog = {
|
||||
"To confirm, type {phrase}": "Para confirmar, escriba {phrase}",
|
||||
"Turn off legacy protocols": "Desactivar los protocolos de correo heredados",
|
||||
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Los protocolos de correo heredados están desactivados para su organización. Solo {app} y las aplicaciones JMAP pueden iniciar sesión.",
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "Opinión del modelo de lenguaje",
|
||||
"One of several signals the spam filter weighed": "Una de varias señales que el filtro de spam ha tenido en cuenta",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -144,8 +144,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "Les chiffres que votre rôle permet de voir, tels que le serveur les indique.",
|
||||
"Nothing to show": "Rien à afficher",
|
||||
"Could not be loaded": "Chargement impossible",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Les métriques détaillées, la file de distribution, les journaux et les réglages du serveur se trouvent dans INBUXA Admin.",
|
||||
"Open INBUXA Admin": "Ouvrir INBUXA Admin",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Les métriques détaillées, la file de distribution, les journaux et les réglages du serveur se trouvent dans inbuxa Admin.",
|
||||
"Open inbuxa Admin": "Ouvrir inbuxa Admin",
|
||||
"Default group role": "Rôle de groupe par défaut",
|
||||
"A group needs an address.": "Un groupe a besoin d’une adresse.",
|
||||
"New group": "Nouveau groupe",
|
||||
@@ -217,7 +217,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Le serveur de messagerie attribue ce rôle par défaut à {kinds}. Une modification ici s’applique à tous ceux qui l’ont ainsi.",
|
||||
"Builds on": "S’appuie sur",
|
||||
"Permissions": "Autorisations",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Le serveur de messagerie attribue ce rôle par défaut : il ne peut donc pas être supprimé. Modifiez d’abord les valeurs par défaut dans INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Le serveur de messagerie attribue ce rôle par défaut : il ne peut donc pas être supprimé. Modifiez d’abord les valeurs par défaut dans inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "Ce rôle comporte des autorisations que le vôtre n’a pas.",
|
||||
"Create role": "Créer le rôle",
|
||||
"builds on this one": "s’appuie sur celui-ci",
|
||||
@@ -1718,6 +1718,9 @@ export const catalog: Catalog = {
|
||||
"To confirm, type {phrase}": "Pour confirmer, saisissez {phrase}",
|
||||
"Turn off legacy protocols": "Désactiver les protocoles de messagerie historiques",
|
||||
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Les protocoles de messagerie historiques sont désactivés pour votre organisation. Seuls {app} et les applications JMAP peuvent se connecter.",
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "Avis du modèle de langage",
|
||||
"One of several signals the spam filter weighed": "Un signal parmi d'autres pris en compte par le filtre antispam",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -138,8 +138,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。",
|
||||
"Nothing to show": "表示するものはありません",
|
||||
"Could not be loaded": "読み込めませんでした",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "詳細なメトリクス、配信キュー、ログ、サーバー設定は INBUXA Admin にあります。",
|
||||
"Open INBUXA Admin": "INBUXA Admin を開く",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "詳細なメトリクス、配信キュー、ログ、サーバー設定は inbuxa Admin にあります。",
|
||||
"Open inbuxa Admin": "inbuxa Admin を開く",
|
||||
"Default group role": "グループの既定ロール",
|
||||
"A group needs an address.": "グループにはアドレスが必要です。",
|
||||
"New group": "新しいグループ",
|
||||
@@ -211,7 +211,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "メールサーバーはこのロールを既定で {kinds} に付与します。ここでの変更は、この方法でロールを持つ全員に反映されます。",
|
||||
"Builds on": "継承元",
|
||||
"Permissions": "権限",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "メールサーバーがこのロールを既定で付与するため、削除できません。先に INBUXA Admin で既定値を変更してください。",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "メールサーバーがこのロールを既定で付与するため、削除できません。先に inbuxa Admin で既定値を変更してください。",
|
||||
"This role carries permissions yours doesn't.": "このロールにはあなたのロールにない権限があります。",
|
||||
"Create role": "ロールを作成",
|
||||
"builds on this one": "このロールを継承しています",
|
||||
@@ -1721,6 +1721,9 @@ export const catalog: Catalog = {
|
||||
"To confirm, type {phrase}": "確認のため {phrase} と入力してください",
|
||||
"Turn off legacy protocols": "従来のメールプロトコルをオフにする",
|
||||
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "組織では従来のメールプロトコルがオフになっています。サインインできるのは {app} と JMAP アプリのみです。",
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "言語モデルの見解",
|
||||
"One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -134,8 +134,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "De cijfers die uw rol mag zien, zoals de server ze meldt.",
|
||||
"Nothing to show": "Niets om te tonen",
|
||||
"Could not be loaded": "Kon niet worden geladen",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in INBUXA Admin.",
|
||||
"Open INBUXA Admin": "INBUXA Admin openen",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in inbuxa Admin.",
|
||||
"Open inbuxa Admin": "inbuxa Admin openen",
|
||||
"Default group role": "Standaardrol voor groepen",
|
||||
"A group needs an address.": "Een groep heeft een adres nodig.",
|
||||
"New group": "Nieuwe groep",
|
||||
@@ -207,7 +207,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "De mailserver geeft deze rol standaard aan {kinds}. Een wijziging hier geldt voor iedereen die hem zo heeft.",
|
||||
"Builds on": "Bouwt voort op",
|
||||
"Permissions": "Rechten",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "De mailserver geeft standaard deze rol, dus hij kan niet worden verwijderd. Wijzig eerst de standaardwaarden in INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "De mailserver geeft standaard deze rol, dus hij kan niet worden verwijderd. Wijzig eerst de standaardwaarden in inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "Deze rol heeft rechten die uw rol niet heeft.",
|
||||
"Create role": "Rol aanmaken",
|
||||
"builds on this one": "bouwt voort op deze",
|
||||
@@ -1710,6 +1710,9 @@ export const catalog: Catalog = {
|
||||
"To confirm, type {phrase}": "Typ ter bevestiging {phrase}",
|
||||
"Turn off legacy protocols": "Verouderde mailprotocollen uitschakelen",
|
||||
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Verouderde mailprotocollen zijn uitgeschakeld voor uw organisatie. Alleen {app} en JMAP-apps kunnen inloggen.",
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "Oordeel van het taalmodel",
|
||||
"One of several signals the spam filter weighed": "Een van meerdere signalen die het spamfilter heeft meegewogen",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -142,8 +142,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "Os números que sua função pode ver, como o servidor os informa.",
|
||||
"Nothing to show": "Nada para mostrar",
|
||||
"Could not be loaded": "Não foi possível carregar",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Métricas detalhadas, a fila de entrega, os registros e as configurações do servidor ficam no INBUXA Admin.",
|
||||
"Open INBUXA Admin": "Abrir o INBUXA Admin",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Métricas detalhadas, a fila de entrega, os registros e as configurações do servidor ficam no inbuxa Admin.",
|
||||
"Open inbuxa Admin": "Abrir o inbuxa Admin",
|
||||
"Default group role": "Função padrão de grupo",
|
||||
"A group needs an address.": "Um grupo precisa de um endereço.",
|
||||
"New group": "Novo grupo",
|
||||
@@ -215,7 +215,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "O servidor de e-mail atribui esta função por padrão a {kinds}. Uma alteração aqui vale para todos que a têm dessa forma.",
|
||||
"Builds on": "Baseia-se em",
|
||||
"Permissions": "Permissões",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "O servidor de e-mail atribui esta função por padrão, então ela não pode ser excluída. Altere primeiro os padrões no INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "O servidor de e-mail atribui esta função por padrão, então ela não pode ser excluída. Altere primeiro os padrões no inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "Esta função tem permissões que a sua não tem.",
|
||||
"Create role": "Criar função",
|
||||
"builds on this one": "baseia-se nesta",
|
||||
@@ -1716,6 +1716,9 @@ export const catalog: Catalog = {
|
||||
"To confirm, type {phrase}": "Para confirmar, digite {phrase}",
|
||||
"Turn off legacy protocols": "Desativar os protocolos de e-mail legados",
|
||||
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Os protocolos de e-mail legados estão desativados para sua organização. Só {app} e aplicativos JMAP podem entrar.",
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "Opinião do modelo de linguagem",
|
||||
"One of several signals the spam filter weighed": "Um dos vários sinais considerados pelo filtro de spam",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -141,8 +141,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.",
|
||||
"Nothing to show": "Нечего показать",
|
||||
"Could not be loaded": "Не удалось загрузить",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в INBUXA Admin.",
|
||||
"Open INBUXA Admin": "Открыть INBUXA Admin",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в inbuxa Admin.",
|
||||
"Open inbuxa Admin": "Открыть inbuxa Admin",
|
||||
"Default group role": "Роль группы по умолчанию",
|
||||
"A group needs an address.": "Группе нужен адрес.",
|
||||
"New group": "Новая группа",
|
||||
@@ -214,7 +214,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Почтовый сервер по умолчанию выдаёт эту роль: {kinds}. Изменение здесь затронет всех, кто получил её так.",
|
||||
"Builds on": "Основана на",
|
||||
"Permissions": "Разрешения",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Почтовый сервер выдаёт эту роль по умолчанию, поэтому её нельзя удалить. Сначала измените значения по умолчанию в INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Почтовый сервер выдаёт эту роль по умолчанию, поэтому её нельзя удалить. Сначала измените значения по умолчанию в inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "У этой роли есть разрешения, которых нет у вашей.",
|
||||
"Create role": "Создать роль",
|
||||
"builds on this one": "основана на этой",
|
||||
@@ -1715,6 +1715,9 @@ export const catalog: Catalog = {
|
||||
"To confirm, type {phrase}": "Для подтверждения введите {phrase}",
|
||||
"Turn off legacy protocols": "Отключить устаревшие почтовые протоколы",
|
||||
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Устаревшие почтовые протоколы отключены для вашей организации. Входить могут только {app} и приложения JMAP.",
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "Мнение языковой модели",
|
||||
"One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -135,8 +135,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.",
|
||||
"Nothing to show": "Нічого показати",
|
||||
"Could not be loaded": "Не вдалося завантажити",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Докладні метрики, черга доставлення, журнали й налаштування сервера є в INBUXA Admin.",
|
||||
"Open INBUXA Admin": "Відкрити INBUXA Admin",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "Докладні метрики, черга доставлення, журнали й налаштування сервера є в inbuxa Admin.",
|
||||
"Open inbuxa Admin": "Відкрити inbuxa Admin",
|
||||
"Default group role": "Роль групи за замовчуванням",
|
||||
"A group needs an address.": "Групі потрібна адреса.",
|
||||
"New group": "Нова група",
|
||||
@@ -208,7 +208,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Поштовий сервер типово надає цю роль: {kinds}. Зміна тут стосується всіх, хто отримав її так.",
|
||||
"Builds on": "Базується на",
|
||||
"Permissions": "Дозволи",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Поштовий сервер типово надає цю роль, тому її не можна видалити. Спершу змініть типові значення в INBUXA Admin.",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "Поштовий сервер типово надає цю роль, тому її не можна видалити. Спершу змініть типові значення в inbuxa Admin.",
|
||||
"This role carries permissions yours doesn't.": "Ця роль має дозволи, яких немає у вашої.",
|
||||
"Create role": "Створити роль",
|
||||
"builds on this one": "базується на цій",
|
||||
@@ -1709,6 +1709,9 @@ export const catalog: Catalog = {
|
||||
"To confirm, type {phrase}": "Для підтвердження введіть {phrase}",
|
||||
"Turn off legacy protocols": "Вимкнути застарілі поштові протоколи",
|
||||
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Застарілі поштові протоколи вимкнено для вашої організації. Входити можуть лише {app} і програми JMAP.",
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "Думка мовної моделі",
|
||||
"One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -137,8 +137,8 @@ export const catalog: Catalog = {
|
||||
"The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。",
|
||||
"Nothing to show": "没有可显示的内容",
|
||||
"Could not be loaded": "无法加载",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "详细指标、投递队列、日志和服务器设置都在 INBUXA Admin 中。",
|
||||
"Open INBUXA Admin": "打开 INBUXA Admin",
|
||||
"Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.": "详细指标、投递队列、日志和服务器设置都在 inbuxa Admin 中。",
|
||||
"Open inbuxa Admin": "打开 inbuxa Admin",
|
||||
"Default group role": "默认群组角色",
|
||||
"A group needs an address.": "群组需要一个地址。",
|
||||
"New group": "新建群组",
|
||||
@@ -210,7 +210,7 @@ export const catalog: Catalog = {
|
||||
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "邮件服务器默认将此角色授予{kinds}。在此处的更改会影响所有以此方式拥有该角色的人。",
|
||||
"Builds on": "基于",
|
||||
"Permissions": "权限",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "邮件服务器默认授予此角色,因此无法删除。请先在 INBUXA Admin 中更改默认设置。",
|
||||
"The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.": "邮件服务器默认授予此角色,因此无法删除。请先在 inbuxa Admin 中更改默认设置。",
|
||||
"This role carries permissions yours doesn't.": "此角色拥有您的角色所没有的权限。",
|
||||
"Create role": "创建角色",
|
||||
"builds on this one": "基于此角色",
|
||||
@@ -1720,6 +1720,9 @@ export const catalog: Catalog = {
|
||||
"To confirm, type {phrase}": "请输入 {phrase} 以确认",
|
||||
"Turn off legacy protocols": "关闭传统邮件协议",
|
||||
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "您的组织已关闭传统邮件协议。只有 {app} 和 JMAP 应用可以登录。",
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "语言模型的判断",
|
||||
"One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -108,7 +108,7 @@ export function isOccurrence(event: CalendarEvent): boolean {
|
||||
* before it is sent — rejected properties throw, inherited ones are reported to
|
||||
* 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([
|
||||
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useMail } from "./mail";
|
||||
* `ContactCard/set` down they are the same" was always claiming and is now
|
||||
* 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.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SPAM_HEADER_PROPS } from "@/lib/spamScore";
|
||||
import { LLM_HEADER_PROP } from "@/lib/llmOpinion";
|
||||
|
||||
|
||||
/*
|
||||
@@ -67,6 +68,8 @@ export const FULL_PROPS = [
|
||||
"header:Precedence:asText",
|
||||
"header:Authentication-Results:asText",
|
||||
...SPAM_HEADER_PROPS,
|
||||
// inbuxa: the language model's opinion, when the server wrote one
|
||||
LLM_HEADER_PROP,
|
||||
];
|
||||
|
||||
export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"];
|
||||
|
||||
@@ -2387,6 +2387,13 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
||||
.spam-weight.bad { color: var(--danger); }
|
||||
.spam-weight.good { color: var(--success); }
|
||||
|
||||
/* inbuxa: the language model's opinion, in the details and above a message in
|
||||
Junk (views/mail/LlmOpinion.tsx). The explanation is the model's own words,
|
||||
so it keeps its line breaks out and wraps rather than widening the pane. */
|
||||
.llm-opinion { display: flex; flex-direction: column; gap: 4px; }
|
||||
.llm-heading { display: inline-flex; flex-wrap: wrap; gap: .4em; align-items: baseline; }
|
||||
.llm-explanation { overflow-wrap: anywhere; }
|
||||
|
||||
/* The placeholder reference under a template's body. */
|
||||
.placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; }
|
||||
.placeholder-row { display: contents; }
|
||||
|
||||
@@ -15,7 +15,7 @@ export function InbuxaWordmark({ className, height }: { className?: string; heig
|
||||
height={height}
|
||||
width={(height * 488) / 112}
|
||||
role="img"
|
||||
aria-label="INBUXA"
|
||||
aria-label="inbuxa"
|
||||
className={`notranslate ${className ?? ""}`}
|
||||
fill="currentColor"
|
||||
>
|
||||
|
||||
@@ -101,7 +101,7 @@ export interface DialogChoice {
|
||||
* which is what "Discard changes" was, on a guard whose whole purpose is to
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export function LoginPage() {
|
||||
margin-top, which a second paragraph would repeat as a gap.
|
||||
*/}
|
||||
{/* ihasmail-inbuxa: this build is INBUXA's webmail, and its site is INBUXA's. */}
|
||||
<span className="notranslate" translate="no">INBUXA webmail v{APP_VERSION}</span>
|
||||
<span className="notranslate" translate="no">inbuxa webmail v{APP_VERSION}</span>
|
||||
<br />
|
||||
<a href="https://inbuxa.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">inbuxa.org</a>
|
||||
{" · "}
|
||||
|
||||
@@ -126,12 +126,12 @@ export function AdminDashboard() {
|
||||
more numbers will be: this is a glance, and operating the server is
|
||||
Stalwart's own administration. The link is the operator's to give. */}
|
||||
<p className="hint admin-dashboard-note">
|
||||
{t("Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.")}
|
||||
{t("Detailed metrics, the delivery queue, logs and server settings are in inbuxa Admin.")}
|
||||
{adminUrl && (
|
||||
<>
|
||||
{" "}
|
||||
<a href={adminUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t("Open INBUXA Admin")} <ExternalLink size={13} aria-hidden="true" />
|
||||
{t("Open inbuxa Admin")} <ExternalLink size={13} aria-hidden="true" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -186,7 +186,7 @@ export function RoleSheet({ role, roles, defaults, entries, permissionsError, on
|
||||
{!creating && can(perms, "Role", "Destroy") && (
|
||||
<DeleteRole
|
||||
role={role}
|
||||
blocked={kinds.length ? t("The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.") : locked ? t("This role carries permissions yours doesn't.") : null}
|
||||
blocked={kinds.length ? t("The mail server gives this role by default, so it can't be deleted. Change the defaults in inbuxa Admin first.") : locked ? t("This role carries permissions yours doesn't.") : null}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
|
||||
|
||||
function signIn(permissions: string[], username = "[email protected]") {
|
||||
useSession.setState({
|
||||
session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:stalwart:jmap": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession,
|
||||
session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:inbuxa:jmap:registry": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("the Administration dashboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the pointer to INBUXA Admin", () => {
|
||||
describe("the pointer to inbuxa Admin", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
beforeEach(() => {
|
||||
@@ -136,7 +136,7 @@ describe("the pointer to INBUXA Admin", () => {
|
||||
it("names it, and links it where the operator has said where it is", async () => {
|
||||
await renderWith("https://admin.example.com");
|
||||
const note = host.querySelector(".admin-dashboard-note")!;
|
||||
expect(note.textContent).toContain("INBUXA Admin");
|
||||
expect(note.textContent).toContain("inbuxa Admin");
|
||||
const link = note.querySelector("a")!;
|
||||
expect(link.getAttribute("href")).toBe("https://admin.example.com");
|
||||
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
@@ -144,7 +144,7 @@ describe("the pointer to INBUXA Admin", () => {
|
||||
|
||||
it("names it without a link where nobody has", async () => {
|
||||
await renderWith(null);
|
||||
expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("INBUXA Admin");
|
||||
expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("inbuxa Admin");
|
||||
expect(host.querySelector(".admin-dashboard-note a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Bot } from "lucide-react";
|
||||
import type { LlmOpinion } from "@/lib/llmOpinion";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
|
||||
/*
|
||||
* inbuxa: the language model's opinion on a message, where the server's AI
|
||||
* spam classification recorded one (lib/llmOpinion).
|
||||
*
|
||||
* Two rules, both from the server's spec. It is always labeled as one signal
|
||||
* the spam filter weighed among several, never as the reason a message was
|
||||
* filed where it was: the model can add a bounded amount to the score and no
|
||||
* more. And the explanation is the model's own output, so it is only ever
|
||||
* rendered as text.
|
||||
*
|
||||
* The category and confidence come from the server's configuration and aren't
|
||||
* translated; only the two framing strings are.
|
||||
*/
|
||||
|
||||
function Verdict({ opinion }: { opinion: LlmOpinion }) {
|
||||
return (
|
||||
<>
|
||||
<strong>{opinion.category}</strong>
|
||||
{opinion.confidence && <span className="hint">{` · ${opinion.confidence}`}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** In the message details, beside the spam filter's own working. */
|
||||
export function LlmOpinionDetail({ opinion }: { opinion: LlmOpinion }) {
|
||||
return (
|
||||
<div className="llm-opinion">
|
||||
<div>
|
||||
<Verdict opinion={opinion} />
|
||||
</div>
|
||||
{opinion.explanation && <div className="llm-explanation">{opinion.explanation}</div>}
|
||||
<div className="hint">{translate("One of several signals the spam filter weighed")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Above a message that's in Junk. */
|
||||
export function LlmOpinionBanner({ opinion }: { opinion: LlmOpinion }) {
|
||||
return (
|
||||
<div className="remote-banner llm-banner" role="note" style={{ margin: "0 16px 8px" }}>
|
||||
<Bot size={16} />
|
||||
<span className="grow llm-opinion">
|
||||
<span className="llm-heading">
|
||||
<span>{translate("Language model's opinion")}</span>
|
||||
<span>
|
||||
<Verdict opinion={opinion} />
|
||||
</span>
|
||||
</span>
|
||||
{opinion.explanation && <span className="llm-explanation">{opinion.explanation}</span>}
|
||||
<span className="hint">{translate("One of several signals the spam filter weighed")}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The banner shows only for a message in Junk that carries an opinion. */
|
||||
export function llmBannerOpinion(
|
||||
opinion: LlmOpinion | null,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
junkId: string | null | undefined,
|
||||
): LlmOpinion | null {
|
||||
return opinion && junkId && mailboxIds[junkId] ? opinion : null;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { emlFilename } from "@/lib/text/emlName";
|
||||
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
|
||||
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
|
||||
import { spamReport, type SpamReport } from "@/lib/spamScore";
|
||||
import { llmOpinion } from "@/lib/llmOpinion";
|
||||
import { LlmOpinionBanner, LlmOpinionDetail, llmBannerOpinion } from "./LlmOpinion";
|
||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||
import { displayName, domainOf, formatAddress } from "@/lib/address";
|
||||
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html";
|
||||
@@ -187,6 +189,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
const receiptRequested = Boolean(e["header:Disposition-Notification-To:asAddresses"]?.length);
|
||||
const authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
|
||||
const spam = useMemo(() => spamReport(e), [e]);
|
||||
// inbuxa: the language model's opinion, where the server's AI spam classification wrote one
|
||||
const llm = useMemo(() => llmOpinion(e), [e]);
|
||||
const junkId = useMail((st) => st.roleId("junk"));
|
||||
const llmBanner = llmBannerOpinion(llm, e.mailboxIds, junkId);
|
||||
const identities = useMail((st) => st.identities);
|
||||
/*
|
||||
* Only computed when the warning is on, because the domains it compares
|
||||
@@ -374,6 +380,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
{e["header:List-Id:asText"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
||||
<dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</dd>
|
||||
{spam && <><dt>{translate("Spam filter")}</dt><dd><SpamSummary report={spam} /></dd></>}
|
||||
{llm && <><dt>{translate("Language model's opinion")}</dt><dd><LlmOpinionDetail opinion={llm} /></dd></>}
|
||||
{receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}</dd></>}
|
||||
</dl>
|
||||
)}
|
||||
@@ -425,6 +432,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
</div>
|
||||
)}
|
||||
<SignatureBanner state={signature} />
|
||||
{llmBanner && <LlmOpinionBanner opinion={llmBanner} />}
|
||||
{externalSender && (
|
||||
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
|
||||
<ShieldAlert size={16} />
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { LlmOpinionBanner, LlmOpinionDetail, llmBannerOpinion } from "../LlmOpinion";
|
||||
import { parseLlmOpinion, type LlmOpinion } from "@/lib/llmOpinion";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/*
|
||||
* The framing is the feature: the model's opinion is always one signal among
|
||||
* several, never presented as why a message is where it is, and its
|
||||
* explanation is model output, so it must never be rendered as markup.
|
||||
*/
|
||||
const opinion = (raw: string) => parseLlmOpinion(raw) as LlmOpinion;
|
||||
|
||||
describe("the language model's opinion", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const render = async (node: React.ReactNode) => {
|
||||
await act(async () => {
|
||||
root.render(node);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("shows category, confidence and explanation, as one signal of several", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion("LLM_UNSOLICITED_HIGH (Sells something unasked)")} />);
|
||||
expect(host.textContent).toContain("Unsolicited");
|
||||
expect(host.textContent).toContain("High");
|
||||
expect(host.textContent).toContain("Sells something unasked");
|
||||
expect(host.textContent).toContain("One of several signals the spam filter weighed");
|
||||
});
|
||||
|
||||
it("renders the explanation as text, never markup", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion('LLM_HARMFUL_HIGH (<img src=x onerror="alert(1)"> <b>bold</b>)')} />);
|
||||
expect(host.querySelector("img")).toBeNull();
|
||||
expect(host.querySelector("b")).toBeNull();
|
||||
expect(host.textContent).toContain('<img src=x onerror="alert(1)">');
|
||||
});
|
||||
|
||||
it("leaves out what the header didn't carry", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion("LLM_LEGITIMATE")} />);
|
||||
expect(host.querySelector(".llm-explanation")).toBeNull();
|
||||
expect(host.textContent).not.toContain("·");
|
||||
});
|
||||
|
||||
it("banners a message in Junk with the same framing", async () => {
|
||||
await render(<LlmOpinionBanner opinion={opinion("LLM_UNSOLICITED_MEDIUM (Bulk newsletter)")} />);
|
||||
expect(host.textContent).toContain("Language model's opinion");
|
||||
expect(host.textContent).toContain("Unsolicited");
|
||||
expect(host.textContent).toContain("Bulk newsletter");
|
||||
expect(host.textContent).toContain("One of several signals the spam filter weighed");
|
||||
});
|
||||
|
||||
it("banners only a message that's in Junk and carries an opinion", () => {
|
||||
const o = opinion("LLM_UNSOLICITED_HIGH");
|
||||
expect(llmBannerOpinion(o, { junk1: true }, "junk1")).toBe(o);
|
||||
expect(llmBannerOpinion(o, { inbox1: true }, "junk1")).toBeNull();
|
||||
expect(llmBannerOpinion(o, { junk1: true }, null)).toBeNull();
|
||||
expect(llmBannerOpinion(null, { junk1: true }, "junk1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -35,13 +35,13 @@ export function AboutSettings() {
|
||||
<table className="sessions-table">
|
||||
<tbody>
|
||||
<tr><td>{t("Signed in as")}</td><td>{session?.username}</td></tr>
|
||||
<tr><td>{t("Mail server")}</td><td className="notranslate" translate="no">INBUXA</td></tr>
|
||||
<tr><td>{t("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("Max upload")}</td><td>{t("{size} MB", { size: Math.round(client.maxSizeUpload / 1048576) })}</td></tr>
|
||||
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="hint" style={{ marginTop: 6 }}>{t("This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.")}</p>
|
||||
<p className="hint" style={{ marginTop: 6 }}>{t("This webmail works with the inbuxa mail server, and sign-in refuses a server that doesn't offer what it needs.")}</p>
|
||||
<p className="hint">{tNode("{app}'s own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.", { example: <strong className="notranslate" translate="no">v2026.8.30+pr129</strong>, sha: <code>+g1fa6578</code> }, { app: appName })}</p>
|
||||
<h2>{t("Server capabilities")}</h2>
|
||||
<div className="row wrap gap-4">
|
||||
|
||||
Reference in New Issue
Block a user