Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2174ccb7b3 | ||
|
|
0580812216 | ||
|
|
06ea28dade | ||
|
|
20ac83c8df | ||
|
|
a58ea326d9 | ||
|
|
9fcf4812f3 | ||
|
|
13c9b8ef91 | ||
|
|
6b979c5ac7 | ||
|
|
8acd30e8a1 | ||
|
|
5f27923ce6 | ||
|
|
68c91a1ad4 | ||
|
|
9e77fb3f47 | ||
|
|
351a5aa01d | ||
|
|
34baca4365 | ||
|
|
9f53759462 | ||
|
|
c66a8aadd7 | ||
|
|
7f12a7d8ee | ||
|
|
722a68432c | ||
|
|
e94508ea89 | ||
|
|
6190d2b4f5 | ||
|
|
ed23610e31 | ||
|
|
3e044057f6 | ||
|
|
69ccb313e3 | ||
|
|
29795f3988 | ||
|
|
38dcbd9e0c | ||
|
|
5fe929c6a4 | ||
|
|
c2502d36bd | ||
|
|
b11f3997d4 | ||
|
|
f6320e6614 | ||
|
|
152311b035 | ||
|
|
3434a5ed39 | ||
|
|
64b5db01f3 | ||
|
|
af9b3a7f70 | ||
|
|
8a670c5e70 | ||
|
|
cfb28ada45 | ||
|
|
7b9da44116 | ||
|
|
300ec1df99 | ||
|
|
3f19f82960 | ||
|
|
2e7f30c64e | ||
|
|
c558693db5 | ||
|
|
c87a6d1cd6 | ||
|
|
34d3f21b4b | ||
|
|
0c5ad7a3b8 | ||
|
|
17a8093c09 | ||
|
|
5664896f53 | ||
|
|
4d7668601e | ||
|
|
bdee5de814 | ||
|
|
b1436d4d2a | ||
|
|
a7c7d88408 | ||
|
|
63ddc23ee2 | ||
|
|
ae83d27e4b | ||
|
|
043fe4ca54 | ||
|
|
24dccdb47d | ||
|
|
6e1166319a | ||
|
|
ddb6ce4611 | ||
|
|
bf35e75f88 | ||
|
|
3251478d61 | ||
|
|
401c32a3e6 | ||
|
|
216ebd4dfe | ||
|
|
829e46beae | ||
|
|
1acf2f29e7 | ||
|
|
d73f5e8f9e | ||
|
|
07cfe1310e | ||
|
|
290bc63dbb | ||
|
|
44f8e30c45 | ||
|
|
ac0f8789f6 | ||
|
|
5c08fb9fe9 | ||
|
|
9e47437ef8 | ||
|
|
173680cc41 | ||
|
|
22d891b1ed | ||
|
|
c64a23f9d9 | ||
|
|
fedc34698e | ||
|
|
8bfc7a85a9 | ||
|
|
2fffc9043d | ||
|
|
a94fd9cce3 | ||
|
|
9ba2c6e290 | ||
|
|
996aa66ef0 | ||
|
|
d5c49c2962 | ||
|
|
ee675004f2 | ||
|
|
2361caf2c7 | ||
|
|
2c4d6cdfae | ||
|
|
f95072ab92 | ||
|
|
ab759143ab | ||
|
|
d0f7c6ed20 | ||
|
|
4fcc8dd1b9 | ||
|
|
8674b70f62 | ||
|
|
ffe1a898f5 | ||
|
|
0adc402629 | ||
|
|
2a30a32c79 | ||
|
|
ee41846c2d | ||
|
|
6cb3321022 | ||
|
|
b407969849 |
No files matched your search
@@ -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://git.coffeylabs.org/inbuxa/ihasmail-inbuxa
|
||||
SOURCE_URL=https://git.coffeylabs.org/inbuxa/inbuxa-webmail
|
||||
|
||||
# ---- Settings this installation decides (all optional) ----
|
||||
#
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Announce each published release on the community forum, in this project's
|
||||
# Announcements category (coffey-labs/actions discourse-release; the repo ->
|
||||
# category map is its release-map.json). Safe to re-run: one topic per tag.
|
||||
# Run it by hand with a tag to announce a release whose own run failed: a
|
||||
# release event runs the workflow as it was at the tag, so a fix on main only
|
||||
# reaches an old release this way.
|
||||
name: announce
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: release tag to announce, e.g. inbuxa-v2026.10.5-g17a8093
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
runs-on: light
|
||||
steps:
|
||||
- uses: coffey-labs/actions/discourse-release@9282c6f27303f4eb9d61d6aee6daa444c11d7268
|
||||
with:
|
||||
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
|
||||
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
tag: ${{ inputs.tag }}
|
||||
@@ -1,6 +1,19 @@
|
||||
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
|
||||
# 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 is the GitHub side, below.
|
||||
#
|
||||
# WHERE THE BUILD RUNS. Gitea push-mirrors this repository to GitHub, and the
|
||||
# org variable BUILD_ON picks which forge does the heavy work:
|
||||
# * unset (or anything but `github`): every job here runs, as it always did,
|
||||
# and GitHub's workflow skips all of its jobs.
|
||||
# * `github`: the test, build and publish jobs here are skipped, GitHub
|
||||
# Actions runs .github/workflows/ci.yml on its hosted runners (native
|
||||
# arm64, no QEMU), and the `github` job below waits for the commit status
|
||||
# that run posts back, passing or failing with it. So this run's result is
|
||||
# still the one that counts, for a PR's checks as for anything that merges
|
||||
# on green CI. The variable is set on both forges, and must agree.
|
||||
# If GitHub is ever unavailable, unsetting BUILD_ON here is the whole
|
||||
# fallback: the jobs below take over again unchanged.
|
||||
#
|
||||
# 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
|
||||
@@ -35,6 +48,7 @@ concurrency:
|
||||
jobs:
|
||||
# -------------------------------------------------------------- test ------
|
||||
node:
|
||||
if: ${{ vars.BUILD_ON != 'github' }}
|
||||
runs-on: light
|
||||
container:
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
@@ -73,7 +87,7 @@ jobs:
|
||||
# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step. The
|
||||
# Dockerfile builds everything itself; `needs` only keeps the order.
|
||||
docker-build:
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
if: ${{ vars.BUILD_ON != 'github' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: [node]
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -93,7 +107,7 @@ jobs:
|
||||
# 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') }}
|
||||
if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
||||
runs-on: light
|
||||
container:
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
@@ -119,13 +133,13 @@ jobs:
|
||||
echo "DOCKER_TAG=${V/+/-}" >> "$GITHUB_OUTPUT"
|
||||
echo "VERSION=$V DOCKER_TAG=${V/+/-}"
|
||||
|
||||
# Multi-arch image at <REGISTRY>/inbuxa/ihasmail-inbuxa, then the release.
|
||||
# Multi-arch image at <REGISTRY>/inbuxa/inbuxa-webmail, 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') }}
|
||||
if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
||||
needs: [node, version]
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -177,3 +191,57 @@ jobs:
|
||||
echo "release $TAG created"
|
||||
- if: always()
|
||||
run: docker logout "$REGISTRY" || true
|
||||
|
||||
# The release above is made with the job's own token, and Gitea starts no
|
||||
# workflow for events the Actions bot causes -- announce.yml's
|
||||
# 'on: release' never fires for it -- so announce it from here. With
|
||||
# BUILD_ON=github the release is created by GitHub's run instead, and this
|
||||
# follows the `github` job. Announcing stays on Gitea either way; the
|
||||
# action posts once per tag, so a second attempt is a no-op.
|
||||
announce:
|
||||
needs: [publish, github]
|
||||
if: ${{ always() && startsWith(github.ref, 'refs/tags/inbuxa-v') && (needs.publish.result == 'success' || needs.github.result == 'success') }}
|
||||
runs-on: light
|
||||
steps:
|
||||
- uses: coffey-labs/actions/discourse-release@9282c6f27303f4eb9d61d6aee6daa444c11d7268
|
||||
with:
|
||||
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
|
||||
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
tag: ${{ github.ref_name }}
|
||||
|
||||
# ------------------------------------------------------------ github ------
|
||||
# With BUILD_ON=github, the work above happens in GitHub Actions, which
|
||||
# posts one commit status back here when it finishes: "github/ci (branch)"
|
||||
# for a branch push, "github/ci (tag)" for a tag. This job waits for that
|
||||
# status on the commit under test -- the PR's head for a pull request -- and
|
||||
# passes or fails with it. Nothing arriving within the timeout means GitHub
|
||||
# never built the commit (a mirror that failed to sync, or GitHub being
|
||||
# down): check the mirror, or unset BUILD_ON to build here.
|
||||
github:
|
||||
if: ${{ vars.BUILD_ON == 'github' }}
|
||||
# Its own runner label with plenty of slots: this job only polls, but holds a slot
|
||||
# for as long as the GitHub build takes, and must not starve the build runners.
|
||||
runs-on: wait
|
||||
timeout-minutes: 150
|
||||
container:
|
||||
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
CONTEXT: github/ci (${{ github.ref_type == 'tag' && format('tag {0}', github.ref_name) || 'branch' }})
|
||||
steps:
|
||||
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl jq >/dev/null
|
||||
- shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
url="$CI_SERVER_INTERNAL/api/v1/repos/$GITHUB_REPOSITORY/commits/$SHA/statuses?limit=50"
|
||||
echo "waiting for '$CONTEXT' on $SHA"
|
||||
while :; do
|
||||
state="$(curl -fsS -H "Authorization: token $TOKEN" "$url" \
|
||||
| jq -r --arg c "$CONTEXT" '[.[] | select(.context == $c)] | sort_by(.id) | last | .status // empty')"
|
||||
case "$state" in
|
||||
success) echo "GitHub reported success"; exit 0 ;;
|
||||
failure|error) echo "GitHub reported $state -- see the status's link for the run" >&2; exit 1 ;;
|
||||
esac
|
||||
sleep 20
|
||||
done
|
||||
@@ -14,7 +14,7 @@
|
||||
## Translations
|
||||
|
||||
<!--
|
||||
Nine languages ship alongside English, and a missing key silently renders
|
||||
Ten languages ship alongside English, and a missing key silently renders
|
||||
its English source -- so an untranslated string is invisible until somebody
|
||||
reading that language finds it. Say which this PR is, explicitly:
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# The npm entry sits at the root because that is where the single lockfile
|
||||
# is: root, server and web are one npm workspace, so one entry covers all
|
||||
# three. Pointing entries at server/ or web/ would find package.json files
|
||||
# with no lockfile beside them and update nothing.
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: tuesday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
# Everything routine arrives as one PR a week, so the dashboard is not
|
||||
# the only place these get noticed. Majors are deliberately left out of
|
||||
# the group: they are migrations, not bumps -- vitest 3 to 4 is one --
|
||||
# and each deserves its own PR and its own CI run.
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: tuesday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
# The runtime and build stages both pin node:22-alpine, so this is what
|
||||
# keeps the published container images off a stale base between the weekly
|
||||
# releases.
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: tuesday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
@@ -1,39 +1,345 @@
|
||||
name: CI
|
||||
# CI and publishing on GitHub Actions, for a repository whose source of truth
|
||||
# is the self-hosted Gitea. Gitea push-mirrors every commit and tag here, and
|
||||
# this workflow does the heavy work on GitHub's hosted runners -- native arm64
|
||||
# included -- then reports the result back to Gitea as a commit status.
|
||||
#
|
||||
# THE SWITCH. Every job here runs only when the org variable BUILD_ON is
|
||||
# `github`. Gitea's .gitea/workflows/ci.yml reads the same variable (set on
|
||||
# the Gitea org too): with it set, Gitea skips its own build jobs and waits for
|
||||
# the status this workflow posts; without it, Gitea builds everything itself,
|
||||
# exactly as before, and every job here is skipped. If GitHub is ever
|
||||
# unavailable, unsetting BUILD_ON on Gitea is the whole fallback.
|
||||
#
|
||||
# There is no pull_request trigger: pull requests live on Gitea. A PR's branch
|
||||
# arrives here as an ordinary push, and the status lands on its head commit,
|
||||
# which is where Gitea's PR looks for it.
|
||||
#
|
||||
# Releases are cut by pushing a tag named `inbuxa-v<version>` (see
|
||||
# .gitea/workflows/ci.yml for why the prefix matters: this repository carries
|
||||
# upstream ihasmail's own `v...` tags, and only `inbuxa-v` tags publish).
|
||||
#
|
||||
# Org configuration, not in this file:
|
||||
# vars.BUILD_ON `github` to build here
|
||||
# vars.REGISTRY the Gitea container registry's DNS-only name
|
||||
# vars.GITEA_URL Gitea's public URL, for statuses, releases and packages
|
||||
# secrets.GITEA_TOKEN jcoffey-dev, write:repository + write:package
|
||||
#
|
||||
# Every `uses:` is pinned to a full commit SHA with the release in the
|
||||
# trailing comment. A tag is a mutable pointer, so trusting `@v7` is trusting
|
||||
# every future version of that action. Do not "simplify" a pin back to a tag.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# Lets CI be run by hand against any ref, including a specific commit.
|
||||
# Without this there is no way to re-run a check that never started: a run
|
||||
# GitHub queues and then orphans -- as it did to every run created during the
|
||||
# Actions outage on 2026-08-26 -- can be neither rerun ("already running")
|
||||
# nor canceled ("already completed"), and the workflow has no other trigger
|
||||
# to reach for. Useful too for putting a check on a commit that predates a CI
|
||||
# change, without pushing an empty commit to move it.
|
||||
branches: ['**']
|
||||
tags: ['**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL }}
|
||||
REGISTRY: ${{ vars.REGISTRY }}
|
||||
# A registry path must be lowercase; the repository name already is.
|
||||
IMAGE: ${{ vars.REGISTRY }}/inbuxa/inbuxa-webmail
|
||||
# A tag's context names the tag: upstream v* and inbuxa-v* tags can sit on
|
||||
# the same commit, and Gitea must not read one tag's result as the other's.
|
||||
STATUS_CONTEXT: github/ci (${{ github.ref_type == 'tag' && format('tag {0}', github.ref_name) || 'branch' }})
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Tells Gitea a result is on its way, so a PR shows the check as running
|
||||
# rather than missing.
|
||||
pending:
|
||||
if: ${{ vars.BUILD_ON == 'github' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
jq -n --arg c "$STATUS_CONTEXT" \
|
||||
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
|
||||
'{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}' \
|
||||
| curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" --data @- \
|
||||
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
|
||||
|
||||
# -------------------------------------------------------------- test ------
|
||||
# version.test.ts shells out to git to resolve a build version from the
|
||||
# history, so the checkout is a full one. The hosted runner runs as an
|
||||
# unprivileged user, so config.test.ts's read-only directory holds here
|
||||
# without the `su node` Gitea needs.
|
||||
node:
|
||||
if: ${{ vars.BUILD_ON == 'github' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Every `uses:` in this repository is pinned to a full commit SHA, with
|
||||
# the release it belongs to in the trailing comment, and the repository
|
||||
# requires it -- an unpinned ref fails the run rather than quietly
|
||||
# resolving. A tag is a mutable pointer: `@v7` is whatever the publisher
|
||||
# last moved it to, so trusting one is trusting every future version of
|
||||
# that action, including the one pushed by whoever compromises the
|
||||
# account. Read the comment for the version; the SHA is what runs.
|
||||
#
|
||||
# Dependabot updates both halves together on its weekly github-actions
|
||||
# run, so this costs nothing to keep current -- do not "simplify" a pin
|
||||
# back to a tag.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
cache: npm
|
||||
# --ignore-scripts: a postinstall script in any transitive dependency
|
||||
# would otherwise run with the job's credentials in its environment.
|
||||
- run: npm ci --ignore-scripts
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
- name: Docker build
|
||||
run: docker build -t ihasmail:ci .
|
||||
|
||||
# ------------------------------------------------------------- build ------
|
||||
# Proves the Dockerfile still builds on every change, without pushing.
|
||||
docker-build:
|
||||
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }}
|
||||
needs: [node]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: docker build -t "ihasmail:ci-${GITHUB_SHA::8}" .
|
||||
|
||||
# ----------------------------------------------------------- release ------
|
||||
# Only for `inbuxa-v` tags. 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: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
docker_tag: ${{ steps.v.outputs.docker_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
- id: v
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
V="$(node scripts/version.mjs)"
|
||||
want="inbuxa-v${V/+/-}"
|
||||
[ "$TAG" = "$want" ] || { echo "::error::$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 "::error::$TAG is not on main"; exit 1; }
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
# A Docker tag may not contain '+', so build metadata becomes '-'.
|
||||
echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT"
|
||||
echo "version $V -> tag ${V/+/-}"
|
||||
|
||||
# Each architecture on its own native runner, pushed as an untagged image by
|
||||
# digest; `publish` joins the two digests into one multi-arch tag.
|
||||
build:
|
||||
if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
||||
needs: [node, version]
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ vars.REGISTRY }}
|
||||
username: jcoffey-dev
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
- name: Build and push by digest
|
||||
id: push
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }}
|
||||
# Attestations add manifests of their own to the index, and
|
||||
# `imagetools create` below expects the two entries pushed here.
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
- name: Save the digest
|
||||
env:
|
||||
DIGEST: ${{ steps.push.outputs.digest }}
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
# Bare hash as the filename; the prefix is put back when joining.
|
||||
touch "/tmp/digests/${DIGEST#sha256:}"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: digest-${{ strategy.job-index }}
|
||||
path: /tmp/digests/*
|
||||
# Kept a week, and overwritable, so a re-run of the build or of
|
||||
# publish alone still finds (or replaces) the digests.
|
||||
retention-days: 7
|
||||
overwrite: true
|
||||
if-no-files-found: error
|
||||
|
||||
# Joins the digests into `:<version>` and `:latest`, links the package to
|
||||
# the repository on Gitea, then creates the release there -- last, so a
|
||||
# release on the page always has its image behind it. The release is made
|
||||
# with GITEA_TOKEN, a user's token, so Gitea's announce.yml fires for it;
|
||||
# Gitea's ci.yml announces as well once this run's status arrives, and the
|
||||
# announce action posts once per tag whichever gets there first.
|
||||
publish:
|
||||
if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
||||
needs: [version, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ vars.REGISTRY }}
|
||||
username: jcoffey-dev
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
- name: Create the manifest
|
||||
env:
|
||||
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
|
||||
run: |
|
||||
refs=()
|
||||
for f in /tmp/digests/*; do refs+=("${IMAGE}@sha256:$(basename "$f")"); done
|
||||
docker buildx imagetools create -t "${IMAGE}:${DOCKER_TAG}" -t "${IMAGE}:latest" "${refs[@]}"
|
||||
docker buildx imagetools inspect "${IMAGE}:${DOCKER_TAG}"
|
||||
# Shows the package on the repository's Packages tab. Idempotent.
|
||||
- env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_URL/api/v1/packages/inbuxa/container/inbuxa-webmail/-/link/inbuxa-webmail" \
|
||||
|| echo "package already linked (or link refused); not fatal"
|
||||
- env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
|
||||
run: |
|
||||
set -eu
|
||||
API="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
|
||||
# A re-run finds the release already there.
|
||||
if curl -fsS -o /dev/null -H "Authorization: token $GITEA_TOKEN" "$API/tags/$TAG"; then
|
||||
echo "release $TAG already exists"; exit 0
|
||||
fi
|
||||
body="$(printf 'INBUXA webmail %s.\n\nImage: `%s:%s` (linux/amd64, linux/arm64), also tagged `latest`.' "$VERSION" "$IMAGE" "$DOCKER_TAG")"
|
||||
jq -n --arg tag "$TAG" --arg body "$body" '{tag_name:$tag, name:$tag, body:$body}' \
|
||||
| curl -fsS -o /dev/null -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
|
||||
--data @- "$API"
|
||||
echo "release $TAG created"
|
||||
|
||||
# ------------------------------------------------------ ghcr replica ------
|
||||
# Copies the release image from the Gitea registry, which stays the
|
||||
# authoritative one, to ghcr.io under the same version tag and :latest. It is
|
||||
# a copy, not a second build: the digest on GHCR is the digest on the
|
||||
# registry, so `docker pull ghcr.io/...` gets exactly the same image. Left
|
||||
# out of the report to Gitea, like the release copy, so a GHCR problem
|
||||
# cannot fail a release.
|
||||
ghcr:
|
||||
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
||||
needs: [version, publish]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.version.outputs.docker_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
src="${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}"
|
||||
dst="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||
tag="$TAG"
|
||||
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
|
||||
docker buildx imagetools create -t "$dst:$tag" -t "$dst:latest" "$src:$tag"
|
||||
want="$(docker buildx imagetools inspect "$src:$tag" --format '{{json .Manifest.Digest}}')"
|
||||
got="$(docker buildx imagetools inspect "$dst:$tag" --format '{{json .Manifest.Digest}}')"
|
||||
echo "registry $src:$tag = $want"
|
||||
echo "ghcr $dst:$tag = $got"
|
||||
[ "$want" = "$got" ] || echo "::warning::GHCR digest differs from the registry's"
|
||||
docker logout ghcr.io
|
||||
|
||||
# ---------------------------------------------------- github release ------
|
||||
# Copies this tag's Gitea release -- notes and files -- to a GitHub release,
|
||||
# so the replica's Releases page, and anyone watching it, keeps up. Gitea's
|
||||
# release is the real one; this is left out of the report to Gitea, so a
|
||||
# failure here cannot fail a release. PR and issue numbers in the notes are
|
||||
# rewritten to Gitea links: on GitHub a bare #16 is some other PR.
|
||||
github-release:
|
||||
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
||||
needs: [publish]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
steps:
|
||||
- run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "GitHub already has a release for $TAG"; exit 0
|
||||
fi
|
||||
# The Gitea release exists by now if this run made it; if the weekly
|
||||
# release job made it, it came before the tag. Allow a few minutes.
|
||||
code=0
|
||||
for _ in $(seq 1 15); do
|
||||
code="$(curl -sS -o rel.json -w '%{http_code}' "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")"
|
||||
[ "$code" = 200 ] && break
|
||||
sleep 20
|
||||
done
|
||||
if [ "$code" != 200 ]; then echo "No Gitea release for $TAG; nothing to copy"; exit 0; fi
|
||||
if [ "$(jq -r .draft rel.json)" = true ]; then echo "The Gitea release is a draft; not copying"; exit 0; fi
|
||||
export BASE="$(jq -r '.html_url | sub("/releases/tag/.*$"; "")' rel.json)"
|
||||
jq -r '.body // ""' rel.json | perl -pe 's{(?<![\w/&\[])#(\d+)\b}{[#$1]($ENV{BASE}/pulls/$1)}g' > notes.md
|
||||
printf '\n\n_Mirrored from [the Gitea release](%s); report issues on [Gitea](%s/issues)._\n' \
|
||||
"$(jq -r .html_url rel.json)" "$BASE" >> notes.md
|
||||
files=()
|
||||
mkdir -p files
|
||||
while IFS=$'\t' read -r name url; do
|
||||
curl -fsSL -o "files/$name" "$url"; files+=("files/$name")
|
||||
done < <(jq -r '.assets[]? | [.name, .browser_download_url] | @tsv' rel.json)
|
||||
title="$(jq -r '.name // ""' rel.json)"; [ -n "$title" ] || title="$TAG"
|
||||
if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; else kind=--latest; fi
|
||||
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$title" \
|
||||
--notes-file notes.md "$kind" "${files[@]}"
|
||||
echo "created the GitHub release for $TAG with ${#files[@]} file(s)"
|
||||
|
||||
# One commit status on Gitea for the whole run: what Gitea's ci.yml waits
|
||||
# for, and what a Gitea PR shows.
|
||||
report:
|
||||
if: ${{ always() && vars.BUILD_ON == 'github' }}
|
||||
needs: [pending, node, docker-build, version, build, publish]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
STATE: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }}
|
||||
run: |
|
||||
# A cancelled run was superseded by a newer run for the same commit (the
|
||||
# mirror can push one commit twice); that run reports. Posting "failure"
|
||||
# here would fail the Gitea check while the real build is still going.
|
||||
if [ "$STATE" = cancelled ]; then echo "cancelled: leaving the result to the newer run"; exit 0; fi
|
||||
jq -n --arg s "$STATE" --arg c "$STATUS_CONTEXT" \
|
||||
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
|
||||
'{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}' \
|
||||
| curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" --data @- \
|
||||
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
|
||||
echo "reported $STATE as '$STATUS_CONTEXT'"
|
||||
@@ -1,69 +0,0 @@
|
||||
# Prune old image versions from GHCR.
|
||||
#
|
||||
# Releases are kept forever -- they carry no assets and their generated notes
|
||||
# are this project's only changelog, so deleting one destroys history that
|
||||
# cannot be reconstructed for nothing saved. Images are the opposite: a
|
||||
# multi-arch build a week, and the by-digest push in publish.yml leaves two
|
||||
# untagged per-architecture manifests behind each time on top of the tagged
|
||||
# index. Those accumulate and nobody wants fifty of them.
|
||||
#
|
||||
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
|
||||
# `delete-only-untagged-versions` -- will happily delete the per-architecture
|
||||
# manifests that a multi-arch tag points *at*, because they are untagged by
|
||||
# design. Nothing appears to break: the tag still exists, and pulls simply
|
||||
# start failing for one architecture. This action understands manifest lists
|
||||
# and will not orphan a retained index, and `validate` re-checks every
|
||||
# multi-arch manifest against the registry afterwards.
|
||||
#
|
||||
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
|
||||
# exactly what would be deleted without rebuilding and re-pushing an image to
|
||||
# find out.
|
||||
name: Prune images
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
dry_run:
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "List what would be deleted, delete nothing"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
prune:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
# The only third-party action here that is not published by GitHub or
|
||||
# Docker, and the one with the most to lose: it is handed
|
||||
# `packages: write` and its whole job is deletion, so a ref repointed at
|
||||
# something else -- by a compromise or a mistake upstream -- is a bad
|
||||
# day. It was pinned to a commit long before the rest of them were.
|
||||
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
|
||||
with:
|
||||
owner: Coffey-Labs
|
||||
package: ihasmail
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Ten weekly releases is roughly a quarter of history, which is more
|
||||
# than enough to roll back to and far less than the year's worth that
|
||||
# would otherwise pile up. Older *releases* stay either way; this
|
||||
# only removes the images.
|
||||
keep-n-tagged: 10
|
||||
# Belt and braces on top of the action's own manifest awareness:
|
||||
# `latest` is never a candidate for deletion under any counting.
|
||||
exclude-tags: latest
|
||||
delete-untagged: true
|
||||
# Sweeps the wreckage of a half-failed run: an index whose platform
|
||||
# images did not all land, and referrers whose parent is gone.
|
||||
delete-partial-images: true
|
||||
delete-orphaned-images: true
|
||||
# Checks every remaining multi-architecture manifest still resolves
|
||||
# in the registry. This is the step that would catch the footgun
|
||||
# above rather than leaving a reader to discover it on `docker pull`.
|
||||
validate: true
|
||||
dry-run: ${{ inputs.dry_run }}
|
||||
@@ -1,206 +0,0 @@
|
||||
# Publish the container image to GHCR.
|
||||
#
|
||||
# The README and the docs site have told people to run
|
||||
# `ghcr.io/coffey-labs/ihasmail:latest` for a long time, and nothing ever
|
||||
# pushed it: `docker pull` answered `denied`, because the package did not
|
||||
# exist. This is the workflow that makes those instructions true. It is also
|
||||
# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid
|
||||
# both install by pulling an image and neither builds from source.
|
||||
#
|
||||
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
|
||||
# a public repository, and an anonymous `docker pull` will still answer
|
||||
# `denied`. Nothing in a workflow can change that -- the visibility is set once
|
||||
# by hand under the package's settings, and until it is, this looks like it
|
||||
# worked while the docs stay just as wrong as before. Check with a logged-out
|
||||
# pull, not with one from a machine that has credentials.
|
||||
#
|
||||
# Two architectures, each built on its own native runner rather than under
|
||||
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
|
||||
# instruction translation, which takes tens of minutes and occasionally runs
|
||||
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
|
||||
# the same work at native speed. The cost is the by-digest dance below: each
|
||||
# runner pushes an untagged image, and a final job joins the two digests into
|
||||
# one multi-arch tag.
|
||||
name: Publish image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
# Callable, so release.yml can build the release it just cut. This is not a
|
||||
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
|
||||
# `release` event -- GitHub refuses to let a token trigger another workflow,
|
||||
# to stop a workflow looping on its own output. A scheduled job that cut a
|
||||
# release and expected this file to notice would silently never publish. The
|
||||
# alternatives are a personal access token kept as a secret, or calling the
|
||||
# workflow directly. This is the one that needs no credential.
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Tag, branch or SHA to build"
|
||||
required: true
|
||||
type: string
|
||||
tag_latest:
|
||||
description: "Also move :latest to this build"
|
||||
type: boolean
|
||||
default: false
|
||||
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
|
||||
# orphans can be neither rerun nor canceled, and this workflow otherwise
|
||||
# only fires on a release -- which is not something to cut twice because a
|
||||
# runner died. `ref` also allows publishing an image for a tag that predates
|
||||
# this workflow, which is how the first one gets built.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Tag, branch or SHA to build"
|
||||
required: true
|
||||
default: main
|
||||
tag_latest:
|
||||
description: "Also move :latest to this build"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
# Hardcoded rather than derived from github.repository: a registry path must
|
||||
# be lowercase and the owner is spelled `Coffey-Labs`, so deriving it means
|
||||
# remembering to lowercase it. This is the string the docs already name.
|
||||
# inbuxa: this fork publishes to INBUXA's own path. Inherited from public
|
||||
# ihasmail, which publishes ghcr.io/coffey-labs/ihasmail -- leaving that
|
||||
# here would push INBUXA's webmail over the image every public ihasmail
|
||||
# install pulls, which SPEC.md 5 exists to prevent.
|
||||
IMAGE: ghcr.io/inbuxa/ihasmail-inbuxa
|
||||
|
||||
jobs:
|
||||
# The version is worked out once and handed to both builds, so the two
|
||||
# architectures cannot disagree about what they are. scripts/version.mjs
|
||||
# reads the commit date and how the commit arrived, so it needs real history
|
||||
# rather than a shallow clone.
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
docker_tag: ${{ steps.v.outputs.docker_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
- id: v
|
||||
run: |
|
||||
V="$(node scripts/version.mjs)"
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
# A Docker tag may not contain '+', so build metadata becomes '-'.
|
||||
# The build is still *told* the real form, which is what About and
|
||||
# /api/health report.
|
||||
echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT"
|
||||
echo "version $V -> tag ${V/+/-}"
|
||||
|
||||
build:
|
||||
needs: version
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push by digest
|
||||
id: push
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }}
|
||||
# Attestations are off deliberately: they add manifests of their own
|
||||
# to the index, and `imagetools create` below expects the two entries
|
||||
# it pushed rather than four.
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
- name: Save the digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
# The prefix is stripped here and put back in the merge job, so the
|
||||
# filename is the bare hash. Leaving it on produces
|
||||
# `image@sha256:sha256:...` when the reference is rebuilt.
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
# One artifact per platform; the merge job globs them back together.
|
||||
name: digest-${{ strategy.job-index }}
|
||||
path: /tmp/digests/*
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
# Joins the per-architecture digests into a single tagged manifest, so
|
||||
# `docker pull ghcr.io/coffey-labs/ihasmail:<tag>` resolves on both.
|
||||
publish:
|
||||
needs: [version, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Create the manifest
|
||||
run: |
|
||||
# Arrays rather than a string: the tags and the digest references
|
||||
# have to reach docker as separate arguments, and building them by
|
||||
# word-splitting an unquoted variable is the version of this that
|
||||
# breaks the day a value contains a space.
|
||||
tags=(-t "${IMAGE}:${{ needs.version.outputs.docker_tag }}")
|
||||
# :latest follows real releases only. A prerelease that moved it
|
||||
# would hand every `:latest` deployment an unfinished build, and a
|
||||
# dispatch run has to ask for it on purpose.
|
||||
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
|
||||
tags+=(-t "${IMAGE}:latest")
|
||||
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
|
||||
tags+=(-t "${IMAGE}:latest")
|
||||
fi
|
||||
refs=()
|
||||
for f in /tmp/digests/*; do
|
||||
refs+=("${IMAGE}@sha256:$(basename "$f")")
|
||||
done
|
||||
echo "tags: ${tags[*]}"
|
||||
echo "refs: ${refs[*]}"
|
||||
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
|
||||
- name: Show what landed
|
||||
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.docker_tag }}"
|
||||
|
||||
# Runs only after a successful publish, because that is the only moment the
|
||||
# package grows. See cleanup.yml for why this is not the obvious one-liner.
|
||||
prune:
|
||||
needs: publish
|
||||
permissions:
|
||||
packages: write
|
||||
uses: ./.github/workflows/cleanup.yml
|
||||
@@ -1,163 +0,0 @@
|
||||
# Cut a release once a week, but only if there is something in it.
|
||||
#
|
||||
# Releases had drifted 184 commits behind main, which made `:latest` describe
|
||||
# a build nobody was running -- the demo, prod and anyone building from source
|
||||
# were all ahead of it. Publishing on release is the right trigger only if
|
||||
# releases actually happen, so this is the part that makes that true without
|
||||
# anyone having to remember.
|
||||
#
|
||||
# It does nothing on a quiet week. A release with no commits in it is worse
|
||||
# than no release: it moves `:latest` to an identical build, spends a version
|
||||
# number, and mails everybody watching the repository about nothing.
|
||||
name: Weekly release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Mondays, 09:17 UTC. GitHub runs scheduled jobs on a best-effort basis and
|
||||
# can delay a run by a good while when the queue is busy, so do not read
|
||||
# the exact minute as a promise. The odd minute is deliberate: the top of
|
||||
# the hour is when most schedules fire, and at 09:00 the first scheduled
|
||||
# run started almost six hours late and the second had not started at all
|
||||
# four and a half hours in. Moving off the hour does not make GitHub keep
|
||||
# time, but it stops competing for the busiest slot. A missed week can be
|
||||
# cut by hand with workflow_dispatch; a late scheduled run that follows
|
||||
# finds the tag already there and does nothing.
|
||||
#
|
||||
# Note also that GitHub disables scheduled workflows in a repository with
|
||||
# no activity for 60 days -- not a concern while this one is being worked
|
||||
# on weekly, but it is why a silent stop is worth checking for before
|
||||
# assuming the file is broken.
|
||||
- cron: "17 9 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Work out what would be released, then stop"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# One at a time. Two overlapping runs would race to create the same tag, and
|
||||
# the loser fails noisily for a reason that has nothing to do with the code.
|
||||
concurrency:
|
||||
group: weekly-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should_release: ${{ steps.decide.outputs.should_release }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
title: ${{ steps.decide.outputs.title }}
|
||||
sha: ${{ steps.decide.outputs.sha }}
|
||||
previous: ${{ steps.decide.outputs.previous }}
|
||||
count: ${{ steps.decide.outputs.count }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 26
|
||||
- id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# The newest published release, or empty on a repository that has
|
||||
# never had one -- in which case everything counts as new. Drafts are
|
||||
# excluded: an unpublished draft is not a release anybody has, so
|
||||
# counting from it would hide commits that have never shipped.
|
||||
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
|
||||
# A tag named by a release is normally present after a full checkout,
|
||||
# but a release can outlive its tag. Falling back to the whole
|
||||
# history is the safe direction to be wrong in: it over-counts, which
|
||||
# cuts a release that was due anyway, where under-counting would skip
|
||||
# one that was.
|
||||
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
|
||||
count="$(git rev-list --count "${previous}..HEAD")"
|
||||
else
|
||||
count="$(git rev-list --count HEAD)"
|
||||
fi
|
||||
|
||||
version="$(node scripts/version.mjs)"
|
||||
# A Docker tag may not contain '+', and neither should the git tag,
|
||||
# so the two always agree about what to call a build.
|
||||
tag="v${version/+/-}"
|
||||
title="v${version%%+*}"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
|
||||
should_release=true
|
||||
reason=""
|
||||
if [ "$count" -eq 0 ]; then
|
||||
should_release=false
|
||||
reason="no commits since ${previous}"
|
||||
elif git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
|
||||
# Same commit, different week: the version is derived from the
|
||||
# commit, so nothing new means the tag already exists.
|
||||
should_release=false
|
||||
reason="tag ${tag} already exists"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "should_release=$should_release"
|
||||
echo "tag=$tag"
|
||||
echo "title=$title"
|
||||
echo "sha=$sha"
|
||||
echo "previous=$previous"
|
||||
echo "count=$count"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Written to the run summary so a skipped week reads as a decision
|
||||
# rather than as a workflow that quietly did nothing.
|
||||
{
|
||||
echo "### Weekly release"
|
||||
echo
|
||||
if [ "$should_release" = "true" ]; then
|
||||
echo "Releasing **${tag}** — ${count} commit(s) since ${previous:-the beginning}."
|
||||
else
|
||||
echo "Nothing to release: ${reason}."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
cut:
|
||||
needs: check
|
||||
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=(--target "${{ needs.check.outputs.sha }}"
|
||||
--title "${{ needs.check.outputs.title }}"
|
||||
--generate-notes)
|
||||
# Bound the notes to what is actually new. Without a start tag the
|
||||
# generator reaches back to whatever it decides is previous, which on
|
||||
# a repository with older tag shapes is not always the last release.
|
||||
if [ -n "${{ needs.check.outputs.previous }}" ]; then
|
||||
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
|
||||
fi
|
||||
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
|
||||
|
||||
# Called rather than left to the `release` trigger on purpose: see the note
|
||||
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
|
||||
# event, so without this the tag would exist and no image would follow it.
|
||||
publish:
|
||||
needs: [check, cut]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: ./.github/workflows/publish.yml
|
||||
with:
|
||||
ref: ${{ needs.check.outputs.sha }}
|
||||
tag_latest: true
|
||||
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**johnellisATlinuxDOTcom**.
|
||||
**communityATcoffeylabsDOTorg**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
@@ -73,13 +73,14 @@ start it, and neither will closing and reopening.
|
||||
|
||||
### Translations
|
||||
|
||||
Nine languages ship alongside English: German, Spanish, French, Dutch,
|
||||
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, in
|
||||
Ten languages ship alongside English: German, Spanish, French, Dutch,
|
||||
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese, Japanese and
|
||||
Turkish, in
|
||||
`web/src/locales/`. A missing key renders its English source rather than
|
||||
failing, so an untranslated string is invisible until somebody reading that
|
||||
language finds it.
|
||||
|
||||
**Any change that adds or alters a user-visible string adds work in all nine
|
||||
**Any change that adds or alters a user-visible string adds work in all ten
|
||||
catalogs.** Say so explicitly in the PR — how many keys, and the fallback
|
||||
count before and after — and say so just as explicitly when a change adds none,
|
||||
so it is never left to be inferred.
|
||||
@@ -94,7 +95,7 @@ plural(n, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })
|
||||
|
||||
is keyed on **`"Deleted {n} contacts"`**. Keying the catalog on the `one`
|
||||
form type-checks, builds, passes every test, and silently falls back to English
|
||||
in all nine languages. Nothing errors. The only signal is the fallback count
|
||||
in all ten languages. Nothing errors. The only signal is the fallback count
|
||||
going up, so read it:
|
||||
|
||||
```sh
|
||||
@@ -212,7 +213,7 @@ and the single-host `deploy.example.sh`, are covered in
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
Please **do not** open a public issue for security vulnerabilities. Instead, report them privately by emailing **johnellisATlinuxDOTcom** with details of the issue. See `SECURITY.md` if one is present in the repo for further instructions.
|
||||
Please **do not** open a public issue for security vulnerabilities. Instead, report them privately by emailing **securityATcoffeylabsDOTorg** with details of the issue. See `SECURITY.md` if one is present in the repo for further instructions.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
<a href="LICENSE"><img alt="License: AGPL-3.0-or-later" src="https://img.shields.io/badge/license-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> Development happens on [git.coffeylabs.org/inbuxa/inbuxa-webmail](https://git.coffeylabs.org/inbuxa/inbuxa-webmail); the copy on GitHub is a read-only mirror.
|
||||
> Report issues at **[git.coffeylabs.org/inbuxa/inbuxa-webmail/issues](https://git.coffeylabs.org/inbuxa/inbuxa-webmail/issues)**, and join discussions at **[community.coffeylabs.org](https://community.coffeylabs.org)**.
|
||||
>
|
||||
> This repository was called `ihasmail-inbuxa` until October 2026. Container images are now published as `inbuxa/inbuxa-webmail`; the old `inbuxa/ihasmail-inbuxa` image stops at `2026.9.26-g654d298`.
|
||||
|
||||
The webmail of the INBUXA suite: mail, calendars, contacts, files and filters
|
||||
in one app that works as well on a phone as on a desktop. It talks only JMAP to
|
||||
the INBUXA mail server, and keeps nothing of its own: everything durable,
|
||||
@@ -29,7 +35,7 @@ settings included, lives on the server, so the container is disposable.
|
||||
Everything else is in INBUXA Admin.
|
||||
- **Sign-in on the mail server's own page**, two-factor included. The webmail
|
||||
never handles a password to sign someone in, and holds only sealed tokens.
|
||||
- **Ten interface languages and twelve themes.**
|
||||
- **Eleven interface languages and twelve themes.**
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -70,7 +76,7 @@ docker compose up --build -d
|
||||
## Source code
|
||||
|
||||
INBUXA webmail is a modified ihasmail, so the AGPL's offer is this fork:
|
||||
<https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa>. The sign-in page and Settings ›
|
||||
<https://git.coffeylabs.org/inbuxa/inbuxa-webmail>. The sign-in page and Settings ›
|
||||
About link there, beside the version, which names the commit the running build
|
||||
came from.
|
||||
|
||||
@@ -101,4 +107,4 @@ keep up. Nothing here is pushed there.
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2026 Coffey Labs. AGPL-3.0-or-later; see [LICENSE](LICENSE).
|
||||
Copyright (C) 2026 Coffey Labs LLC. AGPL-3.0-or-later; see [LICENSE](LICENSE).
|
||||
@@ -15,7 +15,7 @@ ihasmail is under active development. Security fixes are applied to the latest r
|
||||
|
||||
Instead, report security issues privately by emailing:
|
||||
|
||||
**johnellisATlinuxDOTcom**
|
||||
**securityATcoffeylabsDOTorg**
|
||||
|
||||
Please include as much of the following as you can:
|
||||
|
||||
|
||||
@@ -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://git.coffeylabs.org/inbuxa/ihasmail-inbuxa}
|
||||
SOURCE_URL: ${SOURCE_URL:-https://git.coffeylabs.org/inbuxa/inbuxa-webmail}
|
||||
TRUST_PROXY: "1"
|
||||
IMAGE_PROXY: "1"
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* inbuxa MA-B: more than one account signed in in one browser. The session
|
||||
* cookie is the account in front; the others ride in `<name>_more`. Adding
|
||||
* signs a second account in beside the first, switching swaps them, signing
|
||||
* out ends only the one in front, and an organization that doesn't allow it
|
||||
* keeps adding off.
|
||||
*/
|
||||
|
||||
const PORT = 18801;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "first-password";
|
||||
process.env.MOCK_SECOND_USER = "[email protected]";
|
||||
process.env.MOCK_SECOND_PASS = "second-password";
|
||||
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-accounts";
|
||||
// Every test here signs in several times from one address
|
||||
process.env.LOGIN_RATE_LIMIT = "100";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
const { config } = await import("./config.js");
|
||||
const { MAX_ACCOUNTS, parseOthers, serializeOthers } = await import("./accounts.js");
|
||||
|
||||
const app = createApp();
|
||||
const FRONT = config.cookieName;
|
||||
const MORE = `${config.cookieName}_more`;
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
/** A browser's cookie jar, as far as these two cookies go. */
|
||||
class Browser {
|
||||
jar = new Map<string, string>();
|
||||
|
||||
private take(res: Response) {
|
||||
for (const line of res.headers.getSetCookie()) {
|
||||
const [pair, ...attrs] = line.split(";");
|
||||
const at = pair!.indexOf("=");
|
||||
const name = pair!.slice(0, at).trim();
|
||||
const value = pair!.slice(at + 1).trim();
|
||||
const expired = attrs.some((a) => /max-age=0/i.test(a) || /expires=thu, 01 jan 1970/i.test(a));
|
||||
if (expired || !value) this.jar.delete(name);
|
||||
else this.jar.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
async call(path: string, init: { method?: string; body?: unknown } = {}): Promise<{ status: number; body: any }> {
|
||||
const cookie = [...this.jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
||||
const res = await app.request(path, {
|
||||
method: init.method ?? "GET",
|
||||
headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}) },
|
||||
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
||||
});
|
||||
this.take(res);
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null };
|
||||
}
|
||||
|
||||
signIn(username: string, password: string, add = false) {
|
||||
return this.call("/api/auth/login", { method: "POST", body: { username, password, ...(add ? { add: true } : {}) } });
|
||||
}
|
||||
|
||||
async accounts(): Promise<{ username: string; front: boolean; id: string }[]> {
|
||||
const res = await this.call("/api/auth/accounts");
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body));
|
||||
return res.body.accounts;
|
||||
}
|
||||
}
|
||||
|
||||
test("the cookie list keeps only well-formed session cookies, at most one fewer than the cap", () => {
|
||||
const good = "abcdefghij.ABCDEFGHIJKLMN";
|
||||
assert.deepEqual(parseOthers(`${good}~not a cookie~${good}`), [good]);
|
||||
const many = Array.from({ length: 9 }, (_, i) => `abcdefgh${i}x.ABCDEFGHIJKLMN`);
|
||||
assert.equal(parseOthers(many.join("~")).length, MAX_ACCOUNTS - 1);
|
||||
assert.equal(serializeOthers(["bad", good]), good);
|
||||
});
|
||||
|
||||
test("a second account joins the first, and switching swaps them", async () => {
|
||||
const b = new Browser();
|
||||
assert.equal((await b.signIn("[email protected]", "first-password")).status, 200);
|
||||
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"]);
|
||||
const first = b.jar.get(FRONT);
|
||||
|
||||
const added = await b.signIn("[email protected]", "second-password", true);
|
||||
assert.equal(added.status, 200, JSON.stringify(added.body));
|
||||
assert.equal(added.body.added, true);
|
||||
assert.equal(b.jar.get(MORE), first, "the first account moved beside the new one");
|
||||
let accounts = await b.accounts();
|
||||
assert.deepEqual(accounts.map((a) => [a.username, a.front]), [["[email protected]", true], ["[email protected]", false]]);
|
||||
|
||||
// The same account again is not a second copy
|
||||
await b.signIn("[email protected]", "first-password", true);
|
||||
accounts = await b.accounts();
|
||||
assert.equal(accounts.length, 2);
|
||||
assert.equal(accounts[0]!.username, "[email protected]", "it came to the front instead");
|
||||
|
||||
// Switch back
|
||||
const second = accounts.find((a) => !a.front)!;
|
||||
assert.equal((await b.call(`/api/auth/accounts/${second.id}/front`, { method: "POST" })).status, 200);
|
||||
assert.equal((await b.accounts())[0]!.username, "[email protected]");
|
||||
|
||||
// Signing out ends only the one in front; the other comes forward
|
||||
const out = await b.call("/api/auth/logout", { method: "POST" });
|
||||
assert.equal(out.body.next, true);
|
||||
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"]);
|
||||
|
||||
// Sign out of all
|
||||
await b.signIn("[email protected]", "second-password", true);
|
||||
assert.equal((await b.accounts()).length, 2);
|
||||
await b.call("/api/auth/logout-all", { method: "POST" });
|
||||
assert.equal(b.jar.has(FRONT), false);
|
||||
assert.equal(b.jar.has(MORE), false);
|
||||
assert.equal((await b.call("/api/auth/accounts")).status, 401);
|
||||
});
|
||||
|
||||
test("an account in front can't switch to one it doesn't hold", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
assert.equal((await b.call("/api/auth/accounts/not-a-session/front", { method: "POST" })).status, 404);
|
||||
});
|
||||
|
||||
test("an organization that doesn't allow it keeps adding off", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
process.env.MOCK_NO_ADD_ACCOUNTS = "1";
|
||||
try {
|
||||
// The cached upstream session is a minute old at most; ask afresh
|
||||
await b.call("/api/auth/session?refresh=1");
|
||||
const res = await b.call("/api/auth/accounts");
|
||||
assert.equal(res.body.canAdd, false);
|
||||
const added = await b.signIn("[email protected]", "second-password", true);
|
||||
assert.equal(added.status, 403);
|
||||
assert.equal(added.body.error, "add_not_allowed");
|
||||
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"], "the front stayed");
|
||||
} finally {
|
||||
delete process.env.MOCK_NO_ADD_ACCOUNTS;
|
||||
}
|
||||
});
|
||||
|
||||
test("inbuxa MA-8: the accounts not in front report their Inbox unread count", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
// Alone, there is nothing to report
|
||||
assert.deepEqual((await b.call("/api/auth/accounts/unread")).body.accounts, []);
|
||||
await b.signIn("[email protected]", "second-password", true);
|
||||
const res = await b.call("/api/auth/accounts/unread");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.accounts.length, 1, "only the account not in front");
|
||||
const [other] = res.body.accounts;
|
||||
const listed = (await b.accounts()).find((a) => !a.front)!;
|
||||
assert.equal(other.id, listed.id);
|
||||
assert.equal(typeof other.unread, "number", JSON.stringify(res.body));
|
||||
});
|
||||
|
||||
test("inbuxa MA-8: only push, mailboxes and marking mail reach an account not in front", async () => {
|
||||
const { otherAccountCallAllowed } = await import("./app.js");
|
||||
assert.equal(otherAccountCallAllowed(["PushSubscription/get", { ids: null }, "0"]), true);
|
||||
assert.equal(otherAccountCallAllowed(["Mailbox/get", { accountId: "a" }, "0"]), true);
|
||||
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { "keywords/$seen": true } } }, "0"]), true);
|
||||
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { mailboxIds: { arch: true } } } }, "0"]), true);
|
||||
// Anything else is refused
|
||||
assert.equal(otherAccountCallAllowed(["Email/get", { accountId: "a" }, "0"]), false);
|
||||
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", destroy: ["m1"] }, "0"]), false);
|
||||
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", create: { x: {} } }, "0"]), false);
|
||||
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { subject: "x" } } }, "0"]), false);
|
||||
assert.equal(otherAccountCallAllowed(["EmailSubmission/set", {}, "0"]), false);
|
||||
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
const added = await b.signIn("[email protected]", "second-password", true);
|
||||
assert.equal(added.status, 200, JSON.stringify(added.body));
|
||||
const other = (await b.accounts()).find((a) => !a.front)!;
|
||||
const ok = await b.call(`/api/auth/accounts/${other.id}/jmap`, {
|
||||
method: "POST",
|
||||
body: { using: ["urn:ietf:params:jmap:core"], methodCalls: [["PushSubscription/get", { ids: null }, "0"]] },
|
||||
});
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
||||
assert.equal(ok.body.methodResponses[0][0], "PushSubscription/get");
|
||||
const refused = await b.call(`/api/auth/accounts/${other.id}/jmap`, {
|
||||
method: "POST",
|
||||
body: { using: [], methodCalls: [["Email/get", { accountId: "x", ids: null }, "0"]] },
|
||||
});
|
||||
assert.equal(refused.status, 403);
|
||||
// The account in front isn't reached this way, nor a session not held here
|
||||
const front = (await b.accounts()).find((a) => a.front)!;
|
||||
assert.equal((await b.call(`/api/auth/accounts/${front.id}/jmap`, { method: "POST", body: { methodCalls: [] } })).status, 404);
|
||||
});
|
||||
|
||||
test("inbuxa MA-8: each listed account says which mail account it is", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
await b.signIn("[email protected]", "second-password", true);
|
||||
const res = await b.call("/api/auth/accounts");
|
||||
for (const a of res.body.accounts) assert.equal(typeof a.mailAccountId, "string", JSON.stringify(a));
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* More than one signed-in account in a browser (multi-account spec, MA-B).
|
||||
*
|
||||
* The session cookie is unchanged: it is the account in front, and every
|
||||
* request is answered with it, so nothing else in the server has to know
|
||||
* there may be others. The others ride in a second cookie, `<name>_more`: a
|
||||
* list of their own session cookies, each `id.secret` exactly as the front one
|
||||
* is. Switching swaps one of them into front; adding moves the front one into
|
||||
* the list. Each session stays its own -- its own sealed credential, its own
|
||||
* expiry, its own "this is my device" -- and nothing about one can be read
|
||||
* through another.
|
||||
*
|
||||
* At most `MAX_ACCOUNTS` in all, all on the same mail server (MA-9), and only
|
||||
* while both accounts' organizations allow it (`addAccounts`, MA-C).
|
||||
*/
|
||||
import type { UpstreamSession } from "./upstream.js";
|
||||
|
||||
export const MAX_ACCOUNTS = 5;
|
||||
|
||||
/** A session cookie's shape: `id.secret`, both base64url. Anything else is dropped. */
|
||||
const COOKIE_SHAPE = /^[A-Za-z0-9_-]{8,128}\.[A-Za-z0-9_-]{8,256}$/;
|
||||
const SEP = "~";
|
||||
|
||||
export function parseOthers(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const part of value.split(SEP)) {
|
||||
if (!COOKIE_SHAPE.test(part) || seen.has(part)) continue;
|
||||
seen.add(part);
|
||||
out.push(part);
|
||||
if (out.length >= MAX_ACCOUNTS - 1) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function serializeOthers(cookies: string[]): string {
|
||||
return cookies.filter((c) => COOKIE_SHAPE.test(c)).slice(0, MAX_ACCOUNTS - 1).join(SEP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account behind `upstream` may have other accounts beside it:
|
||||
* `addAccounts` on its own account's `urn:inbuxa:jmap` capability. A server
|
||||
* that doesn't say (an older one, or not inbuxa) allows it, as before.
|
||||
*/
|
||||
export function mayAddAccounts(upstream: UpstreamSession): boolean {
|
||||
const primary = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? Object.keys(upstream.accounts ?? {})[0];
|
||||
const account = primary ? (upstream.accounts?.[primary] as { accountCapabilities?: Record<string, unknown> } | undefined) : undefined;
|
||||
const inbuxa = account?.accountCapabilities?.["urn:inbuxa:jmap"] as { addAccounts?: unknown } | undefined;
|
||||
return inbuxa?.addAccounts !== false;
|
||||
}
|
||||
|
||||
/** Why an account can't be added, in words for the person. */
|
||||
export const ADD_REFUSED: Record<string, string> = {
|
||||
add_full: `You can have at most ${MAX_ACCOUNTS} accounts open here.`,
|
||||
add_not_allowed: "Your organization doesn't allow adding other accounts here.",
|
||||
add_other_server: "That account is on another mail server. Only accounts on this server can be added.",
|
||||
};
|
||||
@@ -41,10 +41,11 @@ import {
|
||||
revokeAppPassword,
|
||||
} from "./account.js";
|
||||
import { imageProxyHandler } from "./imageproxy.js";
|
||||
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js";
|
||||
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, passwordConfirms, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js";
|
||||
import { icsProxyHandler } from "./icsproxy.js";
|
||||
import { staticHandler } from "./static.js";
|
||||
import { mailNode, webmailNode } from "./nodes.js";
|
||||
import { ADD_REFUSED, MAX_ACCOUNTS, mayAddAccounts, parseOthers, serializeOthers } from "./accounts.js";
|
||||
|
||||
type Env = { Variables: { session: LiveSession } };
|
||||
|
||||
@@ -330,6 +331,146 @@ function setSessionCookie(c: Context, value: string, remember: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* inbuxa MA-B: the other signed-in accounts, beside the one in front. See
|
||||
* accounts.ts. Kept across a browser restart only when every account in it
|
||||
* would be (MA-7).
|
||||
*/
|
||||
const OTHERS_COOKIE = `${config.cookieName}_more`;
|
||||
|
||||
function setOthersCookie(c: Context, cookies: string[]) {
|
||||
if (!cookies.length) {
|
||||
deleteCookie(c, OTHERS_COOKIE, { path: cookiePath });
|
||||
return;
|
||||
}
|
||||
const remember = cookies.every((cookie) => sessions.resolve(cookie)?.remember === true);
|
||||
setCookie(c, OTHERS_COOKIE, serializeOthers(cookies), {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
secure: isSecureRequest(c),
|
||||
path: cookiePath,
|
||||
...(remember ? { maxAge: config.sessionRememberTtl } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** The other accounts still signed in, in their order; ended ones are left out. */
|
||||
function liveOthers(c: Context): { cookie: string; session: LiveSession }[] {
|
||||
const out: { cookie: string; session: LiveSession }[] = [];
|
||||
for (const cookie of parseOthers(getCookie(c, OTHERS_COOKIE))) {
|
||||
const session = sessions.resolve(cookie);
|
||||
if (session) out.push({ cookie, session });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/*
|
||||
* inbuxa MA-8: what may be asked of a signed-in account that isn't in front.
|
||||
*
|
||||
* Its push subscription has to be registered, verified and renewed through
|
||||
* its own session -- a JMAP push subscription belongs to whoever signs the
|
||||
* request -- and a notification's Archive and Mark read act on its mail. Those
|
||||
* four methods, and for Email/set only changes to keywords and mailboxes: the
|
||||
* browser already holds the session, so this reaches nothing new, but it is
|
||||
* kept to what the notifications need.
|
||||
*/
|
||||
const OTHER_ACCOUNT_METHODS = new Set(["PushSubscription/get", "PushSubscription/set", "Mailbox/get", "Email/set"]);
|
||||
|
||||
export function otherAccountCallAllowed(call: unknown): boolean {
|
||||
if (!Array.isArray(call) || call.length !== 3) return false;
|
||||
const [method, args] = call as [unknown, unknown, unknown];
|
||||
if (typeof method !== "string" || !OTHER_ACCOUNT_METHODS.has(method)) return false;
|
||||
if (typeof args !== "object" || args === null) return false;
|
||||
if (method !== "Email/set") return true;
|
||||
const set = args as { create?: unknown; destroy?: unknown; update?: unknown };
|
||||
if (set.create !== undefined || set.destroy !== undefined) return false;
|
||||
if (typeof set.update !== "object" || set.update === null) return false;
|
||||
return Object.values(set.update as Record<string, unknown>).every(
|
||||
(patch) =>
|
||||
typeof patch === "object" &&
|
||||
patch !== null &&
|
||||
Object.keys(patch).every((k) => k === "keywords" || k === "mailboxIds" || k.startsWith("keywords/") || k.startsWith("mailboxIds/")),
|
||||
);
|
||||
}
|
||||
|
||||
/** inbuxa MA-8: Inbox unread counts of accounts not in front, briefly kept. */
|
||||
const UNREAD_CACHE_MS = 60_000;
|
||||
const unreadCache = new Map<string, { unread: number | null; at: number }>();
|
||||
|
||||
/** The Inbox's unread count for one session's account, or null when it has none. */
|
||||
async function inboxUnread(session: LiveSession): Promise<number | null> {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
if (!accountId) return null;
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
methodCalls: [["Mailbox/get", { accountId, ids: null, properties: ["role", "unreadEmails"] }, "0"]],
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { methodResponses?: [string, { list?: { role?: string | null; unreadEmails?: number }[] }, string][] };
|
||||
const inbox = body.methodResponses?.[0]?.[1]?.list?.find((m) => m.role === "inbox");
|
||||
return typeof inbox?.unreadEmails === "number" ? inbox.unreadEmails : null;
|
||||
}
|
||||
|
||||
/** Whether the account in front may have more beside it, or why not. */
|
||||
async function addRefusal(c: Context, front: LiveSession): Promise<string | null> {
|
||||
if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full";
|
||||
try {
|
||||
const upstream = await getUpstreamSession(front.id, front.authorization, upstreamFor(front.username));
|
||||
if (!mayAddAccounts(upstream)) return "add_not_allowed";
|
||||
} catch {
|
||||
return "add_not_allowed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A session just signed in to be added beside the one in front (MA-B). It
|
||||
* comes to the front and the old front joins the others; or, refused, it is
|
||||
* ended and the front stays. Returns the refusal, or null.
|
||||
*/
|
||||
async function joinAccount(
|
||||
c: Context,
|
||||
created: { cookie: string; session: LiveSession },
|
||||
upstream: Awaited<ReturnType<typeof fetchUpstreamSession>>,
|
||||
): Promise<string | null> {
|
||||
const frontCookie = getCookie(c, config.cookieName);
|
||||
const front = sessions.resolve(frontCookie);
|
||||
if (!front || !frontCookie) {
|
||||
// Nobody in front any more: an ordinary sign-in
|
||||
setSessionCookie(c, created.cookie, created.session.remember);
|
||||
return null;
|
||||
}
|
||||
const others = liveOthers(c);
|
||||
const end = (code: string | null) => {
|
||||
sessions.destroy(created.session.id);
|
||||
forgetUpstreamSession(created.session.id);
|
||||
return code;
|
||||
};
|
||||
if (upstreamFor(created.session.username) !== upstreamFor(front.username)) return end("add_other_server");
|
||||
// Both organizations must allow it
|
||||
if (!mayAddAccounts(upstream)) return end("add_not_allowed");
|
||||
const frontRefusal = await addRefusal(c, front);
|
||||
if (frontRefusal === "add_not_allowed") return end(frontRefusal);
|
||||
// Already open: that one comes to the front instead of a second copy
|
||||
if (created.session.account === front.account) return end(null);
|
||||
const existing = others.find((o) => o.session.account === created.session.account);
|
||||
if (existing) {
|
||||
end(null);
|
||||
setSessionCookie(c, existing.cookie, existing.session.remember);
|
||||
setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== existing).map((o) => o.cookie)]);
|
||||
return null;
|
||||
}
|
||||
if (1 + others.length >= MAX_ACCOUNTS) return end("add_full");
|
||||
setSessionCookie(c, created.cookie, created.session.remember);
|
||||
setOthersCookie(c, [frontCookie, ...others.map((o) => o.cookie)]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function upstreamFailure(c: Context, err: unknown) {
|
||||
if (err instanceof UpstreamError) {
|
||||
return c.json({ error: err.status === 401 ? "invalid_credentials" : "upstream_error", message: err.message }, err.status as 401 | 502);
|
||||
@@ -406,8 +547,19 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
return c.redirect(`${basePath}/?signin_error=rate_limited`, 302);
|
||||
}
|
||||
const username = (c.req.query("username") ?? "").trim().slice(0, 320);
|
||||
// inbuxa MA-B: another account beside the one in front, if it may have one
|
||||
const front = c.req.query("add") === "1" ? sessions.resolve(getCookie(c, config.cookieName)) : null;
|
||||
if (front) {
|
||||
const refused = await addRefusal(c, front);
|
||||
if (refused) return c.redirect(`${basePath}/?account_error=${refused}`, 302);
|
||||
}
|
||||
try {
|
||||
const { location, state } = await startSignIn({ username, base: upstreamFor(username), remember: c.req.query("remember") === "1" });
|
||||
const { location, state } = await startSignIn({
|
||||
username,
|
||||
base: upstreamFor(username),
|
||||
remember: c.req.query("remember") === "1",
|
||||
adding: front !== null,
|
||||
});
|
||||
setCookie(c, OAUTH_STATE_COOKIE, state, { httpOnly: true, sameSite: "Lax", secure: isSecureRequest(c), path: `${basePath}/api/auth`, maxAge: 600 });
|
||||
return c.redirect(location, 302);
|
||||
} catch (err) {
|
||||
@@ -452,6 +604,11 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
ip: clientIp(c),
|
||||
});
|
||||
if (result.adding) {
|
||||
const refused = await joinAccount(c, { cookie, session }, upstream);
|
||||
if (refused) return c.redirect(`${basePath}/?account_error=${refused}`, 302);
|
||||
return c.redirect(`${basePath}/`, 302);
|
||||
}
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
|
||||
@@ -473,7 +630,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
|
||||
let body: { username?: string; password?: string; totp?: string; remember?: boolean; add?: boolean };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
@@ -536,6 +693,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
ip,
|
||||
});
|
||||
// inbuxa MA-B: beside the account in front, when that's what was asked
|
||||
if (body.add && sessions.resolve(getCookie(c, config.cookieName))) {
|
||||
const refused = await joinAccount(c, { cookie, session }, upstream);
|
||||
if (refused) return c.json({ error: refused, message: ADD_REFUSED[refused] }, 403);
|
||||
return c.json({ ok: true, added: true });
|
||||
}
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
// Start the account's push subscription now, so it is usually verified
|
||||
// by the time the browser opens its stream. See push.ts.
|
||||
@@ -606,7 +769,123 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
}
|
||||
// inbuxa MA-B: only this account ends; the next one comes to the front
|
||||
const [next, ...rest] = liveOthers(c);
|
||||
if (next) {
|
||||
setSessionCookie(c, next.cookie, next.session.remember);
|
||||
setOthersCookie(c, rest.map((o) => o.cookie));
|
||||
return c.json({ ok: true, next: true });
|
||||
}
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
deleteCookie(c, OTHERS_COOKIE, { path: cookiePath });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/* inbuxa MA-B: every account signed in here ends. */
|
||||
api.post("/auth/logout-all", async (c) => {
|
||||
const front = sessions.resolve(getCookie(c, config.cookieName));
|
||||
for (const session of [front, ...liveOthers(c).map((o) => o.session)]) {
|
||||
if (!session) continue;
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
}
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
deleteCookie(c, OTHERS_COOKIE, { path: cookiePath });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/* inbuxa MA-B: the accounts signed in here, the one in front first, and whether one more may be added. */
|
||||
api.get("/auth/accounts", requireSession, async (c) => {
|
||||
const front = c.get("session");
|
||||
const others = liveOthers(c);
|
||||
if (others.length !== parseOthers(getCookie(c, OTHERS_COOKIE)).length) {
|
||||
setOthersCookie(c, others.map((o) => o.cookie));
|
||||
}
|
||||
const canAdd = (await addRefusal(c, front)) === null;
|
||||
// inbuxa MA-8: each one's mail account, which its push subscription and
|
||||
// a notification's buttons need
|
||||
const accounts = await Promise.all(
|
||||
[front, ...others.map((o) => o.session)].map(async (s, i) => {
|
||||
let mailAccountId: string | null = null;
|
||||
try {
|
||||
const upstream = await getUpstreamSession(s.id, s.authorization, upstreamFor(s.username));
|
||||
mailAccountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? null;
|
||||
} catch {
|
||||
/* unknown for now: push for it waits for the next start */
|
||||
}
|
||||
return { id: s.id, username: s.username, front: i === 0, mailAccountId };
|
||||
}),
|
||||
);
|
||||
return c.json({ accounts, canAdd, max: MAX_ACCOUNTS });
|
||||
});
|
||||
|
||||
/*
|
||||
* inbuxa MA-8: the Inbox unread count of each account not in front, asked
|
||||
* through that account's own session, so the menu can say where new mail
|
||||
* is. A minute's cache per account: the web app asks every few minutes, and
|
||||
* several tabs may ask at once.
|
||||
*/
|
||||
api.get("/auth/accounts/unread", requireSession, async (c) => {
|
||||
const answers = await Promise.all(
|
||||
liveOthers(c).map(async ({ cookie, session }) => {
|
||||
const cached = unreadCache.get(session.id);
|
||||
if (cached && Date.now() - cached.at < UNREAD_CACHE_MS) return { id: session.id, unread: cached.unread };
|
||||
try {
|
||||
let live: LiveSession | null = session;
|
||||
if (live.tokens && needsRefresh(live.tokens)) live = await refreshSession(cookie, live);
|
||||
if (!live) return { id: session.id, unread: null };
|
||||
const unread = await inboxUnread(live);
|
||||
unreadCache.set(session.id, { unread, at: Date.now() });
|
||||
return { id: session.id, unread };
|
||||
} catch {
|
||||
return { id: session.id, unread: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return c.json({ accounts: answers });
|
||||
});
|
||||
|
||||
/* inbuxa MA-8: a narrow JMAP route to a signed-in account not in front; see OTHER_ACCOUNT_METHODS. */
|
||||
api.post("/auth/accounts/:id/jmap", requireSession, async (c) => {
|
||||
const other = liveOthers(c).find((o) => o.session.id === c.req.param("id"));
|
||||
if (!other) return c.json({ error: "not_found" }, 404);
|
||||
let body: { using?: unknown; methodCalls?: unknown };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "bad_request" }, 400);
|
||||
}
|
||||
const calls = body.methodCalls;
|
||||
if (!Array.isArray(calls) || calls.length === 0 || calls.length > 16 || !calls.every(otherAccountCallAllowed)) {
|
||||
return c.json({ error: "forbidden", message: "Only push subscriptions, mailboxes and marking mail can be reached in another account." }, 403);
|
||||
}
|
||||
const using = Array.isArray(body.using) ? body.using.filter((u): u is string => typeof u === "string") : [];
|
||||
let session: LiveSession | null = other.session;
|
||||
if (session.tokens && needsRefresh(session.tokens)) session = await refreshSession(other.cookie, session);
|
||||
if (!session) return c.json({ error: "unauthenticated" }, 401);
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ using, methodCalls: calls }),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) return c.json({ error: "unauthenticated" }, 401);
|
||||
return c.json(await res.json(), res.ok ? 200 : 502);
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
/* inbuxa MA-B: bring another signed-in account to the front. */
|
||||
api.post("/auth/accounts/:id/front", requireSession, async (c) => {
|
||||
const frontCookie = getCookie(c, config.cookieName)!;
|
||||
const others = liveOthers(c);
|
||||
const chosen = others.find((o) => o.session.id === c.req.param("id"));
|
||||
if (!chosen) return c.json({ error: "not_found" }, 404);
|
||||
setSessionCookie(c, chosen.cookie, chosen.session.remember);
|
||||
setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== chosen).map((o) => o.cookie)]);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -1127,11 +1406,10 @@ async function readJson<T>(c: Context): Promise<T | null> {
|
||||
*/
|
||||
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
|
||||
if (session.tokens) {
|
||||
// Holding no password, the only judge is the server.
|
||||
// Holding no password, the only judge is the server, asked on its sign-in
|
||||
// endpoint since it takes no password over JMAP.
|
||||
try {
|
||||
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
|
||||
await fetchUpstreamSession(authorization, upstreamFor(session.username));
|
||||
return true;
|
||||
return await passwordConfirms({ base: upstreamFor(session.username), username: session.username, password: candidate });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -292,10 +292,10 @@ export const config = {
|
||||
*
|
||||
* The AGPL asks whoever *runs* a modified version to offer that version's
|
||||
* source, not the one it was forked from -- so anyone deploying a patched
|
||||
* ihasmail should point this at their own tree. ihasmail-inbuxa is itself
|
||||
* ihasmail should point this at their own tree. inbuxa-webmail is itself
|
||||
* such a tree, so the default is INBUXA's fork.
|
||||
*/
|
||||
sourceUrl: env("SOURCE_URL", "https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa"),
|
||||
sourceUrl: env("SOURCE_URL", "https://git.coffeylabs.org/inbuxa/inbuxa-webmail"),
|
||||
/**
|
||||
* ihasmail-inbuxa: this webmail's own name, shown in Settings > About next
|
||||
* to the mail node it talks to. Set per host by the deploy; empty means the
|
||||
|
||||
@@ -122,7 +122,11 @@ export function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [
|
||||
hasAttachment: false,
|
||||
preview: body.slice(0, 120),
|
||||
textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||
htmlBody: [],
|
||||
// `htmlBody` is derived (RFC 8621 4.1.4): a message with no HTML
|
||||
// alternative still gets one, holding the text/plain part. Checked against
|
||||
// Stalwart 0.16.21 on 2026-09-10 -- see hasHtmlAlternative() in the client,
|
||||
// which reads the part's type rather than trusting this list to be empty.
|
||||
htmlBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||
attachments: [],
|
||||
bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } },
|
||||
bodyStructure: {
|
||||
@@ -192,7 +196,8 @@ export function addEmail(o: { from: [string, string]; to?: string; subject: stri
|
||||
from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null,
|
||||
subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "),
|
||||
textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [],
|
||||
// No HTML alternative means `htmlBody` names the text part, not nothing. See addSignedEmail.
|
||||
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||
attachments,
|
||||
bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) },
|
||||
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
|
||||
|
||||
@@ -14,7 +14,7 @@ import { MAX_OBJECTS, MethodError, directory, enforceLimits, resolveRefs } from
|
||||
import { handlers } from "./handlers.js";
|
||||
export { account } from "./config.js";
|
||||
import { checkOtp } from "./auth.js";
|
||||
import { checkBearer, handleOAuth } from "./oauth.js";
|
||||
import { basicRefused, checkBearer, handleOAuth } from "./oauth.js";
|
||||
import { sseClients, broadcast } from "./events.js";
|
||||
|
||||
/* ---------- http ---------- */
|
||||
@@ -23,10 +23,26 @@ function unauthorized(res: ServerResponse) {
|
||||
res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" }));
|
||||
}
|
||||
|
||||
/*
|
||||
* inbuxa MA-B: an optional second user, so the account switcher has two real
|
||||
* accounts to move between. It signs in with a password only, and reads the
|
||||
* same mailbox: what the tests look at is who the session says it is.
|
||||
*/
|
||||
const SECOND_USER = process.env.MOCK_SECOND_USER;
|
||||
const SECOND_PASS = process.env.MOCK_SECOND_PASS;
|
||||
|
||||
/** Who a Basic header signs in as, when it is the second user. */
|
||||
function secondUser(req: IncomingMessage): boolean {
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (!SECOND_USER || !h.startsWith("Basic ")) return false;
|
||||
return Buffer.from(h.slice(6), "base64").toString() === `${SECOND_USER}:${SECOND_PASS}`;
|
||||
}
|
||||
|
||||
function checkAuth(req: IncomingMessage): boolean {
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (checkBearer(h)) return true;
|
||||
if (!h.startsWith("Basic ")) return false;
|
||||
if (secondUser(req)) return true;
|
||||
if (!h.startsWith("Basic ") || basicRefused) return false;
|
||||
const raw = Buffer.from(h.slice(6), "base64").toString();
|
||||
const sep = raw.indexOf(":");
|
||||
if (sep < 0) return false;
|
||||
@@ -83,7 +99,14 @@ export const server = createServer(async (req, res) => {
|
||||
if (!checkAuth(req)) return unauthorized(res);
|
||||
if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify(session()));
|
||||
const answer = session() as ReturnType<typeof session> & { username: string };
|
||||
if (secondUser(req)) answer.username = SECOND_USER!;
|
||||
// MA-C: an organization that doesn't allow adding accounts
|
||||
if (process.env.MOCK_NO_ADD_ACCOUNTS === "1") {
|
||||
const own = answer.accounts[ACCOUNT] as { accountCapabilities: Record<string, unknown> };
|
||||
own.accountCapabilities = { ...own.accountCapabilities, "urn:inbuxa:jmap": { addAccounts: false } };
|
||||
}
|
||||
return res.end(JSON.stringify(answer));
|
||||
}
|
||||
// The account info endpoint; the only place a server reports its edition.
|
||||
if (url.pathname === "/api/account" && req.method === "GET") {
|
||||
|
||||
@@ -23,11 +23,18 @@ const refreshTokens = new Map<string, Grant>();
|
||||
|
||||
const base = () => `http://127.0.0.1:${PORT}`;
|
||||
|
||||
/**
|
||||
* Whether JMAP refuses Basic, as INBUXA's server does outside DAV (contract
|
||||
* C-23). Off by default, since the mock also serves password sign-in.
|
||||
*/
|
||||
export let basicRefused = false;
|
||||
|
||||
/** For tests: how long new access tokens last, and a way to end every token. */
|
||||
export const oauthMock = {
|
||||
setAccessTokenTtl(seconds: number) { accessTokenTtl = seconds; },
|
||||
expireAccessTokens() { for (const t of accessTokens.values()) t.expiresAt = 0; },
|
||||
reset() { codes.clear(); accessTokens.clear(); refreshTokens.clear(); accessTokenTtl = 3600; },
|
||||
refuseBasic(on: boolean) { basicRefused = on; },
|
||||
reset() { codes.clear(); accessTokens.clear(); refreshTokens.clear(); accessTokenTtl = 3600; basicRefused = false; },
|
||||
};
|
||||
|
||||
/** A bearer token the mock issued, still valid under the current password. */
|
||||
@@ -50,6 +57,14 @@ function readForm(req: IncomingMessage): Promise<URLSearchParams> {
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<Buffer> {
|
||||
return new Promise((resolve) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
function issue(res: ServerResponse, refresh: string | null) {
|
||||
const access = `mock-at-${randomBytes(16).toString("hex")}`;
|
||||
accessTokens.set(access, { password: account.password, expiresAt: Date.now() + accessTokenTtl * 1000 });
|
||||
@@ -93,6 +108,35 @@ export async function handleOAuth(req: IncomingMessage, res: ServerResponse, url
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === "/api/auth" && req.method === "POST") {
|
||||
// The server's sign-in page posts here; the mock answers for the demo user.
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = JSON.parse((await readBody(req)).toString()) as Record<string, unknown>;
|
||||
} catch {
|
||||
json(res, 400, { error: "invalid_request" });
|
||||
return true;
|
||||
}
|
||||
const redirectUri = typeof body.redirectUri === "string" ? body.redirectUri : "";
|
||||
if (body.type !== "authCode" || body.clientId !== OAUTH_CLIENT_ID || !redirectUri || body.codeChallengeMethod !== "S256") {
|
||||
json(res, 400, { error: "invalid_request" });
|
||||
return true;
|
||||
}
|
||||
const secret = typeof body.accountSecret === "string" ? body.accountSecret : "";
|
||||
const passwordOk = body.accountName === USER && (secret === account.password || account.appPasswords.some((a) => a.secret === secret));
|
||||
if (!passwordOk) {
|
||||
json(res, 200, { type: "failure" });
|
||||
return true;
|
||||
}
|
||||
if (account.otpUrl && secret === account.password && !body.mfaToken) {
|
||||
json(res, 200, { type: "mfaRequired" });
|
||||
return true;
|
||||
}
|
||||
const code = randomBytes(16).toString("hex");
|
||||
codes.set(code, { challenge: String(body.codeChallenge ?? ""), redirectUri, issuedAt: Date.now() });
|
||||
json(res, 200, { type: "authenticated", client_code: code, iss: base() });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === "/auth/token" && req.method === "POST") {
|
||||
const form = await readForm(req);
|
||||
if (form.get("client_id") !== OAUTH_CLIENT_ID || form.get("client_secret") !== OAUTH_CLIENT_SECRET) {
|
||||
|
||||
@@ -198,6 +198,8 @@ test("a password change signs the session out, since the server revokes its toke
|
||||
|
||||
test("creating an app password checks the typed password with the server", async () => {
|
||||
await signIn();
|
||||
// As INBUXA's server does: no password over JMAP (contract C-23).
|
||||
oauthMock.refuseBasic(true);
|
||||
const wrong = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "nope" }) });
|
||||
assert.equal(wrong.status, 403);
|
||||
const right = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "demo-password" }) });
|
||||
@@ -215,3 +217,23 @@ test("push keeps a credential that renews itself", async () => {
|
||||
assert.notEqual(second, first, "a fresh access token");
|
||||
assert.match(second, /^Bearer mock-at-/);
|
||||
});
|
||||
|
||||
test("inbuxa MA-B: adding an account asks the server's page to sign in again", async () => {
|
||||
// With nobody in front, add=1 is an ordinary sign-in
|
||||
let res = await call("/api/auth/oauth/[email protected]&add=1");
|
||||
assert.equal(new URL(res.headers.get("location")!).searchParams.has("prompt"), false);
|
||||
|
||||
await signIn();
|
||||
res = await call("/api/auth/oauth/[email protected]&add=1");
|
||||
assert.equal(res.status, 302);
|
||||
const signInPage = new URL(res.headers.get("location")!);
|
||||
assert.equal(signInPage.searchParams.get("prompt"), "login", "the server's page must not reuse the first sign-in");
|
||||
|
||||
// The mock's page signs the same account in again: it stays one account
|
||||
const approved = await fetch(signInPage, { redirect: "manual" });
|
||||
const back = new URL(approved.headers.get("location")!);
|
||||
res = await call(`/api/auth/callback${back.search}`);
|
||||
assert.equal(res.headers.get("location"), "/");
|
||||
const list = await jsonOf(await call("/api/auth/accounts"));
|
||||
assert.deepEqual(list.accounts.map((a: { username: string }) => a.username), ["[email protected]"]);
|
||||
});
|
||||
@@ -97,6 +97,8 @@ interface Pending {
|
||||
base: string;
|
||||
username: string;
|
||||
remember: boolean;
|
||||
/** MA-B: signing in a second account beside the one in front. */
|
||||
adding: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -114,13 +116,13 @@ function challengeOf(verifier: string): string {
|
||||
* Begin a sign-in. Returns where to send the browser, and the state to bind
|
||||
* to it in a cookie.
|
||||
*/
|
||||
export async function start(params: { username: string; base: string; remember: boolean }): Promise<{ location: string; state: string }> {
|
||||
export async function start(params: { username: string; base: string; remember: boolean; adding?: boolean }): Promise<{ location: string; state: string }> {
|
||||
const metadata = await metadataFor(params.base);
|
||||
sweepPending();
|
||||
if (pending.size >= MAX_PENDING) throw new UpstreamError("Too many sign-ins in progress", 503);
|
||||
const state = randomToken(24);
|
||||
const verifier = randomToken(48);
|
||||
pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, createdAt: Date.now() });
|
||||
pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, adding: Boolean(params.adding), createdAt: Date.now() });
|
||||
const scope = ["openid", "offline_access"].filter((s) => metadata.scopes.length === 0 || metadata.scopes.includes(s)).join(" ");
|
||||
const url = new URL(metadata.authorizationEndpoint);
|
||||
url.searchParams.set("response_type", "code");
|
||||
@@ -131,9 +133,45 @@ export async function start(params: { username: string; base: string; remember:
|
||||
url.searchParams.set("code_challenge", challengeOf(verifier));
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
if (params.username) url.searchParams.set("login_hint", params.username);
|
||||
// MA-B: ask again, rather than let the server's page reuse the sign-in of
|
||||
// the account already in front
|
||||
if (params.adding) url.searchParams.set("prompt", "login");
|
||||
return { location: url.toString(), state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `password` is the account's password, for a session that holds a
|
||||
* token and so has no password to compare with.
|
||||
*
|
||||
* Asked of the server's sign-in endpoint, the one its own sign-in page posts
|
||||
* to, because the server takes no password over JMAP (contract C-23). The
|
||||
* request is this client's, to its registered redirect URI, so it passes the
|
||||
* same checks a real sign-in does. A code it issues can never be exchanged:
|
||||
* the PKCE verifier behind its challenge is thrown away here.
|
||||
*
|
||||
* "Two-factor code needed" counts as confirmed: the server says so only once
|
||||
* the password has matched.
|
||||
*/
|
||||
export async function passwordConfirms(params: { base: string; username: string; password: string }): Promise<boolean> {
|
||||
const res = await fetch(absoluteUpstream("/api/auth", params.base), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "authCode",
|
||||
accountName: params.username,
|
||||
accountSecret: params.password,
|
||||
clientId: config.oauthClientId,
|
||||
redirectUri: redirectUri(),
|
||||
codeChallenge: challengeOf(randomToken(48)),
|
||||
codeChallengeMethod: "S256",
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) throw new UpstreamError(`Password check failed (${res.status})`, 502);
|
||||
const answer = (await res.json()) as { type?: string };
|
||||
return answer.type === "authenticated" || answer.type === "mfaRequired";
|
||||
}
|
||||
|
||||
export class SignInError extends Error {
|
||||
constructor(readonly code: "state_mismatch" | "expired" | "denied" | "exchange_failed", message: string) {
|
||||
super(message);
|
||||
@@ -144,7 +182,7 @@ export class SignInError extends Error {
|
||||
* Finish a sign-in: `state` as it came back in the URL, `boundState` as the
|
||||
* browser's cookie holds it. Each state is good for one attempt.
|
||||
*/
|
||||
export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean }> {
|
||||
export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean; adding: boolean }> {
|
||||
const p = pending.get(params.state);
|
||||
if (!p || !params.boundState || params.boundState !== params.state) {
|
||||
throw new SignInError("state_mismatch", "This sign-in didn't start in this browser. Try again.");
|
||||
@@ -159,7 +197,7 @@ export async function finish(params: { state: string; boundState: string | undef
|
||||
redirect_uri: redirectUri(),
|
||||
});
|
||||
if (!tokens) throw new SignInError("exchange_failed", "The mail server didn't accept the sign-in. Try again.");
|
||||
return { tokens, base: p.base, username: p.username, remember: p.remember };
|
||||
return { tokens, base: p.base, username: p.username, remember: p.remember, adding: p.adding };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<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="icon" href="/favicon.ico?v=2026-09-27a" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png?v=2026-09-27a" />
|
||||
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png?v=2026-09-27a" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>inbuxa</title>
|
||||
</head>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 20 KiB |
@@ -51,17 +51,17 @@
|
||||
"theme_color": "#0f766e",
|
||||
"icons": [
|
||||
{
|
||||
"src": "img/icon-192.png",
|
||||
"src": "img/icon-192.png?v=2026-09-27a",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "img/icon-512.png",
|
||||
"src": "img/icon-512.png?v=2026-09-27a",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "img/icon-maskable.png",
|
||||
"src": "img/icon-maskable.png?v=2026-09-27a",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
|
||||
@@ -18,7 +18,9 @@ const VERSION = "ihasmail-v2";
|
||||
* eventually would.
|
||||
*/
|
||||
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
|
||||
const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
|
||||
// The brand images' version; the same value as BRAND_V in src/lib/brand.ts.
|
||||
const BRAND_V = "2026-09-27a";
|
||||
const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png?v=${BRAND_V}`, `${BASE}/img/icon-192.png?v=${BRAND_V}`, `${BASE}/favicon.ico?v=${BRAND_V}`];
|
||||
|
||||
/*
|
||||
* Only the app page may be kept as the app page.
|
||||
@@ -384,8 +386,11 @@ async function readFacts() {
|
||||
* rather than swallowed. A tap that silently does nothing is the failure worth
|
||||
* avoiding here: the reader has already put the phone down.
|
||||
*/
|
||||
async function jmap(methodCalls) {
|
||||
const res = await fetch(`${BASE}/api/jmap`, {
|
||||
async function jmap(methodCalls, sessionId) {
|
||||
// inbuxa MA-8: an account not in front is reached through its own session,
|
||||
// on the webmail server's narrow route for it
|
||||
const path = sessionId ? `${BASE}/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap` : `${BASE}/api/jmap`;
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" },
|
||||
@@ -454,6 +459,15 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||
event.waitUntil((async () => {
|
||||
const facts = await readFacts();
|
||||
/*
|
||||
* inbuxa MA-8: whose mail this is. The payload names its account; one that
|
||||
* isn't the account in front is one of the others signed in here, or one
|
||||
* that has been signed out since (said plainly, with nothing to act on).
|
||||
*/
|
||||
const forAccount = data && data.accountId && facts && data.accountId !== facts.accountId ? data.accountId : null;
|
||||
const other = forAccount ? (facts.others || []).find((o) => o.accountId === forAccount) || null : null;
|
||||
if (forAccount) return showOtherAccount(emails, facts, other);
|
||||
/*
|
||||
* Someone reading the app already knows. A focused, visible window of this
|
||||
* app gets its new mail from its own event stream, so a notification on
|
||||
@@ -462,7 +476,6 @@ self.addEventListener("push", (event) => {
|
||||
*/
|
||||
const windows = await self.clients.matchAll({ type: "window" });
|
||||
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
|
||||
const facts = await readFacts();
|
||||
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
||||
/*
|
||||
* Mark the app icon, without claiming a number.
|
||||
@@ -482,7 +495,7 @@ self.addEventListener("push", (event) => {
|
||||
// or a payload too large to carry the message. Say something true
|
||||
// rather than inventing a sender.
|
||||
await self.registration.showNotification(strings.newMail, {
|
||||
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
||||
icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`, badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -492,16 +505,15 @@ self.addEventListener("push", (event) => {
|
||||
const { title, body, preview } = textOf(email, strings);
|
||||
await self.registration.showNotification(title, {
|
||||
body: preview ? `${body}\n${preview}` : body,
|
||||
icon: `${BASE}/img/icon-192.png`,
|
||||
badge: `${BASE}/img/favicon-64.png`,
|
||||
icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`,
|
||||
badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`,
|
||||
tag: `ihasmail-${email.id || body}`,
|
||||
// Only where there is a message to act on: a payload without an id can
|
||||
// be shown but not archived, and a button that cannot work should not
|
||||
// be drawn.
|
||||
actions: email.id ? actionsFor(facts) : [],
|
||||
data: {
|
||||
// The route names a conversation, and `m` the message in it.
|
||||
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`,
|
||||
url: messageUrl(facts && facts.inboxId, email),
|
||||
id: email.id || null,
|
||||
title,
|
||||
accountId: facts?.accountId ?? null,
|
||||
@@ -513,6 +525,70 @@ self.addEventListener("push", (event) => {
|
||||
})());
|
||||
});
|
||||
|
||||
/*
|
||||
* inbuxa MA-8: new mail for a signed-in account that isn't in front.
|
||||
*
|
||||
* Shown even while a tab is focused: that tab's own stream only carries the
|
||||
* account in front, so nothing else would tell. The account's address is the
|
||||
* title, so it can't be taken for the front account's mail; the tag carries
|
||||
* the account, so two accounts' notifications don't replace each other; the
|
||||
* buttons act through that account's session; and opening it brings that
|
||||
* account forward before showing the message.
|
||||
*/
|
||||
async function showOtherAccount(emails, facts, other) {
|
||||
const strings = facts.strings;
|
||||
const icon = `${BASE}/img/icon-192.png?v=${BRAND_V}`;
|
||||
const badge = `${BASE}/img/favicon-64.png?v=${BRAND_V}`;
|
||||
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
||||
if (!other || !emails.length) {
|
||||
// Signed out since, or nothing to show: say only what is true
|
||||
await self.registration.showNotification(other ? other.username : strings.newMail, {
|
||||
body: other ? strings.newMail : undefined,
|
||||
icon, badge,
|
||||
tag: `ihasmail-other-${other ? other.accountId : "unknown"}`,
|
||||
data: { url: other ? openUrl(other, `${BASE}/mail`) : `${BASE}/mail` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (const email of emails.slice(0, 5)) {
|
||||
const { title, body, preview } = textOf(email, strings);
|
||||
const at = messageUrl(other.inboxId, email);
|
||||
await self.registration.showNotification(other.username, {
|
||||
body: `${title}: ${body}${preview ? `\n${preview}` : ""}`,
|
||||
icon, badge,
|
||||
tag: `ihasmail-${other.accountId}-${email.id || body}`,
|
||||
actions: email.id ? actionsFor({ ...facts, archiveId: other.archiveId }) : [],
|
||||
data: {
|
||||
url: openUrl(other, at),
|
||||
id: email.id || null,
|
||||
title: other.username,
|
||||
accountId: other.accountId,
|
||||
archiveId: other.archiveId,
|
||||
sessionId: other.sessionId,
|
||||
failed: strings.failed ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Where opening a notification for an account not in front goes: it comes forward first. */
|
||||
/**
|
||||
* Where a notification opens. The route names a mailbox by id and then a
|
||||
* conversation, and `m` the message in it. It used to say `inbox` where the id
|
||||
* goes, which the app reads as a folder that no longer exists, so every click
|
||||
* landed on the inbox list with "That folder no longer exists" instead of the
|
||||
* message. Without an inbox id from the briefing, the inbox is the honest
|
||||
* landing.
|
||||
*/
|
||||
function messageUrl(inboxId, email) {
|
||||
if (!inboxId || !email.id || !email.threadId) return `${BASE}/mail`;
|
||||
return `${BASE}/mail/${encodeURIComponent(inboxId)}/${encodeURIComponent(email.threadId)}?m=${encodeURIComponent(email.id)}`;
|
||||
}
|
||||
|
||||
function openUrl(other, next) {
|
||||
return `${BASE}/?account=${encodeURIComponent(other.sessionId)}&next=${encodeURIComponent(next)}`;
|
||||
}
|
||||
|
||||
/*
|
||||
* Do what the button said, without opening anything.
|
||||
*
|
||||
@@ -533,12 +609,12 @@ async function runAction(action, data) {
|
||||
: { "keywords/$seen": true };
|
||||
try {
|
||||
if (action === "archive" && !archiveId) throw new Error("no archive mailbox");
|
||||
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]]);
|
||||
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]], data.sessionId || null);
|
||||
} catch {
|
||||
await self.registration.showNotification(data.title || "ihasmail", {
|
||||
body: data.failed || "Could not do that — open ihasmail and try again",
|
||||
icon: `${BASE}/img/icon-192.png`,
|
||||
badge: `${BASE}/img/favicon-64.png`,
|
||||
icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`,
|
||||
badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`,
|
||||
tag: `ihasmail-failed-${id}`,
|
||||
data: { url: data.url },
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fragment, lazy, Suspense, useEffect, useState } from "react";
|
||||
import { Fragment, lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { Route, Switch, Redirect, useLocation, Router } from "wouter";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSession, useViewingDelegation } from "@/store/session";
|
||||
import { notifyOwnWhileAway, ownAccountAway, useMail } from "@/store/mail";
|
||||
import { scheduleSupported, useScheduled } from "@/store/scheduled";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
@@ -19,7 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
|
||||
import { publishWorkerFacts } from "@/lib/sw/swFacts";
|
||||
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||
import { armSettingsSync, loadRemoteSettings, loadSettingsOnce, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
||||
import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
|
||||
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
||||
@@ -119,6 +119,7 @@ export function App() {
|
||||
|
||||
function AuthedApp() {
|
||||
const accountId = useSession((s) => s.accountId);
|
||||
const viewing = useSession((s) => s.viewing);
|
||||
const [location] = useLocation();
|
||||
|
||||
/*
|
||||
@@ -140,22 +141,22 @@ function AuthedApp() {
|
||||
* Once per account, not once per mount: this subtree is keyed on the
|
||||
* language version, so picking a language throws it away and builds it
|
||||
* again. Re-reading the settings file there would apply a copy written
|
||||
* before the change and undo it.
|
||||
* before the change and undo it. And the load is not this mount's to
|
||||
* cancel: the settings file choosing a language remounts the tree midway
|
||||
* through it, and a load cut off there never armed the pushes, so nothing
|
||||
* changed afterwards was saved (Gitea issue #23). `loadSettingsOnce` runs
|
||||
* it to the end and lets every mount wait on it.
|
||||
*/
|
||||
const [ready, setReady] = useState(PAINTED_FROM_CACHE);
|
||||
useEffect(() => {
|
||||
if (settingsAlreadyLoadedFor(accountId)) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
let canceled = false;
|
||||
void (async () => {
|
||||
void loadSettingsOnce(accountId, async (isCurrent) => {
|
||||
/* Before the account's own settings, so both the seeding below and the
|
||||
enforcement inside `hydrate` have something to apply. */
|
||||
await loadSettingsPolicy();
|
||||
if (canceled) return;
|
||||
if (!isCurrent()) return;
|
||||
const remote = await loadRemoteSettings();
|
||||
if (canceled) return;
|
||||
if (!isCurrent()) return;
|
||||
if (remote) useSettings.getState().hydrate(remote);
|
||||
// No settings file: this account has never had settings of its own, so
|
||||
// the installation's defaults are what it starts on rather than
|
||||
@@ -177,15 +178,16 @@ function AuthedApp() {
|
||||
// The catalog for whatever language that turned out to be. Hydrating
|
||||
// asks for it; this is waiting for the answer.
|
||||
await whenLanguageReady();
|
||||
if (canceled) return;
|
||||
setReady(true);
|
||||
if (!isCurrent()) return;
|
||||
// Pushes were held back until now so they could not race the load. A
|
||||
// change made while it was in flight was kept, and goes out here.
|
||||
armSettingsSync();
|
||||
// No file yet — seed one from what this browser has, so the next device
|
||||
// to sign in starts from these rather than from the defaults.
|
||||
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
|
||||
})();
|
||||
}).then(() => {
|
||||
if (!canceled) setReady(true);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
@@ -230,6 +232,9 @@ function AuthedApp() {
|
||||
timer = null;
|
||||
for (const [a, types] of pending) {
|
||||
if (a === useMail.getState().accountId) void useMail.getState().applyChanges(types);
|
||||
// inbuxa AL-7: the reader's own mail, while a delegated account
|
||||
// is in view, is still announced
|
||||
if (a === ownAccountAway() && types.has("Email")) void notifyOwnWhileAway();
|
||||
if (a === useContacts.getState().accountId) useContacts.getState().applyChanges(types);
|
||||
if (a === useCalendar.getState().accountId) useCalendar.getState().applyChanges(types);
|
||||
if (a === useFiles.getState().accountId) useFiles.getState().applyChanges(types);
|
||||
@@ -253,16 +258,67 @@ function AuthedApp() {
|
||||
};
|
||||
}, [accountId]);
|
||||
|
||||
/*
|
||||
* inbuxa AL-7: a delegated account, the whole of it, when it comes into
|
||||
* view, and the reader's own when it goes back. Push carries nothing for an account only
|
||||
* shared with the reader, so while one is open it is polled.
|
||||
*/
|
||||
const viewedOnce = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!viewedOnce.current) {
|
||||
viewedOnce.current = true;
|
||||
if (!viewing) return;
|
||||
}
|
||||
const mail = useMail.getState();
|
||||
void mail.loadMailboxes();
|
||||
void mail.loadIdentities();
|
||||
// The whole account follows: calendar, contacts and files too
|
||||
void useCalendar.getState().init();
|
||||
void useContacts.getState().init();
|
||||
void useFiles.getState().init();
|
||||
if (!viewing) return;
|
||||
const poll = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void useMail.getState().applyChanges(new Set(["Email", "Mailbox"]));
|
||||
}
|
||||
}, 60_000);
|
||||
return () => window.clearInterval(poll);
|
||||
}, [viewing]);
|
||||
|
||||
// inbuxa AL-7: a delegation given or taken away while the app is open shows
|
||||
// up when the reader comes back to it, without signing in again
|
||||
useEffect(() => {
|
||||
let last = 0;
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState !== "visible" || Date.now() - last < 60_000) return;
|
||||
last = Date.now();
|
||||
void useSession.getState().refresh();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
}, []);
|
||||
|
||||
const delegationEnded = useSession((s) => s.delegationEnded);
|
||||
useEffect(() => {
|
||||
if (!delegationEnded) return;
|
||||
toast.show(t("You no longer have access to {name}. Back to your own mail.", { name: delegationEnded }));
|
||||
useSession.getState().clearDelegationEnded();
|
||||
}, [delegationEnded]);
|
||||
|
||||
// Unread badge in title/favicon
|
||||
const inboxUnread = useMail((s) => {
|
||||
const id = s.roleId("inbox");
|
||||
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
|
||||
});
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
// inbuxa AL-7: a locked account in view is named, with a padlock, in the
|
||||
// tab; a shared or group mailbox (MA-A) is named without one
|
||||
const viewingName = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined));
|
||||
const lockedInView = useViewingDelegation()?.kind === "lock";
|
||||
useEffect(() => {
|
||||
setBaseTitle(appName);
|
||||
setBaseTitle(viewingName ? `${lockedInView ? "🔒 " : ""}${viewingName} · ${appName}` : appName);
|
||||
setUnreadBadge(inboxUnread);
|
||||
}, [inboxUnread, appName]);
|
||||
}, [inboxUnread, appName, viewingName, lockedInView]);
|
||||
|
||||
/*
|
||||
* Leave the service worker its briefing.
|
||||
@@ -274,10 +330,14 @@ function AuthedApp() {
|
||||
* See lib/swFacts.ts.
|
||||
*/
|
||||
const archiveId = useMail((s) => s.roleId("archive"));
|
||||
const inboxId = useMail((s) => s.roleId("inbox"));
|
||||
const languageVersion = useLanguageVersion();
|
||||
useEffect(() => {
|
||||
void publishWorkerFacts(accountId, archiveId);
|
||||
}, [accountId, archiveId, languageVersion]);
|
||||
// inbuxa AL-7: the worker acts on the reader's own mail; while a
|
||||
// delegated account is in view, the archive folder here is its
|
||||
if (viewing) return;
|
||||
void publishWorkerFacts(accountId, archiveId, inboxId);
|
||||
}, [accountId, archiveId, inboxId, languageVersion, viewing]);
|
||||
|
||||
// Request notification permission lazily when enabled
|
||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { INBUXA_CAP, legacyProtocolsPartlyOff } from "../client";
|
||||
import { parseTenantLegacy } from "@/lib/admin/adminLegacyProtocols";
|
||||
import type { JmapSession } from "../types";
|
||||
|
||||
/**
|
||||
* INBUXA can switch IMAP, POP3 and ManageSieve off one at a time. The session
|
||||
* lists what is still allowed (`legacyAllowed`); the webmail names what isn't,
|
||||
* only while some but not all are off, and never from a server that doesn't
|
||||
* say.
|
||||
*/
|
||||
describe("legacyProtocolsPartlyOff", () => {
|
||||
const session = (cap: Record<string, unknown> | undefined) =>
|
||||
({
|
||||
accounts: { a: { name: "[email protected]", accountCapabilities: cap ? { [INBUXA_CAP]: cap } : {} } },
|
||||
}) as unknown as JmapSession;
|
||||
|
||||
it("names what is off when only some are", () => {
|
||||
const s = session({ legacyProtocols: "enabled", legacyAllowed: ["imap", "manageSieve", "submission"] });
|
||||
expect(legacyProtocolsPartlyOff(s, "a")).toEqual(["POP3"]);
|
||||
const two = session({ legacyProtocols: "enabled", legacyAllowed: ["pop3", "submission"] });
|
||||
expect(legacyProtocolsPartlyOff(two, "a")).toEqual(["IMAP", "ManageSieve"]);
|
||||
});
|
||||
|
||||
it("is empty with none off, all off, or an older server", () => {
|
||||
const all = ["imap", "pop3", "manageSieve", "submission"];
|
||||
expect(legacyProtocolsPartlyOff(session({ legacyProtocols: "enabled", legacyAllowed: all }), "a")).toEqual([]);
|
||||
expect(legacyProtocolsPartlyOff(session({ legacyProtocols: "disabled", legacyAllowed: [] }), "a")).toEqual([]);
|
||||
expect(legacyProtocolsPartlyOff(session({ legacyProtocols: "enabled" }), "a")).toEqual([]);
|
||||
expect(legacyProtocolsPartlyOff(null, "a")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTenantLegacy", () => {
|
||||
it("reads a tenant with only some off, and an older server's one switch", () => {
|
||||
expect(parseTenantLegacy({ legacyProtocols: "enabled", pop3: "disabled" }).partlyOff).toEqual(["POP3"]);
|
||||
const all = parseTenantLegacy({ legacyProtocols: "disabled", imap: "disabled", pop3: "disabled" });
|
||||
expect(all.off).toBe(true);
|
||||
expect(all.partlyOff).toEqual([]);
|
||||
expect(parseTenantLegacy({ legacyProtocols: "enabled" }).partlyOff).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,24 @@ export function legacyProtocolsOff(session: JmapSession | null, accountId: Id |
|
||||
return cap?.legacyProtocols === "disabled";
|
||||
}
|
||||
|
||||
/** The protocols the server can switch off one at a time, as it names them. */
|
||||
const SWITCHED = ["imap", "pop3", "manageSieve"] as const;
|
||||
const PROTOCOL_NAMES: Record<string, string> = { imap: "IMAP", pop3: "POP3", manageSieve: "ManageSieve" };
|
||||
|
||||
/**
|
||||
* Which of IMAP, POP3 and ManageSieve are off for this account, by name, when
|
||||
* only some are (INBUXA legacy-protocols, one switch per protocol). Empty when
|
||||
* none are, when all are (see `legacyProtocolsOff`), and from a server that
|
||||
* doesn't say which (`legacyAllowed`).
|
||||
*/
|
||||
export function legacyProtocolsPartlyOff(session: JmapSession | null, accountId: Id | null): string[] {
|
||||
if (!session || !accountId || legacyProtocolsOff(session, accountId)) return [];
|
||||
const cap = session.accounts[accountId]?.accountCapabilities?.[INBUXA_CAP] as { legacyAllowed?: unknown } | undefined;
|
||||
if (!Array.isArray(cap?.legacyAllowed)) return [];
|
||||
const allowed = cap.legacyAllowed;
|
||||
return SWITCHED.filter((p) => !allowed.includes(p)).map((p) => PROTOCOL_NAMES[p] ?? p);
|
||||
}
|
||||
|
||||
export class JmapMethodError extends Error {
|
||||
constructor(
|
||||
public readonly method: string,
|
||||
|
||||
@@ -29,15 +29,10 @@ describe("resolveUiLanguage", () => {
|
||||
expect(resolveUiLanguage("xx-XX")).toBe("en");
|
||||
});
|
||||
|
||||
it("carries the Beta flag until a person has signed the language off", () => {
|
||||
// Not a completeness measure. A catalog can be word-for-word finished
|
||||
// and still read like a machine wrote it, which is what this marks.
|
||||
// Every shipped language except English is unreviewed, and stays marked
|
||||
// until a person says otherwise.
|
||||
for (const l of UI_LANGUAGES) {
|
||||
if (l.tag === "en") expect(l.beta).toBeUndefined();
|
||||
else expect(l.beta).toBe(true);
|
||||
}
|
||||
it("marks no language Beta", () => {
|
||||
// inbuxa: every shipped language is offered without the Beta mark
|
||||
// (John, 2026-09-27); only Dutch has been read by a native speaker.
|
||||
for (const l of UI_LANGUAGES) expect(l.beta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honors one that is", () => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/** inbuxa MA-8: new mail in the accounts not in front. */
|
||||
|
||||
const shown: { title: string; opts: Record<string, unknown> }[] = [];
|
||||
vi.mock("@/lib/notify/notify", () => ({
|
||||
showNotification: (title: string, opts: Record<string, unknown>) => shown.push({ title, opts }),
|
||||
}));
|
||||
|
||||
const { risen, pollOtherUnread, useOtherUnread } = await import("@/lib/otherAccounts");
|
||||
const { useSession } = await import("@/store/session");
|
||||
const { useSettings } = await import("@/store/settings");
|
||||
|
||||
let counts: Record<string, number>;
|
||||
|
||||
beforeEach(() => {
|
||||
shown.length = 0;
|
||||
counts = { b: 3 };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
const body = { accounts: Object.entries(counts).map(([id, unread]) => ({ id, unread })) };
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response;
|
||||
}),
|
||||
);
|
||||
useOtherUnread.getState().set({});
|
||||
useSession.setState({
|
||||
signedIn: [
|
||||
{ id: "a", username: "[email protected]", front: true },
|
||||
{ id: "b", username: "[email protected]", front: false },
|
||||
],
|
||||
});
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, desktopNotifications: true } }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("other accounts' unread mail", () => {
|
||||
it("counts only a rise, and never an account seen for the first time", () => {
|
||||
expect(risen({}, { b: 4 })).toEqual([]);
|
||||
expect(risen({ b: 4 }, { b: 4 })).toEqual([]);
|
||||
expect(risen({ b: 4 }, { b: 2 })).toEqual([]);
|
||||
expect(risen({ b: 4, c: 1 }, { b: 5, c: 1 })).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("keeps the counts, and names the account when new mail arrives", async () => {
|
||||
await pollOtherUnread();
|
||||
expect(useOtherUnread.getState().unread).toEqual({ b: 3 });
|
||||
expect(shown).toEqual([]);
|
||||
|
||||
counts = { b: 5 };
|
||||
await pollOtherUnread();
|
||||
expect(shown).toHaveLength(1);
|
||||
expect(shown[0]!.title).toContain("[email protected]");
|
||||
expect(shown[0]!.opts.tag).toBe("other-account-b");
|
||||
});
|
||||
|
||||
it("stays quiet when desktop notifications are off", async () => {
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, desktopNotifications: false } }));
|
||||
await pollOtherUnread();
|
||||
counts = { b: 9 };
|
||||
await pollOtherUnread();
|
||||
expect(shown).toEqual([]);
|
||||
expect(useOtherUnread.getState().unread).toEqual({ b: 9 });
|
||||
});
|
||||
|
||||
it("leaves telling to the worker where background notifications are on", async () => {
|
||||
const { setDeviceTrusted } = await import("@/lib/storage");
|
||||
const { setPushEnabledHere } = await import("@/lib/notify/webpush");
|
||||
setDeviceTrusted(true);
|
||||
setPushEnabledHere(true);
|
||||
try {
|
||||
await pollOtherUnread();
|
||||
counts = { b: 12 };
|
||||
await pollOtherUnread();
|
||||
expect(shown).toEqual([]);
|
||||
expect(useOtherUnread.getState().unread).toEqual({ b: 12 });
|
||||
} finally {
|
||||
setPushEnabledHere(false);
|
||||
setDeviceTrusted(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings";
|
||||
import { isAppFolder } from "../appFolder";
|
||||
import { settingsAlreadyLoadedFor, stopSettingsSync } from "../settingsSync";
|
||||
import { loadSettingsOnce, stopSettingsSync } from "../settingsSync";
|
||||
|
||||
/**
|
||||
* Settings used to live only in localStorage, so nothing followed the user
|
||||
@@ -116,20 +116,67 @@ describe("a change made but not yet written up", () => {
|
||||
expect(merged.uiLanguage).toBe("en");
|
||||
});
|
||||
|
||||
it("reads the file again for an account after a sign-out", () => {
|
||||
it("reads the file again for an account after a sign-out", async () => {
|
||||
stopSettingsSync();
|
||||
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
|
||||
let loads = 0;
|
||||
const load = async () => { loads++; };
|
||||
await loadSettingsOnce("a1", load);
|
||||
// The remount that a language change causes must not read it a second time.
|
||||
expect(settingsAlreadyLoadedFor("a1")).toBe(true);
|
||||
await loadSettingsOnce("a1", load);
|
||||
expect(loads).toBe(1);
|
||||
// Signing out drops the claim, so signing back in reads the file rather
|
||||
// than trusting whatever the previous session left behind.
|
||||
stopSettingsSync();
|
||||
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
|
||||
await loadSettingsOnce("a1", load);
|
||||
expect(loads).toBe(2);
|
||||
stopSettingsSync();
|
||||
});
|
||||
|
||||
it("treats a missing account as already loaded, so nothing is fetched", () => {
|
||||
expect(settingsAlreadyLoadedFor(null)).toBe(true);
|
||||
expect(settingsAlreadyLoadedFor(undefined)).toBe(true);
|
||||
it("finishes a load that a remount interrupts, so changes are saved (Gitea #23)", async () => {
|
||||
stopSettingsSync();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
let loads = 0;
|
||||
let armed = false;
|
||||
const load = async (isCurrent: () => boolean) => {
|
||||
loads++;
|
||||
await gate;
|
||||
if (!isCurrent()) return;
|
||||
armed = true;
|
||||
};
|
||||
// First mount starts the load; the settings file picks a language and the
|
||||
// tree remounts before the load has finished.
|
||||
const first = loadSettingsOnce("a1", load);
|
||||
const second = loadSettingsOnce("a1", load);
|
||||
expect(second).toBe(first);
|
||||
release();
|
||||
await second;
|
||||
expect(loads).toBe(1);
|
||||
// This is what used to be skipped: the remounted tree took the
|
||||
// already-loaded path and nothing armed the pushes.
|
||||
expect(armed).toBe(true);
|
||||
stopSettingsSync();
|
||||
});
|
||||
|
||||
it("stops a load that a sign-out overtakes", async () => {
|
||||
stopSettingsSync();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
let applied = false;
|
||||
const pending = loadSettingsOnce("a1", async (isCurrent) => {
|
||||
await gate;
|
||||
if (isCurrent()) applied = true;
|
||||
});
|
||||
stopSettingsSync();
|
||||
release();
|
||||
await pending;
|
||||
expect(applied).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a missing account as already loaded, so nothing is fetched", async () => {
|
||||
let loads = 0;
|
||||
await loadSettingsOnce(null, async () => { loads++; });
|
||||
await loadSettingsOnce(undefined, async () => { loads++; });
|
||||
expect(loads).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,8 @@ import { CONFIRM_PHRASE, impactEntries, parseTenantLegacy, phraseMatches } from
|
||||
|
||||
describe("a tenant's legacy mail protocols switch, as the server sends it", () => {
|
||||
it("reads the switch, and tells an older server from nobody", () => {
|
||||
expect(parseTenantLegacy({ legacyProtocols: "disabled", recentLegacyUse: [] })).toEqual({ off: true, recent: [] });
|
||||
expect(parseTenantLegacy({ legacyProtocols: "enabled" })).toEqual({ off: false, recent: null });
|
||||
expect(parseTenantLegacy({ legacyProtocols: "disabled", recentLegacyUse: [] })).toEqual({ off: true, partlyOff: [], recent: [] });
|
||||
expect(parseTenantLegacy({ legacyProtocols: "enabled" })).toEqual({ off: false, partlyOff: [], recent: null });
|
||||
});
|
||||
|
||||
it("puts each account on the panel once, with every protocol and its latest use", () => {
|
||||
|
||||
@@ -34,7 +34,10 @@ export interface RecentUse {
|
||||
}
|
||||
|
||||
export interface TenantLegacy {
|
||||
/** All of IMAP, POP3 and ManageSieve off: the kill-all's state. */
|
||||
off: boolean;
|
||||
/** When only some are off, which, by name; set one at a time in the console. */
|
||||
partlyOff: string[];
|
||||
/** Null from a server too old to say who uses legacy apps -- not the same as nobody. */
|
||||
recent: RecentUse[] | null;
|
||||
}
|
||||
@@ -49,8 +52,13 @@ function parseRecent(raw: unknown): RecentUse[] | null {
|
||||
});
|
||||
}
|
||||
|
||||
const PROTOCOL_NAMES: [string, string][] = [["imap", "IMAP"], ["pop3", "POP3"], ["manageSieve", "ManageSieve"]];
|
||||
|
||||
export function parseTenantLegacy(raw: Record<string, unknown>): TenantLegacy {
|
||||
return { off: raw.legacyProtocols === "disabled", recent: parseRecent(raw.recentLegacyUse) };
|
||||
const off = raw.legacyProtocols === "disabled";
|
||||
// An older server sends only legacyProtocols, which stands for all three.
|
||||
const partlyOff = off ? [] : PROTOCOL_NAMES.filter(([key]) => raw[key] === "disabled").map(([, name]) => name);
|
||||
return { off, partlyOff, recent: parseRecent(raw.recentLegacyUse) };
|
||||
}
|
||||
|
||||
/** The tenant's switch, or null where the server has none (not INBUXA, or too old). */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useSession } from "@/store/session";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
|
||||
/**
|
||||
* What this instance calls itself, when nothing has said otherwise yet.
|
||||
@@ -36,3 +37,25 @@ export function useAppName(): string {
|
||||
export function currentAppName(): string {
|
||||
return useSession.getState().session?.ihasmail?.appName?.trim() || DEFAULT_APP_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* The brand images' version, carried as `?v=` on every URL that names one.
|
||||
*
|
||||
* The images live in `public/img` under fixed names and are served with a
|
||||
* browser cache of hours, so replacing one (the mark changed on 2026-09-27)
|
||||
* left returning visitors on the old picture until their copy expired -- and
|
||||
* the favicon and an installed app's icon hold on longer still. A new value
|
||||
* here is a new URL everywhere at once.
|
||||
*
|
||||
* Date-stamped with a letter for a second change the same day, never a
|
||||
* counter, for the reason the sites give for ASSET_V: a value that could have
|
||||
* been requested before may already be cached, with old bytes behind it.
|
||||
* `index.html`, `public/manifest.webmanifest` and `public/sw.js` can't import
|
||||
* this, so they carry the same value written out; keep the four in step.
|
||||
*/
|
||||
export const BRAND_V = "2026-09-27a";
|
||||
|
||||
/** A brand image's URL under the mount, versioned: `brandImage("/img/logo.png")`. */
|
||||
export function brandImage(path: string): string {
|
||||
return withBase(`${path}?v=${BRAND_V}`);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Locked accounts handed to the reader (inbuxa audit-hold-lock spec, AL-7).
|
||||
*
|
||||
* The server marks one in the account's `urn:inbuxa:jmap` capability, as
|
||||
* `delegation: {locked, access, sendAs, until}`. Only that mark makes an
|
||||
* account switchable: a server advertises every capability on any shared
|
||||
* account, so "it has mail" proves nothing about what was handed over.
|
||||
*/
|
||||
|
||||
import type { Id, JmapSession } from "@/jmap/types";
|
||||
|
||||
export type DelegationAccess = "read" | "organize" | "full";
|
||||
|
||||
export interface Delegation {
|
||||
locked: boolean;
|
||||
/**
|
||||
* A locked account, or a shared mailbox such as support@ (MA-S). Both are
|
||||
* reached as a delegate at an access level; only how they are shown
|
||||
* differs. Absent from older servers, where it is always a lock.
|
||||
*/
|
||||
kind: "lock" | "sharedMailbox";
|
||||
access: DelegationAccess;
|
||||
sendAs: boolean;
|
||||
/** UTC date the delegation ends, if it does. */
|
||||
until: string | null;
|
||||
}
|
||||
|
||||
export interface DelegatedAccount {
|
||||
id: Id;
|
||||
name: string;
|
||||
delegation: Delegation;
|
||||
}
|
||||
|
||||
const INBUXA = "urn:inbuxa:jmap";
|
||||
|
||||
type SessionLike = Pick<JmapSession, "accounts">;
|
||||
|
||||
export function delegationOf(session: SessionLike | null, accountId: Id | null): Delegation | null {
|
||||
if (!session || !accountId) return null;
|
||||
const account = session.accounts[accountId];
|
||||
if (!account || account.isPersonal) return null;
|
||||
const raw = (account.accountCapabilities?.[INBUXA] as { delegation?: Partial<Delegation> } | undefined)?.delegation;
|
||||
if (!raw || raw.locked !== true) return null;
|
||||
const access: DelegationAccess = raw.access === "organize" || raw.access === "full" ? raw.access : "read";
|
||||
return {
|
||||
locked: true,
|
||||
kind: raw.kind === "sharedMailbox" ? "sharedMailbox" : "lock",
|
||||
access,
|
||||
sendAs: raw.sendAs === true && access !== "read",
|
||||
until: typeof raw.until === "string" ? raw.until : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every locked account handed to the reader, by name. Shared mailboxes are listed by lib/sharedMail. */
|
||||
export function delegatedAccounts(session: SessionLike | null): DelegatedAccount[] {
|
||||
if (!session) return [];
|
||||
return Object.entries(session.accounts)
|
||||
.map(([id, account]) => {
|
||||
const delegation = delegationOf(session, id);
|
||||
return delegation?.kind === "lock" ? { id, name: account.name, delegation } : null;
|
||||
})
|
||||
.filter((a): a is DelegatedAccount => a !== null)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Whether the reader may change anything in the account (AL-6). */
|
||||
export function mayWrite(delegation: Delegation | null): boolean {
|
||||
return !delegation || delegation.access !== "read";
|
||||
}
|
||||
|
||||
/** Whether the reader may delete in the account (AL-6). */
|
||||
export function mayDestroy(delegation: Delegation | null): boolean {
|
||||
return !delegation || delegation.access === "full";
|
||||
}
|
||||
@@ -35,17 +35,20 @@ export interface UiLanguage {
|
||||
beta?: boolean;
|
||||
}
|
||||
|
||||
// inbuxa: no language is marked Beta (John, 2026-09-27). The flag and its
|
||||
// settings note stay, so public ihasmail's changes to them still apply.
|
||||
export const UI_LANGUAGES: readonly UiLanguage[] = [
|
||||
{ tag: "en", name: "English" },
|
||||
{ tag: "de", name: "Deutsch", beta: true },
|
||||
{ tag: "es", name: "Español", beta: true },
|
||||
{ tag: "fr", name: "Français", beta: true },
|
||||
{ tag: "nl", name: "Nederlands", beta: true },
|
||||
{ tag: "pt-BR", name: "Português (Brasil)", beta: true },
|
||||
{ tag: "ja", name: "日本語", beta: true },
|
||||
{ tag: "ru", name: "Русский", beta: true },
|
||||
{ tag: "uk", name: "Українська", beta: true },
|
||||
{ tag: "zh-Hans", name: "简体中文", beta: true },
|
||||
{ tag: "de", name: "Deutsch" },
|
||||
{ tag: "es", name: "Español" },
|
||||
{ tag: "fr", name: "Français" },
|
||||
{ tag: "nl", name: "Nederlands" },
|
||||
{ tag: "pt-BR", name: "Português (Brasil)" },
|
||||
{ tag: "ja", name: "日本語" },
|
||||
{ tag: "ru", name: "Русский" },
|
||||
{ tag: "uk", name: "Українська" },
|
||||
{ tag: "zh-Hans", name: "简体中文" },
|
||||
{ tag: "tr", name: "Türkçe" },
|
||||
];
|
||||
|
||||
/** Where to report a bad translation. Beta languages depend on it. */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { unproxiedImageUrl } from "@/lib/text/html";
|
||||
import type { ImagePolicy } from "@/store/settings";
|
||||
|
||||
/**
|
||||
* Whether a message's remote images may be fetched.
|
||||
*
|
||||
* The reader's decision, in one place, because the composer has to make the
|
||||
* same one. Quoting a message into a reply renders it again — and a quote that
|
||||
* fetched what the reader had declined would report the message read, and the
|
||||
* address live, to whoever was counting. The tracking pixel does not care
|
||||
* which window it loaded in.
|
||||
*/
|
||||
export function remoteImagesAllowed(opts: {
|
||||
from: string | null | undefined;
|
||||
policy: ImagePolicy;
|
||||
trusted: string[];
|
||||
inContacts: boolean;
|
||||
/** The reader pressed "Show images" on this message. */
|
||||
shown: boolean;
|
||||
}): boolean {
|
||||
if (opts.shown || opts.policy === "always") return true;
|
||||
if (opts.trusted.includes((opts.from ?? "").toLowerCase())) return true;
|
||||
return opts.policy === "contacts" && opts.inContacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point proxied images back at their own addresses, on the way out.
|
||||
*
|
||||
* Reading a message fetches its remote images through this server, so the
|
||||
* sender learns nothing about the reader. Those URLs belong to this
|
||||
* deployment, so a quote that kept them would reach the recipient as images
|
||||
* only this server can serve -- broken for them, and a beacon back here for
|
||||
* anyone who could load them (#412).
|
||||
*/
|
||||
export function unproxyImages(html: string): string {
|
||||
if (!html.includes("/api/image?url=")) return html;
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
for (const img of Array.from(doc.querySelectorAll("img[src]"))) {
|
||||
const real = unproxiedImageUrl(img.getAttribute("src") ?? "");
|
||||
if (real) img.setAttribute("src", real);
|
||||
}
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put back the addresses of images that were blocked when the message was
|
||||
* quoted, on the way out.
|
||||
*
|
||||
* Blocking keeps the original URL on the element (`data-ihm-remote`), so
|
||||
* nothing was lost by not fetching it. The copy that leaves here should be the
|
||||
* quote as its sender wrote it: the recipient's client decides for itself
|
||||
* whether to load those images, the same as it would have with any other
|
||||
* client's reply.
|
||||
*/
|
||||
export function restoreBlockedImages(html: string): string {
|
||||
if (!html.includes("data-ihm-blocked")) return html;
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
for (const img of Array.from(doc.querySelectorAll("img[data-ihm-blocked]"))) {
|
||||
const url = img.getAttribute("data-ihm-remote");
|
||||
if (url) img.setAttribute("src", url);
|
||||
img.removeAttribute("data-ihm-blocked");
|
||||
img.removeAttribute("data-ihm-remote");
|
||||
}
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canPlaceFolder, compareFolders, neighbour, placeFolder, siblingsOf } from "../folderOrder";
|
||||
import { canPlaceFolder, compareFolders, neighbour, placeFolder, siblingsOf, treeOrder } from "../folderOrder";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
|
||||
const RIGHTS = { mayRename: true, mayCreateChild: true } as Mailbox["myRights"];
|
||||
@@ -119,3 +119,23 @@ describe("neighbour", () => {
|
||||
expect(neighbour(hidden, "alpha", "up", (m) => m.isSubscribed)).toEqual({ targetId: "junk", placement: "before" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("treeOrder", () => {
|
||||
const ids = (all: Record<Id, Mailbox>) => treeOrder(all).map((m) => m.id);
|
||||
|
||||
it("lists the tree the way the sidebar does, each folder followed by its subfolders", () => {
|
||||
expect(ids(fresh)).toEqual(["inbox", "drafts", "sent", "junk", "trash", "alpha", "work", "clients", "zeta"]);
|
||||
});
|
||||
|
||||
it("follows a saved order rather than A–Z", () => {
|
||||
// #1 on GitLab: the move-to picker kept the old order after the sidebar changed.
|
||||
const ordered = apply(fresh, { zeta: { sortOrder: 10 }, sent: { sortOrder: 20 }, alpha: { sortOrder: 30 }, drafts: { sortOrder: 40 }, junk: { sortOrder: 50 }, trash: { sortOrder: 60 }, work: { sortOrder: 70 } });
|
||||
expect(ids(ordered)).toEqual(["inbox", "zeta", "sent", "alpha", "drafts", "junk", "trash", "work", "clients"]);
|
||||
});
|
||||
|
||||
it("still lists a folder the walk from the top can't reach", () => {
|
||||
const looped = apply(fresh, { work: { parentId: "clients" } });
|
||||
expect(ids(looped)).toHaveLength(Object.keys(looped).length);
|
||||
expect(ids(looped)).toEqual(expect.arrayContaining(["work", "clients"]));
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,37 @@ function roleRank(m: Mailbox): number {
|
||||
return m.role && m.role in ROLE_ORDER ? ROLE_ORDER[m.role]! : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every folder, parents before their children and siblings in
|
||||
* `compareFolders` order: the sidebar's order with every folder expanded.
|
||||
* Lists that show all folders at once, like the move-to picker, use this so a
|
||||
* folder sits where the user dragged it rather than where A–Z would put it.
|
||||
*
|
||||
* A folder the walk from the top never reaches (a parent loop the server
|
||||
* should not allow) is appended rather than dropped, so it can still be
|
||||
* picked.
|
||||
*/
|
||||
export function treeOrder(mailboxes: Record<Id, Mailbox>): Mailbox[] {
|
||||
const byParent = new Map<Id | null, Mailbox[]>();
|
||||
for (const m of Object.values(mailboxes)) {
|
||||
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
|
||||
byParent.set(p, [...(byParent.get(p) ?? []), m]);
|
||||
}
|
||||
for (const list of byParent.values()) list.sort(compareFolders);
|
||||
const out: Mailbox[] = [];
|
||||
const seen = new Set<Id>();
|
||||
const walk = (parent: Id | null) => {
|
||||
for (const m of byParent.get(parent) ?? []) {
|
||||
if (seen.has(m.id)) continue;
|
||||
seen.add(m.id);
|
||||
out.push(m);
|
||||
walk(m.id);
|
||||
}
|
||||
};
|
||||
walk(null);
|
||||
return out.concat(Object.values(mailboxes).filter((m) => !seen.has(m.id)).sort(compareFolders));
|
||||
}
|
||||
|
||||
/** Every folder under `parentId` (null: the top level), in list order. */
|
||||
export function siblingsOf(mailboxes: Record<Id, Mailbox>, parentId: Id | null): Mailbox[] {
|
||||
return Object.values(mailboxes)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { otherAccountCall } from "@/lib/notify/otherAccount";
|
||||
import { listSubscriptions } from "@/lib/notify/webpush";
|
||||
|
||||
/** inbuxa MA-8: push calls made as an account that isn't in front. */
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function stub(response: unknown) {
|
||||
const seen: { url: string; body: any }[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
|
||||
return { ok: true, status: 200, json: async () => response, text: async () => JSON.stringify(response) } as Response;
|
||||
}),
|
||||
);
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("otherAccountCall", () => {
|
||||
it("goes through that account's route and answers the method's result", async () => {
|
||||
const seen = stub({ methodResponses: [["PushSubscription/get", { list: [{ id: "p1", deviceClientId: "d" }] }, "0"]] });
|
||||
const subs = await listSubscriptions(otherAccountCall("sess-2"));
|
||||
expect(subs.map((s) => s.id)).toEqual(["p1"]);
|
||||
expect(seen[0]!.url).toContain("/api/auth/accounts/sess-2/jmap");
|
||||
expect(seen[0]!.body.methodCalls[0][0]).toBe("PushSubscription/get");
|
||||
expect(seen[0]!.body.using).toContain("urn:ietf:params:jmap:core");
|
||||
});
|
||||
|
||||
it("turns a JMAP error into a thrown one", async () => {
|
||||
stub({ methodResponses: [["error", { type: "forbidden" }, "0"]] });
|
||||
await expect(listSubscriptions(otherAccountCall("sess-2"))).rejects.toThrow("forbidden");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearSignedInData, setDeviceTrusted } from "@/lib/storage";
|
||||
import { deviceClientId, registeredEndpoint, rememberEndpoint, unsubscribeAccount, type JmapCall } from "@/lib/notify/webpush";
|
||||
|
||||
/** inbuxa MA-8 part 2: every signed-in account on this device has its own push subscription. */
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe("push for every signed-in account", () => {
|
||||
it("remembers each account's endpoint, and keeps them through a switch", () => {
|
||||
rememberEndpoint("https://push.example/old");
|
||||
// An account with nothing of its own yet reads the endpoint from before
|
||||
expect(registeredEndpoint("acc-a")).toBe("https://push.example/old");
|
||||
rememberEndpoint("https://push.example/a", "acc-a");
|
||||
rememberEndpoint("https://push.example/b", "acc-b");
|
||||
expect(registeredEndpoint("acc-a")).toBe("https://push.example/a");
|
||||
expect(registeredEndpoint("acc-b")).toBe("https://push.example/b");
|
||||
localStorage.setItem("ihasmail:cached-mail", "x");
|
||||
clearSignedInData();
|
||||
expect(registeredEndpoint("acc-a")).toBe("https://push.example/a");
|
||||
expect(localStorage.getItem("ihasmail:cached-mail")).toBeNull();
|
||||
rememberEndpoint(null, "acc-a");
|
||||
expect(localStorage.getItem("ihasmail:pushEndpoint:acc-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes only this device's subscription from one account", async () => {
|
||||
// A device id is kept only where the device is trusted
|
||||
setDeviceTrusted(true);
|
||||
const mine = deviceClientId();
|
||||
const destroyed: string[] = [];
|
||||
const call: JmapCall = async <T,>(method: string, args: Record<string, unknown>) => {
|
||||
if (method === "PushSubscription/get") {
|
||||
return { list: [{ id: "p1", deviceClientId: mine }, { id: "p2", deviceClientId: "ihasmail-other-phone" }] } as T;
|
||||
}
|
||||
destroyed.push(...((args.destroy as string[]) ?? []));
|
||||
return {} as T;
|
||||
};
|
||||
rememberEndpoint("https://push.example/a", "acc-a");
|
||||
await unsubscribeAccount(call, "acc-a");
|
||||
expect(destroyed).toEqual(["p1"]);
|
||||
expect(localStorage.getItem("ihasmail:pushEndpoint:acc-a")).toBeNull();
|
||||
setDeviceTrusted(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { withBase } from "../basePath";
|
||||
import { brandImage } from "@/lib/brand";
|
||||
|
||||
let baseTitle = "inbuxa";
|
||||
let faviconCanvas: HTMLCanvasElement | null = null;
|
||||
@@ -40,13 +40,13 @@ export function setUnreadBadge(count: number): void {
|
||||
if (!link) return;
|
||||
if (!baseFavicon) {
|
||||
baseFavicon = new Image();
|
||||
baseFavicon.src = withBase("/img/favicon-64.png");
|
||||
baseFavicon.src = brandImage("/img/favicon-64.png");
|
||||
baseFavicon.onload = () => setUnreadBadge(count);
|
||||
return;
|
||||
}
|
||||
if (!baseFavicon.complete) return;
|
||||
if (count <= 0) {
|
||||
link.href = withBase("/img/favicon-64.png");
|
||||
link.href = brandImage("/img/favicon-64.png");
|
||||
return;
|
||||
}
|
||||
faviconCanvas ??= document.createElement("canvas");
|
||||
@@ -95,7 +95,7 @@ export function showNotification(title: string, opts: NotificationOptions & { on
|
||||
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
||||
if (document.visibilityState === "visible" && document.hasFocus()) return;
|
||||
const { onClick, ...options } = opts;
|
||||
const full = { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...options };
|
||||
const full = { icon: brandImage("/img/icon-192.png"), badge: brandImage("/img/favicon-64.png"), ...options };
|
||||
const viaWorker = navigator.serviceWorker?.controller ? navigator.serviceWorker.ready : null;
|
||||
if (viaWorker) {
|
||||
void viaWorker.then((reg) => reg.showNotification(title, full)).catch(() => undefined);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* inbuxa MA-8: JMAP calls made as a signed-in account that isn't in front,
|
||||
* through the webmail server's narrow route for it
|
||||
* (POST /api/auth/accounts/<sessionId>/jmap). The server allows only what
|
||||
* notifications need: push subscriptions, mailboxes, and marking or filing
|
||||
* mail.
|
||||
*/
|
||||
import { apiFetch, CAP } from "@/jmap/client";
|
||||
import type { JmapCall } from "@/lib/notify/webpush";
|
||||
|
||||
export function otherAccountCall(sessionId: string): JmapCall {
|
||||
return async <T,>(method: string, args: Record<string, unknown>, using: string[]): Promise<T> => {
|
||||
const res = await apiFetch<{ methodResponses?: [string, unknown, string][] }>(
|
||||
`/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap`,
|
||||
{ method: "POST", body: JSON.stringify({ using: [...new Set([CAP.core, ...using])], methodCalls: [[method, args, "0"]] }) },
|
||||
);
|
||||
const [name, out] = res.methodResponses?.[0] ?? [];
|
||||
if (!name) throw new Error("The mail server sent no response.");
|
||||
if (name === "error") throw new Error(String((out as { type?: string } | undefined)?.type ?? "error"));
|
||||
return out as T;
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,15 @@ import type { GetResponse, Id, SetResponse } from "@/jmap/types";
|
||||
import { isDeviceTrusted } from "@/lib/storage";
|
||||
|
||||
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
|
||||
|
||||
/**
|
||||
* Who a push call is made as (inbuxa MA-8). A JMAP push subscription belongs
|
||||
* to whoever signs the request, so registering one for an account that isn't
|
||||
* in front goes through that account's own session (lib/notify/otherAccount).
|
||||
* Everything here defaults to the account in front.
|
||||
*/
|
||||
export type JmapCall = <T>(method: string, args: Record<string, unknown>, using: string[]) => Promise<T>;
|
||||
export const frontCall: JmapCall = (method, args, using) => client.call(method, args, using);
|
||||
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
|
||||
|
||||
/**
|
||||
@@ -285,13 +294,13 @@ export function needsRenewal(subs: JmapPushSubscription[], deviceId: string, now
|
||||
return at - now <= RENEW_WITHIN_MS;
|
||||
}
|
||||
|
||||
export async function listSubscriptions(): Promise<JmapPushSubscription[]> {
|
||||
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
|
||||
export async function listSubscriptions(call: JmapCall = frontCall): Promise<JmapPushSubscription[]> {
|
||||
const res = await call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
|
||||
return res.list;
|
||||
}
|
||||
|
||||
export async function createSubscription(body: Record<string, unknown>): Promise<Id | null> {
|
||||
const res = await client.call<SetResponse<JmapPushSubscription>>(
|
||||
export async function createSubscription(body: Record<string, unknown>, call: JmapCall = frontCall): Promise<Id | null> {
|
||||
const res = await call<SetResponse<JmapPushSubscription>>(
|
||||
"PushSubscription/set",
|
||||
{ create: { s: body } },
|
||||
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
|
||||
@@ -307,16 +316,16 @@ export async function createSubscription(body: Record<string, unknown>): Promise
|
||||
* Seven days is JMAP's ceiling and what Stalwart grants a new one; the server
|
||||
* may shorten what is asked for, and whatever it keeps is what counts.
|
||||
*/
|
||||
export async function extendSubscription(id: Id, now: number = Date.now()): Promise<void> {
|
||||
export async function extendSubscription(id: Id, now: number = Date.now(), call: JmapCall = frontCall): Promise<void> {
|
||||
const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z");
|
||||
const res = await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
|
||||
const res = await call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type));
|
||||
}
|
||||
|
||||
export async function destroySubscriptions(ids: Id[]): Promise<void> {
|
||||
export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall): Promise<void> {
|
||||
if (!ids.length) return;
|
||||
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
|
||||
await call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,18 +337,29 @@ export async function destroySubscriptions(ids: Id[]): Promise<void> {
|
||||
*/
|
||||
const ENDPOINT_KEY = "ihasmail:pushEndpoint";
|
||||
|
||||
export function registeredEndpoint(): string | null {
|
||||
/*
|
||||
* inbuxa MA-8: one per account, since each signed-in account on this device
|
||||
* has its own subscription, and kept when switching between them (see
|
||||
* KEEP_ON_SIGN_OUT in lib/storage) so a switch doesn't register them afresh.
|
||||
* Without an account, the key from before: the account in front.
|
||||
*/
|
||||
function endpointKey(accountId?: Id | null): string {
|
||||
return accountId ? `${ENDPOINT_KEY}:${accountId}` : ENDPOINT_KEY;
|
||||
}
|
||||
|
||||
export function registeredEndpoint(accountId?: Id | null): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ENDPOINT_KEY);
|
||||
return localStorage.getItem(endpointKey(accountId)) ?? (accountId ? localStorage.getItem(ENDPOINT_KEY) : null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberEndpoint(endpoint: string | null): void {
|
||||
export function rememberEndpoint(endpoint: string | null, accountId?: Id | null): void {
|
||||
try {
|
||||
if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint);
|
||||
else localStorage.removeItem(ENDPOINT_KEY);
|
||||
const key = endpointKey(accountId);
|
||||
if (endpoint) localStorage.setItem(key, endpoint);
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* private mode: every start is then a fresh registration, which still works */
|
||||
}
|
||||
@@ -353,8 +373,8 @@ export function rememberEndpoint(endpoint: string | null): void {
|
||||
* the client echoes it. A subscription left unverified looks registered and is
|
||||
* silent, which is the confusing failure worth being explicit about.
|
||||
*/
|
||||
export async function verifySubscription(id: Id, verificationCode: string): Promise<void> {
|
||||
const res = await client.call<SetResponse<JmapPushSubscription>>(
|
||||
export async function verifySubscription(id: Id, verificationCode: string, call: JmapCall = frontCall): Promise<void> {
|
||||
const res = await call<SetResponse<JmapPushSubscription>>(
|
||||
"PushSubscription/set",
|
||||
{ update: { [id]: { verificationCode } } },
|
||||
[CAP.core, VAPID_CAP],
|
||||
@@ -367,6 +387,20 @@ export async function destroySubscription(id: Id): Promise<void> {
|
||||
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]);
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: remove this device's subscription in one account only, leaving
|
||||
* the browser's push subscription and the switch alone -- for signing out of
|
||||
* the account in front while others stay signed in and keep notifying.
|
||||
*/
|
||||
export async function unsubscribeAccount(call: JmapCall = frontCall, accountId?: Id | null): Promise<void> {
|
||||
try {
|
||||
await destroySubscriptions(mySubscriptions(await listSubscriptions(call), deviceClientId()).map((s) => s.id), call);
|
||||
} catch {
|
||||
/* signing out must not fail over this */
|
||||
}
|
||||
rememberEndpoint(null, accountId);
|
||||
}
|
||||
|
||||
/** Remove every subscription this browser registered. Used when signing out. */
|
||||
export async function unsubscribeThisDevice(): Promise<void> {
|
||||
const mine = deviceClientId();
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
* testable: everything here touches the browser's service worker and
|
||||
* permission prompt, none of which exists under a test runner.
|
||||
*/
|
||||
import { CAP } from "@/jmap/client";
|
||||
import { apiFetch, CAP } from "@/jmap/client";
|
||||
import type { GetResponse, Id, Mailbox } from "@/jmap/types";
|
||||
import { otherAccountCall } from "@/lib/notify/otherAccount";
|
||||
import { setOtherAccountFacts, type OtherAccountFacts } from "@/lib/sw/swFacts";
|
||||
import type { SignedInAccount } from "@/store/session";
|
||||
import { withBase } from "../basePath";
|
||||
import { SW_CACHE_NAME } from "../sw/swCache";
|
||||
import { isDeviceTrusted } from "@/lib/storage";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { ownInboxId } from "@/store/mail";
|
||||
import {
|
||||
applicationServerKey,
|
||||
createSubscription,
|
||||
@@ -18,6 +22,8 @@ import {
|
||||
destroySubscriptions,
|
||||
deviceClientId,
|
||||
extendSubscription,
|
||||
frontCall,
|
||||
type JmapCall,
|
||||
findSubscription,
|
||||
listSubscriptions,
|
||||
mySubscriptions,
|
||||
@@ -29,6 +35,7 @@ import {
|
||||
roomToMake,
|
||||
setPushEnabledHere,
|
||||
subscriptionPayload,
|
||||
unsubscribeAccount,
|
||||
unsubscribeThisDevice,
|
||||
verifySubscription,
|
||||
webPushAvailable,
|
||||
@@ -48,7 +55,7 @@ export function listenForVerification(): void {
|
||||
listening = true;
|
||||
navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => {
|
||||
const d = e.data as { type?: string; id?: string; code?: string } | undefined;
|
||||
if (d?.type === "push-verification" && d.id && d.code) void verifySubscription(d.id, d.code).catch(() => {});
|
||||
if (d?.type === "push-verification" && d.id && d.code) void verifyAnywhere(d.id, d.code);
|
||||
});
|
||||
void collectStoredVerification();
|
||||
}
|
||||
@@ -64,12 +71,44 @@ async function collectStoredVerification(): Promise<void> {
|
||||
if (!hit) return;
|
||||
const { id, code } = (await hit.json()) as { id?: string; code?: string };
|
||||
await cache.delete(key);
|
||||
if (id && code) await verifySubscription(id, code);
|
||||
if (id && code) await verifyAnywhere(id, code);
|
||||
} catch {
|
||||
/* nothing waiting, or no cache: not a failure */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: a verification code belongs to one account's subscription, and
|
||||
* the worker doesn't say which: the account in front first, then each other
|
||||
* signed-in account until one takes it.
|
||||
*/
|
||||
async function verifyAnywhere(id: Id, code: string): Promise<void> {
|
||||
try {
|
||||
await verifySubscription(id, code);
|
||||
return;
|
||||
} catch {
|
||||
/* not the front account's */
|
||||
}
|
||||
for (const account of await otherAccounts()) {
|
||||
try {
|
||||
await verifySubscription(id, code, otherAccountCall(account.id));
|
||||
return;
|
||||
} catch {
|
||||
/* not this one's either */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The signed-in accounts not in front, as the server lists them now. */
|
||||
async function otherAccounts(): Promise<SignedInAccount[]> {
|
||||
try {
|
||||
const answer = await apiFetch<{ accounts: SignedInAccount[] }>("/api/auth/accounts");
|
||||
return answer.accounts.filter((a) => !a.front);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe this browser. Safe to call again: see `registerThisBrowser`.
|
||||
*
|
||||
@@ -97,6 +136,8 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
|
||||
await registerThisBrowser(key);
|
||||
setPushEnabledHere(true);
|
||||
listenForVerification();
|
||||
// inbuxa MA-8: and every other account signed in here
|
||||
await registerOtherAccounts(key);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
|
||||
@@ -128,7 +169,23 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
|
||||
* gave up there, leaving push off for good with the switch still saying it was
|
||||
* on.
|
||||
*/
|
||||
async function registerThisBrowser(key: string): Promise<void> {
|
||||
interface PushTarget {
|
||||
call: JmapCall;
|
||||
/** The account's mail account, which the subscription names. */
|
||||
accountId: Id | null;
|
||||
inboxId: Id | null;
|
||||
/** Whose remembered endpoint to compare with: the account's own (MA-8). */
|
||||
endpointOf: Id | null;
|
||||
}
|
||||
|
||||
function frontTarget(): PushTarget {
|
||||
// inbuxa AL-7: the reader's own inbox, never a delegated account's in view
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.mail);
|
||||
return { call: frontCall, accountId, inboxId: ownInboxId(), endpointOf: accountId };
|
||||
}
|
||||
|
||||
async function registerThisBrowser(key: string, target: PushTarget = frontTarget()): Promise<void> {
|
||||
const { call } = target;
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({
|
||||
// Web Push requires it, and Chrome refuses a subscription without it.
|
||||
@@ -136,34 +193,61 @@ async function registerThisBrowser(key: string): Promise<void> {
|
||||
applicationServerKey: decodeApplicationServerKey(key),
|
||||
}));
|
||||
const deviceId = deviceClientId();
|
||||
const subs = await listSubscriptions();
|
||||
const subs = await listSubscriptions(call);
|
||||
const mine = mySubscriptions(subs, deviceId);
|
||||
const [newest, ...extra] = mine;
|
||||
|
||||
if (newest && registeredEndpoint() === sub.endpoint) {
|
||||
if (extra.length) await destroySubscriptions(extra.map((s) => s.id));
|
||||
if (newest && registeredEndpoint(target.endpointOf) === sub.endpoint) {
|
||||
if (extra.length) await destroySubscriptions(extra.map((s) => s.id), call);
|
||||
const at = newest.expires ? Date.parse(newest.expires) : Number.NaN;
|
||||
if (!newest.expires || (!Number.isNaN(at) && at - Date.now() > RENEW_WITHIN_MS)) return;
|
||||
try {
|
||||
await extendSubscription(newest.id);
|
||||
await extendSubscription(newest.id, Date.now(), call);
|
||||
return;
|
||||
} catch {
|
||||
/* not extendable: replaced below */
|
||||
}
|
||||
}
|
||||
|
||||
if (mine.length) await destroySubscriptions(mine.map((s) => s.id));
|
||||
const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), useMail.getState().roleId("inbox"));
|
||||
if (mine.length) await destroySubscriptions(mine.map((s) => s.id), call);
|
||||
const payload = subscriptionPayload(sub, target.accountId, target.inboxId);
|
||||
try {
|
||||
await createSubscription(payload);
|
||||
await createSubscription(payload, call);
|
||||
} catch (err) {
|
||||
if (!(err instanceof PushSetError) || err.type !== "overQuota") throw err;
|
||||
const room = roomToMake(subs.filter((s) => !mine.includes(s)), deviceId);
|
||||
if (!room.length) throw err;
|
||||
await destroySubscriptions(room);
|
||||
await createSubscription(payload);
|
||||
await destroySubscriptions(room, call);
|
||||
await createSubscription(payload, call);
|
||||
}
|
||||
rememberEndpoint(sub.endpoint);
|
||||
rememberEndpoint(sub.endpoint, target.endpointOf);
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: register this browser in every other account signed in here,
|
||||
* each through its own session, and tell the worker who they are so their
|
||||
* notifications say whose they are and their buttons act on the right mail.
|
||||
* One account failing doesn't stop the rest; the next start tries it again.
|
||||
*/
|
||||
async function registerOtherAccounts(key: string): Promise<void> {
|
||||
const facts: OtherAccountFacts[] = [];
|
||||
for (const account of await otherAccounts()) {
|
||||
if (!account.mailAccountId) continue;
|
||||
const call = otherAccountCall(account.id);
|
||||
try {
|
||||
const boxes = await call<GetResponse<Mailbox>>(
|
||||
"Mailbox/get",
|
||||
{ accountId: account.mailAccountId, ids: null, properties: ["role"] },
|
||||
[CAP.mail],
|
||||
);
|
||||
const roleId = (role: string) => boxes.list.find((m) => m.role === role)?.id ?? null;
|
||||
await registerThisBrowser(key, { call, accountId: account.mailAccountId, inboxId: roleId("inbox"), endpointOf: account.mailAccountId });
|
||||
facts.push({ accountId: account.mailAccountId, sessionId: account.id, username: account.username, archiveId: roleId("archive"), inboxId: roleId("inbox") });
|
||||
} catch {
|
||||
/* this one waits for the next start */
|
||||
}
|
||||
}
|
||||
await setOtherAccountFacts(facts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,16 +273,26 @@ export async function renewWebPush(): Promise<void> {
|
||||
// subscription is close to expiring, missing, or duplicated.
|
||||
await registerThisBrowser(key);
|
||||
listenForVerification();
|
||||
await registerOtherAccounts(key);
|
||||
} catch {
|
||||
/* offline, or the server said no: the next start tries again */
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove this browser's subscription, at the browser and at the server. */
|
||||
/** Remove this browser's subscription, at the browser and at the server, in every signed-in account. */
|
||||
export async function disableWebPush(): Promise<void> {
|
||||
await unsubscribeOtherAccounts();
|
||||
await unsubscribeThisDevice();
|
||||
}
|
||||
|
||||
/** inbuxa MA-8: remove this device's subscription from every account not in front. */
|
||||
export async function unsubscribeOtherAccounts(): Promise<void> {
|
||||
for (const account of await otherAccounts()) {
|
||||
await unsubscribeAccount(otherAccountCall(account.id), account.mailAccountId);
|
||||
}
|
||||
await setOtherAccountFacts([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether *this browser* has a subscription registered at the server.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* inbuxa MA-8: new mail in the accounts not in front.
|
||||
*
|
||||
* The webmail server answers each one's Inbox unread count through that
|
||||
* account's own session (/api/auth/accounts/unread). The menu shows the counts;
|
||||
* when one rises while the app is open, a desktop notification names the
|
||||
* account, so mail for support@ isn't missed while someone works in their own.
|
||||
*
|
||||
* Only while a tab is open, and only where background notifications are off:
|
||||
* with them on, each account has its own Web Push subscription and the worker
|
||||
* notifies (lib/notify/webpushEnable, public/sw.js).
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import { apiFetch } from "@/jmap/client";
|
||||
import { showNotification } from "@/lib/notify/notify";
|
||||
import { pushEnabledHere } from "@/lib/notify/webpush";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useSettings } from "@/store/settings";
|
||||
|
||||
/** How often to ask. The server keeps each answer a minute. */
|
||||
export const POLL_MS = 2 * 60_000;
|
||||
|
||||
interface OtherUnreadState {
|
||||
/** Unread count per session id; absent until first asked, or when unknown. */
|
||||
unread: Record<string, number>;
|
||||
set(unread: Record<string, number>): void;
|
||||
}
|
||||
|
||||
export const useOtherUnread = create<OtherUnreadState>((set) => ({
|
||||
unread: {},
|
||||
set: (unread) => set({ unread }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Which accounts gained unread mail since the last answer. An account seen for
|
||||
* the first time is not "new": its mail was already there when it was added.
|
||||
*/
|
||||
export function risen(before: Record<string, number>, after: Record<string, number>): string[] {
|
||||
return Object.entries(after)
|
||||
.filter(([id, n]) => id in before && n > before[id]!)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
export async function pollOtherUnread(): Promise<void> {
|
||||
const answer = await apiFetch<{ accounts: { id: string; unread: number | null }[] }>("/api/auth/accounts/unread");
|
||||
const next: Record<string, number> = {};
|
||||
for (const a of answer.accounts) if (typeof a.unread === "number") next[a.id] = a.unread;
|
||||
const before = useOtherUnread.getState().unread;
|
||||
useOtherUnread.getState().set(next);
|
||||
if (!useSettings.getState().settings.desktopNotifications) return;
|
||||
// With background notifications on here, the worker tells about these
|
||||
// accounts already (MA-8 part 2): the counts stay, a second telling doesn't
|
||||
if (pushEnabledHere()) return;
|
||||
const names = new Map(useSession.getState().signedIn.map((a) => [a.id, a.username]));
|
||||
for (const id of risen(before, next)) {
|
||||
const name = names.get(id);
|
||||
if (!name) continue;
|
||||
showNotification(t("New mail for {name}", { name }), {
|
||||
body: t("Unread in the Inbox: {count}", { count: next[id]! }),
|
||||
tag: `other-account-${id}`,
|
||||
onClick: () => void useSession.getState().switchTo(id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the counts fresh while more than one account is signed in. */
|
||||
export function useOtherAccountsUnread(): void {
|
||||
const others = useSession((s) => s.signedIn.filter((a) => !a.front).length);
|
||||
useEffect(() => {
|
||||
if (others === 0) {
|
||||
useOtherUnread.getState().set({});
|
||||
return;
|
||||
}
|
||||
const tick = () => void pollOtherUnread().catch(() => undefined);
|
||||
tick();
|
||||
const timer = setInterval(tick, POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [others]);
|
||||
}
|
||||
@@ -34,6 +34,9 @@ let inFlight: Promise<void> | null = null;
|
||||
/** Nothing is pushed before the first load has settled, or we would race it. */
|
||||
let armed = false;
|
||||
let loadedFor: string | null = null;
|
||||
let loading: Promise<void> | null = null;
|
||||
/** Bumped by every new load and every sign-out; a load checks it is still the latest. */
|
||||
let generation = 0;
|
||||
let listenersBound = false;
|
||||
|
||||
export function settingsSyncAvailable(): boolean {
|
||||
@@ -64,21 +67,34 @@ export async function loadRemoteSettings(): Promise<Record<string, unknown> | nu
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this account's settings file already been read on this page load?
|
||||
* Load this account's settings, once per page load, however many times the
|
||||
* caller mounts.
|
||||
*
|
||||
* Claims the account as a side effect, so two callers cannot both start a
|
||||
* read. The subtree that does the reading is keyed on the language version
|
||||
* and so is deliberately remounted whenever somebody picks a language;
|
||||
* without this the remount re-reads a file written before the change and
|
||||
* applies it, putting the old language back.
|
||||
* The subtree that does the reading is keyed on the language version, so it
|
||||
* is remounted whenever the language changes -- including when the settings
|
||||
* file itself picks one. Tying the load to a mount meant that remount canceled
|
||||
* it halfway: the new mount saw the account already claimed and skipped the
|
||||
* load, and nothing armed the pushes, so every later change was silently
|
||||
* dropped (Gitea issue #23). Now the load belongs to the account, not the
|
||||
* mount: every mount waits on the same promise, and the load runs to the end.
|
||||
*
|
||||
* Re-reading on a remount is still wrong -- it would apply a file written
|
||||
* before the change and undo it -- which is why it is shared, not repeated.
|
||||
*
|
||||
* `isCurrent` turns false once the session that started the load signs out,
|
||||
* so a load overtaken by a sign-out stops instead of applying stale settings.
|
||||
* Cleared by `stopSettingsSync`, so signing out and back in reads again.
|
||||
*/
|
||||
export function settingsAlreadyLoadedFor(accountId: string | null | undefined): boolean {
|
||||
if (!accountId) return true;
|
||||
if (loadedFor === accountId) return true;
|
||||
export function loadSettingsOnce(
|
||||
accountId: string | null | undefined,
|
||||
load: (isCurrent: () => boolean) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (!accountId) return Promise.resolve();
|
||||
if (loadedFor === accountId && loading) return loading;
|
||||
loadedFor = accountId;
|
||||
return false;
|
||||
const mine = ++generation;
|
||||
loading = load(() => generation === mine);
|
||||
return loading;
|
||||
}
|
||||
|
||||
/** Allow pushes. Called once the first load has settled, either way. */
|
||||
@@ -93,6 +109,8 @@ export function armSettingsSync(): void {
|
||||
export function stopSettingsSync(): void {
|
||||
armed = false;
|
||||
loadedFor = null;
|
||||
loading = null;
|
||||
generation++;
|
||||
pending = null;
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Mail other people let the reader into (multi-account spec, MA-A): a group's
|
||||
* mailbox, folders someone shared, or a shared mailbox an administrator
|
||||
* assigned them to (MA-S).
|
||||
*
|
||||
* Either one arrives as another account in the session, `isPersonal: false`.
|
||||
* That alone proves nothing about mail -- the server advertises every
|
||||
* capability on any account it lists, so a colleague who shared one calendar
|
||||
* shows up with mail too. What does prove it is asking: an account whose
|
||||
* `Mailbox/get` answers with at least one mailbox has mail the reader can
|
||||
* open, and only those are offered.
|
||||
*
|
||||
* A shared mailbox needs no asking: the server marks it, as a delegation of
|
||||
* kind `sharedMailbox`, and it is mail by definition. A locked account handed
|
||||
* to the reader (AL-7) is listed by `delegation.ts` instead, and left out
|
||||
* here so it is never offered twice.
|
||||
*/
|
||||
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { GetResponse, Id, JmapSession, Mailbox } from "@/jmap/types";
|
||||
import { delegationOf } from "@/lib/delegation";
|
||||
|
||||
export interface SharedMailAccount {
|
||||
id: Id;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type SessionLike = Pick<JmapSession, "accounts">;
|
||||
|
||||
/** Accounts that might hold mail for the reader, by name; see the note above. */
|
||||
export function sharedMailCandidates(session: SessionLike | null): SharedMailAccount[] {
|
||||
if (!session) return [];
|
||||
return Object.entries(session.accounts)
|
||||
.filter(([id, account]) => account.isPersonal === false && CAP.mail in (account.accountCapabilities ?? {}) && delegationOf(session, id)?.kind !== "lock")
|
||||
.map(([id, account]) => ({ id, name: account.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** The candidates that answer with at least one mailbox. One that fails is left out. */
|
||||
export async function findSharedMail(session: SessionLike | null): Promise<SharedMailAccount[]> {
|
||||
const candidates = sharedMailCandidates(session);
|
||||
const answers = await Promise.all(
|
||||
candidates.map((account) =>
|
||||
delegationOf(session, account.id)?.kind === "sharedMailbox"
|
||||
? Promise.resolve(account)
|
||||
: client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId: account.id, ids: null, properties: ["id"] }).then(
|
||||
(res) => (res.list.length > 0 ? account : null),
|
||||
() => null,
|
||||
),
|
||||
),
|
||||
);
|
||||
return answers.filter((a): a is SharedMailAccount => a !== 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://git.coffeylabs.org/inbuxa/ihasmail-inbuxa";
|
||||
export const DEFAULT_SOURCE_URL = "https://git.coffeylabs.org/inbuxa/inbuxa-webmail";
|
||||
@@ -87,6 +87,9 @@ function ownKeys(): string[] {
|
||||
export function clearSignedInData(): void {
|
||||
for (const key of ownKeys()) {
|
||||
if (KEEP_ON_SIGN_OUT.includes(key)) continue;
|
||||
// inbuxa MA-8: each account's registered push endpoint, which is not mail
|
||||
// and is cleared with its subscription (webpush.ts)
|
||||
if (key.startsWith("pushEndpoint:")) continue;
|
||||
removeKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,14 @@ describe("the worker's briefing", () => {
|
||||
expect(facts.archiveId).toBe("mb-archive");
|
||||
});
|
||||
|
||||
it("names the inbox a notification opens in", async () => {
|
||||
// The route takes a mailbox id. The worker used to put the word `inbox`
|
||||
// there, and every click landed on "That folder no longer exists".
|
||||
const { store } = fakeCaches();
|
||||
await publishWorkerFacts("a1", "mb-archive", "mb-inbox");
|
||||
expect(written(store).inboxId).toBe("mb-inbox");
|
||||
});
|
||||
|
||||
it("carries the worker's text in the language the tab is in", async () => {
|
||||
// The worker has no catalog. Everything it will say has to be said here
|
||||
// first, or a German reader gets English buttons on their lock screen.
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface WorkerFacts {
|
||||
accountId: string;
|
||||
/** Where Archive files to; null where the account has no archive folder. */
|
||||
archiveId: string | null;
|
||||
/** The inbox a notification opens in; the route names a mailbox by id. */
|
||||
inboxId?: string | null;
|
||||
/**
|
||||
* inbuxa MA-8: the other accounts signed in here, so a push for one of them
|
||||
* says whose it is and its buttons act through that account's session.
|
||||
*/
|
||||
others?: OtherAccountFacts[];
|
||||
/** The worker's own user-visible text, in the language this tab is in. */
|
||||
strings: {
|
||||
newMail: string;
|
||||
@@ -47,11 +54,31 @@ export interface WorkerFacts {
|
||||
* reading in a week's time. Rewriting it is one cache put; there is nothing to
|
||||
* gain by working out whether it differs.
|
||||
*/
|
||||
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null): Promise<void> {
|
||||
export interface OtherAccountFacts {
|
||||
accountId: string;
|
||||
sessionId: string;
|
||||
username: string;
|
||||
archiveId: string | null;
|
||||
inboxId?: string | null;
|
||||
}
|
||||
|
||||
let lastFront: { accountId: string | null; archiveId: string | null; inboxId: string | null } = { accountId: null, archiveId: null, inboxId: null };
|
||||
let others: OtherAccountFacts[] = [];
|
||||
|
||||
/** inbuxa MA-8: record the other accounts and write the briefing again with them. */
|
||||
export async function setOtherAccountFacts(list: OtherAccountFacts[]): Promise<void> {
|
||||
others = list;
|
||||
await publishWorkerFacts(lastFront.accountId, lastFront.archiveId, lastFront.inboxId);
|
||||
}
|
||||
|
||||
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null, inboxId: string | null = null): Promise<void> {
|
||||
lastFront = { accountId, archiveId, inboxId };
|
||||
if (typeof caches === "undefined" || !accountId) return;
|
||||
const facts: WorkerFacts = {
|
||||
accountId,
|
||||
archiveId,
|
||||
inboxId,
|
||||
others,
|
||||
strings: {
|
||||
newMail: t("New mail"),
|
||||
newMessage: t("New message"),
|
||||
|
||||
@@ -387,3 +387,16 @@ describe("the containment that mail CSS cannot override", () => {
|
||||
expect(rule).toMatch(/contain\s*:\s*layout/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mail wider than the reading pane", () => {
|
||||
/*
|
||||
* The root keeps `contain: content`, which clips what overflows, so without
|
||||
* a scroll the right side of a min-width table was simply gone, worst on a
|
||||
* phone (Gitea issue #24). jsdom does no layout, so this pins the rule.
|
||||
*/
|
||||
it("scrolls sideways instead of being cut off", () => {
|
||||
const root = EMAIL_BASE_CSS.match(/\.ihm-email-root \{([^}]*)\}/)?.[1] ?? "";
|
||||
expect(root).toMatch(/contain:\s*content/);
|
||||
expect(root).toMatch(/overflow-x:\s*auto/);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* The rule this replaces was `subject.replace(/[^\w.-]+/g, "_")`, and `\w`
|
||||
* without the `u` flag is ASCII: every character of a Russian, Japanese or
|
||||
* Chinese subject failed the class, so those messages downloaded as a row of
|
||||
* underscores. ihasmail ships in nine languages besides English, so the
|
||||
* underscores. ihasmail ships in ten languages besides English, so the
|
||||
* subjects it handled worst were most of the world's.
|
||||
*
|
||||
* What is actually unsafe in a filename is a much shorter list than "not
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { BASE_PATH, withBase } from "@/lib/basePath";
|
||||
|
||||
export interface SanitizeOptions {
|
||||
/** Map of Content-ID (without angle brackets) → URL for inline images. */
|
||||
@@ -158,6 +158,23 @@ export function proxiedImageUrl(url: string): string {
|
||||
return withBase(`/api/image?url=${encodeURIComponent(url)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The address a proxied image really points at, or null if this is not one.
|
||||
*
|
||||
* A proxied URL is this server's, so it is right for reading a message and
|
||||
* wrong for sending one: a quote left this way would hand the recipient
|
||||
* images that only load from inside this deployment (#412).
|
||||
*/
|
||||
export function unproxiedImageUrl(src: string): string | null {
|
||||
const path = `${BASE_PATH}/api/image?url=`;
|
||||
if (!src.startsWith(path)) return null;
|
||||
try {
|
||||
return decodeURIComponent(src.slice(path.length)) || null;
|
||||
} catch {
|
||||
return null; // Malformed escape: leave it alone rather than mangle it.
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
|
||||
ensureHooks();
|
||||
let bodyStyle = "";
|
||||
@@ -270,7 +287,10 @@ export function sanitizeEditorHtml(input: string): string {
|
||||
export const EMAIL_BASE_CSS = `
|
||||
:host { display:block; color-scheme: light; }
|
||||
:host(.themed) { color-scheme: inherit; }
|
||||
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; }
|
||||
/* contain: content clips what overflows, so mail built wider than the pane --
|
||||
a min-width table, a nowrap cell -- was cut off with no way to reach the
|
||||
rest. It scrolls sideways instead (Gitea issue #24). */
|
||||
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; overflow-x:auto; overflow-y:hidden; }
|
||||
.ihm-email-root img { max-width:100%; height:auto; }
|
||||
.ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; }
|
||||
.ihm-email-root table { max-width:100%; }
|
||||
|
||||
@@ -651,6 +651,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} weitere",
|
||||
|
||||
"Contacts": "Kontakte",
|
||||
|
||||
"Collected": "Gesammelt",
|
||||
|
||||
"Save people you write to as contacts": "Personen, denen Sie schreiben, als Kontakte speichern",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Alle, an die Sie senden und die noch keine Kontakte sind, werden dem Adressbuch „Gesammelt“ hinzugefügt und auf jedem Gerät vorgeschlagen. Ihre eigenen Adressen werden nie hinzugefügt.",
|
||||
"Contacts are not available": "Kontakte sind nicht verfügbar",
|
||||
"New contact": "Neuer Kontakt",
|
||||
"Edit contact": "Kontakt bearbeiten",
|
||||
@@ -732,6 +738,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Einstellungen",
|
||||
"Show folder list": "Ordnerliste einblenden",
|
||||
"Hide folder list": "Ordnerliste ausblenden",
|
||||
"All settings": "Alle Einstellungen",
|
||||
"Sections": "Bereiche",
|
||||
"General": "Allgemein",
|
||||
@@ -812,6 +820,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Schriftgröße",
|
||||
"Font size": "Schriftgröße",
|
||||
"Small": "Klein",
|
||||
"Original size": "Originalgröße",
|
||||
"Drag to resize": "Zum Ändern der Größe ziehen",
|
||||
"Image size": "Bildgröße",
|
||||
"Medium": "Mittel",
|
||||
"Large": "Groß",
|
||||
"Huge": "Sehr groß",
|
||||
@@ -1352,6 +1363,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Trotzdem senden",
|
||||
"Send canceled — the message is back in Drafts": "Senden abgebrochen – die Nachricht liegt wieder in den Entwürfen",
|
||||
"Send failed: {error}": "Senden fehlgeschlagen: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Es konnte nicht geprüft werden, ob diese Nachricht schon gesendet wurde. Prüfen Sie „Gesendet“, bevor Sie sie erneut senden.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Es konnte nicht bestätigt werden, ob diese Nachricht gesendet wurde. Prüfen Sie „Gesendet“, bevor Sie sie erneut senden.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Diese Nachricht war bereits gesendet und wurde nicht noch einmal gesendet.",
|
||||
"Send invites": "Einladungen senden",
|
||||
"Send scheduled for {when}": "Senden geplant für {when}",
|
||||
"Send without a subject?": "Ohne Betreff senden?",
|
||||
@@ -1406,6 +1420,8 @@ export const catalog: Catalog = {
|
||||
"Collapse all": "Alle einklappen",
|
||||
"Expand all": "Alle ausklappen",
|
||||
"Send now instead": "Stattdessen jetzt senden",
|
||||
"This message is rich text": "Diese Nachricht ist formatierter Text",
|
||||
"This message is plain text": "Diese Nachricht ist Nur-Text",
|
||||
"Switch to plain text": "Zu Nur-Text wechseln",
|
||||
"Switch to rich text": "Zu formatiertem Text wechseln",
|
||||
"{used} of {total} used": "{used} von {total} belegt",
|
||||
@@ -1752,6 +1768,28 @@ export const catalog: Catalog = {
|
||||
// ── 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",
|
||||
// inbuxa: legacy mail protocols, one switch per protocol
|
||||
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Ihre Organisation hat {protocols} für Mail-Apps ausgeschaltet. Mail-Apps, die das nutzen, können sich nicht mit diesem Konto verbinden; andere schon.",
|
||||
"Some legacy mail protocols are off for your organization: {protocols}.": "Einige ältere Mailprotokolle sind für Ihre Organisation ausgeschaltet: {protocols}.",
|
||||
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Einige sind für {tenant} aus: {protocols}. In der Verwaltungskonsole lassen sie sich einzeln ein- und ausschalten.",
|
||||
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
|
||||
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Löschen, Sperren und rechtliche Aufbewahrungspflichten werden in der Verwaltungskonsole erledigt, die den Grund festhält und bewahrt, was eine Aufbewahrungspflicht umfasst.",
|
||||
"Open in the console": "In der Konsole öffnen",
|
||||
// inbuxa AL-7, AL-8: a locked account handed to the reader
|
||||
"You no longer have access to {name}. Back to your own mail.": "Sie haben keinen Zugriff mehr auf {name}. Zurück zu Ihren eigenen E-Mails.",
|
||||
"You can't send from {name}. It was handed to you to read, not to send as.": "Sie können nicht als {name} senden. Das Konto wurde Ihnen zum Lesen übergeben, nicht zum Senden.",
|
||||
"This account was handed to you to read. Nothing in it can be changed.": "Dieses Konto wurde Ihnen zum Lesen übergeben. Darin kann nichts geändert werden.",
|
||||
"You can file and move mail in this account, but not delete it.": "Sie können E-Mails in diesem Konto ablegen und verschieben, aber nicht löschen.",
|
||||
"Read only": "Nur lesen",
|
||||
"Read and organize": "Lesen und ordnen",
|
||||
"Full access": "Voller Zugriff",
|
||||
"Locked account:": "Gesperrtes Konto:",
|
||||
"You can send as this account": "Sie können als dieses Konto senden",
|
||||
"Back to my mail": "Zurück zu meinen E-Mails",
|
||||
"Send or close the message you're writing first.": "Senden oder schließen Sie zuerst die Nachricht, die Sie gerade schreiben.",
|
||||
"Mail to show": "E-Mails anzeigen von",
|
||||
"My mail": "Meine E-Mails",
|
||||
"Locked account": "Gesperrtes Konto",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -643,6 +643,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} más",
|
||||
|
||||
"Contacts": "Contactos",
|
||||
|
||||
"Collected": "Recopilados",
|
||||
|
||||
"Save people you write to as contacts": "Guardar como contactos a las personas a las que escribes",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todas las personas a las que envías y que aún no son contactos se añaden a la libreta Recopilados, para que se sugieran en todos tus dispositivos. Tus propias direcciones nunca se añaden.",
|
||||
"Contacts are not available": "Los contactos no están disponibles",
|
||||
"New contact": "Contacto nuevo",
|
||||
"Edit contact": "Editar el contacto",
|
||||
@@ -727,6 +733,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Configuración",
|
||||
"Show folder list": "Mostrar lista de carpetas",
|
||||
"Hide folder list": "Ocultar lista de carpetas",
|
||||
"All settings": "Toda la configuración",
|
||||
"Sections": "Secciones",
|
||||
"General": "General",
|
||||
@@ -808,6 +816,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Tamaño del texto",
|
||||
"Font size": "Tamaño de letra",
|
||||
"Small": "Pequeño",
|
||||
"Original size": "Tamaño original",
|
||||
"Drag to resize": "Arrastra para cambiar el tamaño",
|
||||
"Image size": "Tamaño de la imagen",
|
||||
"Medium": "Mediano",
|
||||
"Large": "Grande",
|
||||
"Huge": "Muy grande",
|
||||
@@ -1325,6 +1336,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Enviar de todos modos",
|
||||
"Send canceled — the message is back in Drafts": "Envío cancelado: el mensaje ha vuelto a Borradores",
|
||||
"Send failed: {error}": "No se pudo enviar: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "No se pudo comprobar si este mensaje ya se envió. Revisa Enviados antes de volver a enviarlo.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "No se pudo confirmar si este mensaje se envió. Revisa Enviados antes de volver a enviarlo.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Este mensaje ya se había enviado, así que no se ha vuelto a enviar.",
|
||||
"Send invites": "Enviar invitaciones",
|
||||
"Send scheduled for {when}": "Envío programado para {when}",
|
||||
"Send without a subject?": "¿Enviar sin asunto?",
|
||||
@@ -1379,6 +1393,8 @@ export const catalog: Catalog = {
|
||||
"Collapse all": "Contraer todo",
|
||||
"Expand all": "Expandir todo",
|
||||
"Send now instead": "Enviar ahora, sin programar",
|
||||
"This message is rich text": "Este mensaje es texto enriquecido",
|
||||
"This message is plain text": "Este mensaje es texto sin formato",
|
||||
"Switch to plain text": "Cambiar a texto sin formato",
|
||||
"Switch to rich text": "Cambiar a texto enriquecido",
|
||||
"{used} of {total} used": "{used} de {total} usados",
|
||||
@@ -1725,6 +1741,28 @@ export const catalog: Catalog = {
|
||||
// ── 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",
|
||||
// inbuxa: legacy mail protocols, one switch per protocol
|
||||
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Su organización ha desactivado {protocols} para las aplicaciones de correo. Las que lo usan no pueden conectarse a esta cuenta; las demás sí.",
|
||||
"Some legacy mail protocols are off for your organization: {protocols}.": "Algunos protocolos de correo heredados están desactivados para su organización: {protocols}.",
|
||||
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Algunos están desactivados para {tenant}: {protocols}. Actívelos o desactívelos uno a uno en la consola de administración.",
|
||||
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
|
||||
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Eliminar, bloquear y las retenciones legales se gestionan en la consola de administración, que registra el motivo y conserva lo que cubre una retención.",
|
||||
"Open in the console": "Abrir en la consola",
|
||||
// inbuxa AL-7, AL-8: a locked account handed to the reader
|
||||
"You no longer have access to {name}. Back to your own mail.": "Ya no tiene acceso a {name}. Vuelve a su propio correo.",
|
||||
"You can't send from {name}. It was handed to you to read, not to send as.": "No puede enviar desde {name}. Se le entregó para leerla, no para enviar en su nombre.",
|
||||
"This account was handed to you to read. Nothing in it can be changed.": "Esta cuenta se le entregó para leerla. No se puede cambiar nada en ella.",
|
||||
"You can file and move mail in this account, but not delete it.": "Puede archivar y mover el correo de esta cuenta, pero no eliminarlo.",
|
||||
"Read only": "Solo lectura",
|
||||
"Read and organize": "Leer y organizar",
|
||||
"Full access": "Acceso completo",
|
||||
"Locked account:": "Cuenta bloqueada:",
|
||||
"You can send as this account": "Puede enviar como esta cuenta",
|
||||
"Back to my mail": "Volver a mi correo",
|
||||
"Send or close the message you're writing first.": "Envíe o cierre primero el mensaje que está escribiendo.",
|
||||
"Mail to show": "Correo que mostrar",
|
||||
"My mail": "Mi correo",
|
||||
"Locked account": "Cuenta bloqueada",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -648,6 +648,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} autres",
|
||||
|
||||
"Contacts": "Contacts",
|
||||
|
||||
"Collected": "Collectés",
|
||||
|
||||
"Save people you write to as contacts": "Enregistrer comme contacts les personnes à qui vous écrivez",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Toute personne à qui vous écrivez et qui n’est pas encore un contact est ajoutée au carnet Collectés, pour vous être proposée sur tous vos appareils. Vos propres adresses ne sont jamais ajoutées.",
|
||||
"Contacts are not available": "Les contacts ne sont pas disponibles",
|
||||
"New contact": "Nouveau contact",
|
||||
"Edit contact": "Modifier le contact",
|
||||
@@ -733,6 +739,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Paramètres",
|
||||
"Show folder list": "Afficher la liste des dossiers",
|
||||
"Hide folder list": "Masquer la liste des dossiers",
|
||||
"All settings": "Tous les paramètres",
|
||||
"Sections": "Sections",
|
||||
"General": "Général",
|
||||
@@ -814,6 +822,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Taille du texte",
|
||||
"Font size": "Taille de police",
|
||||
"Small": "Petite",
|
||||
"Original size": "Taille d’origine",
|
||||
"Drag to resize": "Faites glisser pour redimensionner",
|
||||
"Image size": "Taille de l’image",
|
||||
"Medium": "Moyenne",
|
||||
"Large": "Grande",
|
||||
"Huge": "Très grande",
|
||||
@@ -1330,6 +1341,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Envoyer quand même",
|
||||
"Send canceled — the message is back in Drafts": "Envoi annulé : le message est de retour dans les brouillons",
|
||||
"Send failed: {error}": "Échec de l’envoi : {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Impossible de vérifier si ce message a déjà été envoyé. Consultez Envoyés avant de le renvoyer.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Impossible de confirmer l’envoi de ce message. Consultez Envoyés avant de le renvoyer.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Ce message avait déjà été envoyé, il n’a donc pas été renvoyé.",
|
||||
"Send invites": "Envoyer les invitations",
|
||||
"Send scheduled for {when}": "Envoi programmé pour {when}",
|
||||
"Send without a subject?": "Envoyer sans objet ?",
|
||||
@@ -1384,6 +1398,8 @@ export const catalog: Catalog = {
|
||||
"Collapse all": "Tout réduire",
|
||||
"Expand all": "Tout développer",
|
||||
"Send now instead": "Envoyer tout de suite",
|
||||
"This message is rich text": "Ce message est en texte enrichi",
|
||||
"This message is plain text": "Ce message est en texte brut",
|
||||
"Switch to plain text": "Passer en texte brut",
|
||||
"Switch to rich text": "Passer en texte enrichi",
|
||||
"{used} of {total} used": "{used} sur {total} utilisés",
|
||||
@@ -1730,6 +1746,28 @@ export const catalog: Catalog = {
|
||||
// ── 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",
|
||||
// inbuxa: legacy mail protocols, one switch per protocol
|
||||
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Votre organisation a désactivé {protocols} pour les applications de messagerie. Celles qui l’utilisent ne peuvent pas se connecter à ce compte ; les autres le peuvent toujours.",
|
||||
"Some legacy mail protocols are off for your organization: {protocols}.": "Certains protocoles de messagerie historiques sont désactivés pour votre organisation : {protocols}.",
|
||||
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Certains sont désactivés pour {tenant} : {protocols}. Activez-les ou désactivez-les un par un dans la console d’administration.",
|
||||
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
|
||||
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "La suppression, le verrouillage et les conservations légales se font dans la console d’administration, qui enregistre le motif et conserve ce qu’une conservation couvre.",
|
||||
"Open in the console": "Ouvrir dans la console",
|
||||
// inbuxa AL-7, AL-8: a locked account handed to the reader
|
||||
"You no longer have access to {name}. Back to your own mail.": "Vous n’avez plus accès à {name}. Retour à votre propre courrier.",
|
||||
"You can't send from {name}. It was handed to you to read, not to send as.": "Vous ne pouvez pas envoyer depuis {name}. Ce compte vous a été confié pour le lire, pas pour envoyer en son nom.",
|
||||
"This account was handed to you to read. Nothing in it can be changed.": "Ce compte vous a été confié pour le lire. Rien ne peut y être modifié.",
|
||||
"You can file and move mail in this account, but not delete it.": "Vous pouvez classer et déplacer le courrier de ce compte, mais pas le supprimer.",
|
||||
"Read only": "Lecture seule",
|
||||
"Read and organize": "Lecture et classement",
|
||||
"Full access": "Accès complet",
|
||||
"Locked account:": "Compte verrouillé :",
|
||||
"You can send as this account": "Vous pouvez envoyer au nom de ce compte",
|
||||
"Back to my mail": "Retour à mon courrier",
|
||||
"Send or close the message you're writing first.": "Envoyez ou fermez d’abord le message que vous rédigez.",
|
||||
"Mail to show": "Courrier à afficher",
|
||||
"My mail": "Mon courrier",
|
||||
"Locked account": "Compte verrouillé",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -642,6 +642,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "他 {n} 件",
|
||||
|
||||
"Contacts": "連絡先",
|
||||
|
||||
"Collected": "収集したアドレス",
|
||||
|
||||
"Save people you write to as contacts": "メールを送った相手を連絡先に保存する",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "まだ連絡先にない送信先は「収集したアドレス」アドレス帳に追加され、どのデバイスでも候補に表示されます。自分のアドレスは追加されません。",
|
||||
"Contacts are not available": "連絡先は利用できません",
|
||||
"New contact": "新しい連絡先",
|
||||
"Edit contact": "連絡先を編集",
|
||||
@@ -727,6 +733,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "設定",
|
||||
"Show folder list": "フォルダー一覧を表示",
|
||||
"Hide folder list": "フォルダー一覧を隠す",
|
||||
"All settings": "すべての設定",
|
||||
"Sections": "セクション",
|
||||
"General": "一般",
|
||||
@@ -808,6 +816,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "文字サイズ",
|
||||
"Font size": "フォントサイズ",
|
||||
"Small": "小",
|
||||
"Original size": "元のサイズ",
|
||||
"Drag to resize": "ドラッグしてサイズを変更",
|
||||
"Image size": "画像のサイズ",
|
||||
"Medium": "中",
|
||||
"Large": "大",
|
||||
"Huge": "特大",
|
||||
@@ -1333,6 +1344,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "このまま送信",
|
||||
"Send canceled — the message is back in Drafts": "送信を取り消しました。メールは下書きに戻っています",
|
||||
"Send failed: {error}": "送信できませんでした: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "このメッセージが送信済みかどうかを確認できませんでした。もう一度送信する前に送信済みフォルダーを確認してください。",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "このメッセージが送信されたかどうかを確認できませんでした。もう一度送信する前に送信済みフォルダーを確認してください。",
|
||||
"This message had already been sent, so it wasn't sent again.": "このメッセージはすでに送信されていたため、再送信しませんでした。",
|
||||
"Send invites": "招待を送信",
|
||||
"Send scheduled for {when}": "{when} に送信を予約しました",
|
||||
"Send without a subject?": "件名なしで送信しますか?",
|
||||
@@ -1387,6 +1401,8 @@ export const catalog: Catalog = {
|
||||
"Collapse all": "すべて折りたたむ",
|
||||
"Expand all": "すべて展開",
|
||||
"Send now instead": "予約をやめて今すぐ送信",
|
||||
"This message is rich text": "このメールはリッチテキストです",
|
||||
"This message is plain text": "このメールはプレーンテキストです",
|
||||
"Switch to plain text": "プレーンテキストに切り替え",
|
||||
"Switch to rich text": "リッチテキストに切り替え",
|
||||
"{used} of {total} used": "{total} 中 {used} を使用",
|
||||
@@ -1733,6 +1749,28 @@ export const catalog: Catalog = {
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "言語モデルの見解",
|
||||
"One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ",
|
||||
// inbuxa: legacy mail protocols, one switch per protocol
|
||||
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "組織ではメールアプリ向けの {protocols} がオフになっています。これを使うメールアプリはこのアカウントに接続できませんが、ほかのアプリは接続できます。",
|
||||
"Some legacy mail protocols are off for your organization: {protocols}.": "組織では一部の従来のメールプロトコルがオフになっています: {protocols}。",
|
||||
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "{tenant} では一部がオフです: {protocols}。管理コンソールで 1 つずつ切り替えてください。",
|
||||
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
|
||||
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "削除、ロック、訴訟ホールドは管理コンソールで行います。コンソールでは理由が記録され、ホールドの対象は保持されます。",
|
||||
"Open in the console": "コンソールで開く",
|
||||
// inbuxa AL-7, AL-8: a locked account handed to the reader
|
||||
"You no longer have access to {name}. Back to your own mail.": "{name} にアクセスできなくなりました。自分のメールに戻ります。",
|
||||
"You can't send from {name}. It was handed to you to read, not to send as.": "{name} から送信することはできません。このアカウントは閲覧のために委任されています。",
|
||||
"This account was handed to you to read. Nothing in it can be changed.": "このアカウントは閲覧用に委任されています。内容を変更することはできません。",
|
||||
"You can file and move mail in this account, but not delete it.": "このアカウントのメールは整理・移動できますが、削除はできません。",
|
||||
"Read only": "閲覧のみ",
|
||||
"Read and organize": "閲覧と整理",
|
||||
"Full access": "フルアクセス",
|
||||
"Locked account:": "ロックされたアカウント:",
|
||||
"You can send as this account": "このアカウントとして送信できます",
|
||||
"Back to my mail": "自分のメールに戻る",
|
||||
"Send or close the message you're writing first.": "作成中のメッセージを先に送信するか閉じてください。",
|
||||
"Mail to show": "表示するメール",
|
||||
"My mail": "自分のメール",
|
||||
"Locked account": "ロックされたアカウント",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { Catalog } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* Dutch — generated by AI, and not reviewed by a native speaker.
|
||||
* Dutch — generated by AI, then read and corrected by a native speaker.
|
||||
*
|
||||
* Same standing as German and French: written against the terminology Dutch
|
||||
* mail clients already use rather than invented, marked Beta in the picker,
|
||||
* and carrying a report link that is the review process until somebody who
|
||||
* speaks Dutch signs it off. A missing string renders its English source, so
|
||||
* deleting a bad entry is a valid fix.
|
||||
* Michael (mbjboon82, https://git.coffeylabs.org/mbjboon82; also
|
||||
* mbjboon-netizen) reviewed the whole catalog, this one and permissions/nl.ts,
|
||||
* and signed it off in September 2026, so Dutch is the first language out of
|
||||
* Beta. The Dutch wording is his; where it differs from what an earlier draft
|
||||
* or a style guide would choose, his call stands. The reviewer's notes below
|
||||
* are kept where they were left. A missing string still renders its English
|
||||
* source, and strings added after the review have not been read by a Dutch
|
||||
* speaker yet.
|
||||
*
|
||||
* ── Decisions this file is consistent about ──────────────────────────────
|
||||
* --- Native speaker note: i would keep the more formal "u" and "uw" ---
|
||||
@@ -441,7 +444,7 @@ export const catalog: Catalog = {
|
||||
"Move up": "Omhoog",
|
||||
"Move down": "Omlaag",
|
||||
"Drag to reorder": "Sleep om te herschikken",
|
||||
"Right-click for options": "Rechtsklik voor opties",
|
||||
"Right-click for options": "Rechtermuisknop voor opties",
|
||||
|
||||
// ── Mail ───────────────────────────────────────────────────────────
|
||||
"Mail": "E-mail",
|
||||
@@ -628,7 +631,7 @@ export const catalog: Catalog = {
|
||||
"Working hours": "Werktijden",
|
||||
"Working hours start": "Werktijd begint",
|
||||
"Working hours end": "Werktijd eindigt",
|
||||
"Color categories": "Kleurcategorieën",
|
||||
"Color categories": "Kleur categorieën",
|
||||
"Category": "Categorie",
|
||||
"No category": "Geen categorie",
|
||||
"New category": "Nieuwe categorie",
|
||||
@@ -640,6 +643,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} meer",
|
||||
|
||||
"Contacts": "Contacten",
|
||||
|
||||
"Collected": "Verzameld",
|
||||
|
||||
"Save people you write to as contacts": "Mensen aan wie je schrijft als contact opslaan",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Iedereen aan wie je mailt en die nog geen contact is, wordt toegevoegd aan het adresboek Verzameld, zodat die op elk apparaat wordt voorgesteld. Je eigen adressen worden nooit toegevoegd.",
|
||||
"Contacts are not available": "Contacten zijn niet beschikbaar",
|
||||
"New contact": "Nieuw contact",
|
||||
"Edit contact": "Contact bewerken",
|
||||
@@ -725,6 +734,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Instellingen",
|
||||
"Show folder list": "Mappenlijst tonen",
|
||||
"Hide folder list": "Mappenlijst verbergen",
|
||||
"All settings": "Alle instellingen",
|
||||
"Sections": "Onderdelen",
|
||||
"General": "Algemeen",
|
||||
@@ -806,15 +817,18 @@ export const catalog: Catalog = {
|
||||
"Text size": "Tekstgrootte",
|
||||
"Font size": "Lettergrootte",
|
||||
"Small": "Klein",
|
||||
"Original size": "Oorspronkelijke grootte",
|
||||
"Drag to resize": "Sleep om het formaat te wijzigen",
|
||||
"Image size": "Afbeeldingsgrootte",
|
||||
"Medium": "Middel",
|
||||
"Large": "Groot",
|
||||
"Huge": "Zeer groot",
|
||||
"Sidebar": "Zijbalk",
|
||||
"Show labels in the sidebar": "Labels in de zijbalk tonen",
|
||||
"Collapse sidebar to icons": "Zijbalk inklappen tot pictogrammen",
|
||||
"Collapse sidebar to icons": "Zijbalk inklappen en toon alleen pictogrammen",
|
||||
"Apply the theme to messages too": "Thema ook op berichten toepassen",
|
||||
"Apply it even to mail that styles itself": "Pas dit ook toe op e-mail met eigen vormgeving",
|
||||
"Most marketing and receipt mail sets a color somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colors: backgrounds they laid the message on are dropped, while buttons and colored banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Bijna alle reclame- en bonmail zet ergens een kleur, waardoor de instelling hierboven vrijwel alles op een witte kaart laat staan. Met deze optie wordt het thema over de kleuren van de afzender heen gelegd: achtergronden waarop het bericht is geplaatst vervallen, terwijl knoppen en gekleurde banners blijven staan zodat hun tekst leesbaar blijft. Sommige berichten overleven dat niet ongeschonden, en daarom is dit een aparte instelling.",
|
||||
"Apply it even to mail that styles itself": "Pas dit ook toe op e-mails met eigen vormgeving",
|
||||
"Most marketing and receipt mail sets a color somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colors: backgrounds they laid the message on are dropped, while buttons and colored banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "De meeste marketing- en ontvangst-/bevestigingsmails stellen ergens een kleur in, waardoor de instelling hierboven vrijwel alles op een witte kaart laat staan. Met deze optie wordt het thema over de kleuren van de afzender heen gelegd: achtergronden waarop het bericht is geplaatst vervallen, terwijl knoppen en gekleurde banners blijven staan zodat hun tekst leesbaar blijft. Sommige e-mails zullen hierdoor niet helemaal intact blijven. Daarom staat deze optie apart.",
|
||||
"Swiping": "Vegen",
|
||||
"Swipe left": "Naar links vegen",
|
||||
"Swipe right": "Naar rechts vegen",
|
||||
@@ -822,7 +836,7 @@ export const catalog: Catalog = {
|
||||
"Export settings": "Instellingen exporteren",
|
||||
"Import settings": "Instellingen importeren",
|
||||
"Settings imported": "Instellingen geïmporteerd",
|
||||
"Invalid settings file": "Ongeldig instellingenbestand",
|
||||
"Invalid settings file": "Instellingenbestand is ongeldig",
|
||||
"Reset to defaults": "Standaardwaarden herstellen",
|
||||
"Default mail app": "Standaard e-mailprogramma",
|
||||
"Documentation": "Documentatie",
|
||||
@@ -835,7 +849,7 @@ export const catalog: Catalog = {
|
||||
"Account": "Account",
|
||||
"Max upload": "Maximale upload",
|
||||
"{size} MB": "{size} MB",
|
||||
"KB": "kB",
|
||||
"KB": "KB",
|
||||
"Image privacy proxy": "Privacyproxy voor afbeeldingen",
|
||||
"enabled": "ingeschakeld",
|
||||
"disabled": "uitgeschakeld",
|
||||
@@ -844,7 +858,7 @@ export const catalog: Catalog = {
|
||||
"hidden": "verborgen",
|
||||
"connected": "verbonden",
|
||||
"reconnecting…": "opnieuw verbinden…",
|
||||
"AGPL-3.0 source": "AGPL-3.0-broncode",
|
||||
"AGPL-3.0 source": "AGPL-3.0 broncode",
|
||||
|
||||
// ── Identities, templates, filters ─────────────────────────────────
|
||||
"Identities & signatures": "Identiteiten en handtekeningen",
|
||||
@@ -868,9 +882,9 @@ export const catalog: Catalog = {
|
||||
"Insert template": "Sjabloon invoegen",
|
||||
"Template text…": "Sjabloontekst…",
|
||||
"Subject: {subject}": "Onderwerp: {subject}",
|
||||
"Filters & rules": "Filters en regels",
|
||||
"Filters & rules": "Filters en filterregels",
|
||||
"Filters unavailable": "Filters niet beschikbaar",
|
||||
"Rules": "Regels",
|
||||
"Rules": "Filterregels",
|
||||
"Rule name": "Naam van de regel",
|
||||
"New rule": "Nieuwe regel",
|
||||
"Delete rule": "Regel verwijderen",
|
||||
@@ -893,7 +907,7 @@ export const catalog: Catalog = {
|
||||
"does not exist": "bestaat niet",
|
||||
"is larger than": "is groter dan",
|
||||
"is smaller than": "is kleiner dan",
|
||||
"Stop processing more rules": "Stoppen met verdere regels",
|
||||
"Stop processing more rules": "Geen verdere filterregels verwerken",
|
||||
"keep copy": "kopie bewaren",
|
||||
"Forward to": "Doorsturen naar",
|
||||
"Reject with message": "Weigeren met bericht",
|
||||
@@ -904,8 +918,8 @@ export const catalog: Catalog = {
|
||||
"Delete script": "Script verwijderen",
|
||||
"Sieve source": "Sieve-broncode",
|
||||
"Preview generated Sieve script": "Gegenereerd Sieve-script bekijken",
|
||||
"Start with rules": "Beginnen met regels",
|
||||
"Switch to rules?": "Overschakelen naar regels?",
|
||||
"Start with rules": "Beginnen met filterregels",
|
||||
"Switch to rules?": "Overschakelen naar filterregels?",
|
||||
"Create filter": "Filter maken",
|
||||
"Filter messages like this": "Berichten zoals dit filteren",
|
||||
"Filter messages like this…": "Berichten zoals dit filteren…",
|
||||
@@ -926,7 +940,7 @@ export const catalog: Catalog = {
|
||||
"Code from your authenticator": "Code uit uw authenticator-app",
|
||||
"Two-factor authentication": "Tweefactorauthenticatie",
|
||||
"Turn off two-factor authentication": "Tweefactorauthenticatie uitschakelen",
|
||||
"Your password alone will be enough to sign in again.": "Uw wachtwoord alleen volstaat dan weer om in te loggen.",
|
||||
"Your password alone will be enough to sign in again.": "Met alleen uw wachtwoord kunt u opnieuw inloggen.",
|
||||
"App passwords": "App-wachtwoorden",
|
||||
"Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "Uw organisatie staat alleen {app} en JMAP-apps toe, dus mail-apps op telefoon en computer kunnen geen verbinding maken met dit account.",
|
||||
"New app password for": "Nieuw app-wachtwoord voor",
|
||||
@@ -951,19 +965,19 @@ export const catalog: Catalog = {
|
||||
"Type": "Type",
|
||||
"Email or username": "E-mail of gebruikersnaam",
|
||||
"Use your usual address as the username.": "Gebruik uw gebruikelijke adres als gebruikersnaam.",
|
||||
"Fast, friendly webmail. Your mailbox, your way.": "Snelle, prettige webmail. Uw postbus, op uw manier.",
|
||||
"Fast, friendly webmail. Your mailbox, your way.": "Snelle, prettige webmail. Uw postvak, op uw manier.",
|
||||
|
||||
"Notifications": "Meldingen",
|
||||
"Notifications are blocked in your browser settings.": "Meldingen zijn geblokkeerd in uw browserinstellingen.",
|
||||
"Not supported in this browser.": "Niet ondersteund in deze browser.",
|
||||
"Desktop notifications while {app} is open": "Systeemmeldingen terwijl {app} open is",
|
||||
"Notify me even when {app} is closed": "Ook melden wanneer {app} gesloten is",
|
||||
"Play a sound for new mail": "Geluid afspelen bij nieuwe post",
|
||||
"Play a sound for new mail": "Geluid afspelen bij nieuwe e-mail",
|
||||
"Test notification": "Testmelding",
|
||||
"Background notifications are on": "Achtergrondmeldingen staan aan",
|
||||
"The tab title and favicon always show your unread Inbox count.": "De tabbladtitel en het favicon tonen altijd het aantal ongelezen berichten in Postvak IN.",
|
||||
"The tab title and favicon always show your unread Inbox count.": "De titel van het tabblad en het favicon tonen altijd het aantal ongelezen berichten in Postvak IN.",
|
||||
"Live updates are delivered via JMAP push ({state}).": "Live-updates komen binnen via JMAP-push ({state}).",
|
||||
"Shows a system notification when new mail arrives in your Inbox while the tab is in the background.": "Toont een systeemmelding wanneer er nieuwe post in Postvak IN aankomt terwijl het tabblad op de achtergrond staat.",
|
||||
"Shows a system notification when new mail arrives in your Inbox while the tab is in the background.": "Toont een systeemmelding wanneer er een nieuwe e-mail in Postvak IN aankomt terwijl het tabblad op de achtergrond staat.",
|
||||
|
||||
// ── Editor, search, shortcuts, misc ────────────────────────────────
|
||||
"Formatting": "Opmaak",
|
||||
@@ -1027,20 +1041,20 @@ export const catalog: Catalog = {
|
||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Afbeeldingen worden opgeslagen in uw bestanden (map “ihasmail”) en bij verzending ingesloten.",
|
||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Bedankt voor uw bericht. Ik ben afwezig tot … en reageer zodra ik terug ben.",
|
||||
"Automatically reply to people who email you while you're away. Each sender gets at most one reply.": "Automatisch antwoorden aan mensen die u mailen terwijl u weg bent. Elke afzender krijgt hoogstens één antwoord.",
|
||||
"Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.": "Inkomende post automatisch sorteren. De regels draaien op de server (Sieve) en gelden dus voor elke client die u gebruikt.",
|
||||
"Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.": "Inkomende e-mails automatisch sorteren. De filterregels staan op de server (Sieve) en gelden dus voor elke client die u gebruikt.",
|
||||
"Canned responses you can insert into any message from the composer's template button.": "Kant-en-klare antwoorden die u via de sjabloonknop in elk bericht kunt invoegen.",
|
||||
"Create a rule to move newsletters to a folder, flag important senders, or forward mail.": "Maak een regel om nieuwsbrieven naar een map te verplaatsen, belangrijke afzenders te markeren of post door te sturen.",
|
||||
"Create a rule to move newsletters to a folder, flag important senders, or forward mail.": "Maak een filterregel om nieuwsbrieven naar een map te verplaatsen, belangrijke afzenders te markeren of een e-mail door te sturen.",
|
||||
"Advanced: manage raw Sieve scripts. Only one script can be active at a time.": "Geavanceerd: Sieve-scripts rechtstreeks beheren. Er kan maar één script tegelijk actief zijn.",
|
||||
"Only part of your filter script arrived.": "Slechts een deel van uw filterscript is aangekomen.",
|
||||
"Your active script “{name}” was written by hand.": "Uw actieve script “{name}” is met de hand geschreven.",
|
||||
"Another script (“{name}”) is active. Saving rules here will activate the “ihasmail” script instead.": "Een ander script (“{name}”) is actief. Als u hier regels opslaat, wordt in plaats daarvan het script “ihasmail” geactiveerd.",
|
||||
"Another script (“{name}”) is active. Saving rules here will activate the “ihasmail” script instead.": "Een ander script (“{name}”) is actief. Als u hier filterregels opslaat, wordt in plaats daarvan het script “ihasmail” geactiveerd.",
|
||||
"“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.": "“{name}” wordt gedeactiveerd (niet verwijderd) en een nieuw script “ihasmail” neemt het over.",
|
||||
"Sieve filtering is not available for this account.": "Sieve-filtering is niet beschikbaar voor dit account.",
|
||||
"Sieve filtering is not enabled for this account.": "Sieve-filtering is niet ingeschakeld voor dit account.",
|
||||
"Vacation responses are not available for this account.": "Afwezigheidsantwoorden zijn niet beschikbaar voor dit account.",
|
||||
"This account does not have the JMAP calendars capability.": "Dit account beschikt niet over de JMAP-agendafunctie.",
|
||||
"This account does not have the JMAP contacts capability.": "Dit account beschikt niet over de JMAP-contactenfunctie.",
|
||||
"This account does not have the JMAP file storage capability.": "Dit account beschikt niet over de JMAP-bestandsopslagfunctie.",
|
||||
"This account does not have the JMAP calendars capability.": "Dit account beschikt niet over de JMAP-agenda functie.",
|
||||
"This account does not have the JMAP contacts capability.": "Dit account beschikt niet over de JMAP-contacten functie.",
|
||||
"This account does not have the JMAP file storage capability.": "Dit account beschikt niet over de JMAP-bestandsopslag functie.",
|
||||
|
||||
// ── Labels held in constants, translated where they render ─────────
|
||||
"Add": "Toevoegen",
|
||||
@@ -1099,62 +1113,62 @@ export const catalog: Catalog = {
|
||||
"share sheet\u0004Share…": "Delen…",
|
||||
"folder": "map",
|
||||
"“{name}” moved into “{parent}”": "“{name}” is verplaatst naar “{parent}”",
|
||||
"“{name}” moved to the top level": "“{name}” is naar het hoogste niveau verplaatst",
|
||||
"“{name}” moved to the top level": "“{name}” is naar het hoofdniveau verplaatst",
|
||||
"Move “{name}” to…": "“{name}” verplaatsen naar…",
|
||||
"Top level": "Hoogste niveau",
|
||||
"Top level": "Hoofdniveau",
|
||||
"Could not move “{name}”: {reason}": "Kon “{name}” niet verplaatsen: {reason}",
|
||||
"Delete “{name}”?": "“{name}” verwijderen?",
|
||||
"Rename folder": "Map hernoemen",
|
||||
"Search: {query}": "Zoeken: {query}",
|
||||
"No conversation selected": "Geen gesprek geselecteerd",
|
||||
"No message selected": "Geen bericht geselecteerd",
|
||||
"Drop here for the top level": "Hier neerzetten voor het hoogste niveau",
|
||||
"Drop here for the top level": "Hier neerzetten voor het hoofdniveau",
|
||||
|
||||
// ── Longer prose ───────────────────────────────────────────────────
|
||||
"Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)": "In e-mail zoeken (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)",
|
||||
"Settings → Filters & rules": "Instellingen → Filters en regels",
|
||||
"Open the Mail view to see all shortcuts.": "Open de E-mailweergave om alle sneltoetsen te zien.",
|
||||
"Settings → Filters & rules": "Instellingen → Filters en filterregels",
|
||||
"Open the Mail view to see all shortcuts.": "Open de e-mailweergave om alle sneltoetsen te zien.",
|
||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Sneltoetsen in Gmail-stijl staan altijd aan. Druk overal op {key} om deze lijst te zien.",
|
||||
"Select a conversation to read it here · Press {key} for shortcuts": "Selecteer een gesprek om het hier te lezen · {key} voor sneltoetsen",
|
||||
"Select a message to read it here · Press {key} for shortcuts": "Selecteer een bericht om het hier te lezen · {key} voor sneltoetsen",
|
||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tip: druk op {key} bij een gesprek om labels toe te wijzen. Zoek met {operator}.",
|
||||
"Defaults for the calendar views and new events.": "Standaardwaarden voor de agendaweergaven en nieuwe afspraken.",
|
||||
"Replies will go to this address instead of the From address": "Antwoorden gaan naar dit adres in plaats van naar het afzenderadres",
|
||||
"Replies to mail sent from this identity go here instead of the From address.": "Antwoorden op post die vanaf deze identiteit is verzonden, komen hier aan in plaats van bij het afzenderadres.",
|
||||
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Een nieuwe identiteit moet een adres gebruiken waarvandaan dit account mag verzenden (aliassen die op de server zijn ingesteld).",
|
||||
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Wordt niet aangeboden bij het opstellen. Het adres ontvangt nog steeds post, en u kunt er weer vanaf verzenden door het opnieuw te tonen.",
|
||||
"Replies to mail sent from this identity go here instead of the From address.": "Antwoorden op e-mails die vanaf deze identiteit is verzonden, komen hier aan in plaats van bij het afzenderadres.",
|
||||
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Nieuwe identiteiten moeten een adres gebruiken waar dit account vanaf mag verzenden (aliassen die op de server zijn ingesteld).",
|
||||
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Wordt niet aangeboden bij het opstellen. Het adres ontvangt nog steeds e-mails, en u kunt er weer vanaf verzenden door het opnieuw te tonen.",
|
||||
"Each identity is a sender address with its own name, Reply-To and signature. The default identity is preselected when you compose; set a Reply-To when replies should go somewhere other than the From address.": "Elke identiteit is een afzenderadres met een eigen naam, antwoordadres en handtekening. De standaardidentiteit is voorgeselecteerd bij het opstellen; stel een antwoordadres in wanneer antwoorden ergens anders heen moeten dan naar het afzenderadres.",
|
||||
"This signature is larger than the server's {limit}-byte limit. {app} will keep the full version in your Files and store a short text fallback on the server \u2014 other mail clients will see the plain-text version.": "Deze handtekening is groter dan de limiet van {limit} bytes van de server. {app} bewaart de volledige versie in uw Bestanden en zet een korte tekstversie op de server \u2014 andere e-mailprogramma's zien de platte-tekstversie.",
|
||||
"Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.": "Categorieën in Outlook-stijl die u via het rechtsklikmenu of de afsprakeneditor aan afspraken kunt toewijzen. De categorienaam wordt in de afspraak opgeslagen en synchroniseert dus met andere clients.",
|
||||
"This signature is larger than the server's {limit}-byte limit. {app} will keep the full version in your Files and store a short text fallback on the server \u2014 other mail clients will see the plain-text version.": "Deze handtekening is groter dan de limiet van {limit} bytes van de server. {app} bewaart de volledige versie in uw bestanden en zet een korte tekstversie op de server \u2014 andere e-mailprogramma's zien de platte-tekstversie.",
|
||||
"Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.": "Categorieën in Outlook-stijl die u via het rechtermuisknop-menu of de afsprakeneditor aan afspraken kunt toewijzen. De categorienaam wordt in de afspraak opgeslagen en synchroniseert met andere clients.",
|
||||
"Plain-text mail already follows the theme. With this on, HTML mail that brings no colors of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them.": "Platte-tekstberichten volgen het thema al. Met deze optie doen HTML-berichten zonder eigen kleuren dat ook, in plaats van op een witte achtergrond te staan. Berichten met een eigen vormgeving blijven precies zoals de afzender ze heeft ontworpen.",
|
||||
"This is separate from {setting} in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.": "Dit staat los van {setting} onder Algemeen, waar wordt bepaald hoe datums, tijden en getallen worden geschreven. U kunt een Engelse interface met Nederlandse datums lezen, of andersom.",
|
||||
"On a touchscreen, drag a message sideways to act on it. Each direction can do one thing, or nothing. These follow your account, so a phone and a tablet agree; a mouse ignores them and keeps dragging messages into folders instead.": "Sleep op een aanraakscherm een bericht opzij om er iets mee te doen. Elke richting kan één ding doen, of niets. Deze instelling volgt uw account, zodat telefoon en tablet overeenkomen; met een muis wordt ze genegeerd en blijft slepen naar mappen werken.",
|
||||
"This is separate from {setting} in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.": "Dit staat los van {setting} onder Algemeen, waar wordt bepaald hoe datums, tijden en getallen worden geschreven. U kunt bijvoorbeeld een Engelse interface met Nederlandse datums kiezen, of andersom.",
|
||||
"On a touchscreen, drag a message sideways to act on it. Each direction can do one thing, or nothing. These follow your account, so a phone and a tablet agree; a mouse ignores them and keeps dragging messages into folders instead.": "Veeg op een touchscreen een bericht opzij om er een actie op uit te voeren. Elke richting kan een eigen actie uitvoeren, of helemaal niets doen. Deze instellingen worden aan uw account gekoppeld, zodat uw telefoon en tablet dezelfde instellingen gebruiken. Met een muis worden deze instellingen genegeerd en kunt u berichten gewoon naar mappen slepen.",
|
||||
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Dit scherm heeft geen aanraakscherm, dus hier verandert niets. Uw telefoon of tablet neemt deze instellingen over.",
|
||||
"Holding a message selects it, and holding a folder opens its menu. Pull the top of the message list down to check for new mail.": "Een bericht ingedrukt houden selecteert het, een map ingedrukt houden opent het menu. Trek de bovenkant van de berichtenlijst omlaag om nieuwe post op te halen.",
|
||||
"A receipt tells whoever asked that this address is live and when the message was read, and the sender chooses where it goes — so there is no automatic option. Bulk mail, mailing lists and anything marked auto-submitted are never offered one at all.": "Een bevestiging vertelt de aanvrager dat dit adres actief is en wanneer het bericht is gelezen, en de afzender bepaalt waar die heen gaat — daarom is er geen automatische optie. Bij bulkpost, mailinglijsten en alles wat als automatisch verzonden is gemarkeerd, wordt er nooit een aangeboden.",
|
||||
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API \u2014 you can still make {app} the default from your operating system if you install it as an app.": "Deze browser kan geen programma's registreren voor {scheme}-links. Safari heeft daar in het bijzonder geen voorziening voor \u2014 u kunt {app} nog steeds als standaard instellen via uw besturingssysteem als u het als app installeert.",
|
||||
"Holding a message selects it, and holding a folder opens its menu. Pull the top of the message list down to check for new mail.": "Houd een bericht ingedrukt om het te selecteren en houd een map ingedrukt om het menu ervan te openen. Trek de bovenkant van de berichtenlijst omlaag om nieuwe e-mails op te halen.",
|
||||
"A receipt tells whoever asked that this address is live and when the message was read, and the sender chooses where it goes — so there is no automatic option. Bulk mail, mailing lists and anything marked auto-submitted are never offered one at all.": "Een bevestiging vertelt de aanvrager dat dit adres actief is en wanneer het bericht is gelezen, en de afzender bepaalt waar die heen gaat — daarom is er geen automatische optie. Bulkmail, mailinglijsten en alles wat als automatisch verzonden is gemarkeerd, krijgen nooit deze optie aangeboden.",
|
||||
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API \u2014 you can still make {app} the default from your operating system if you install it as an app.": "Deze browser kan geen programma's registreren voor {scheme}-links. Safari in het bijzonder heeft daar geen voorziening voor \u2014 u kunt {app} nog steeds als standaard instellen via uw besturingssysteem als u het als een app installeert.",
|
||||
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Registreren voor {scheme}-links vereist een beveiligde (HTTPS-)verbinding.",
|
||||
"Open {scheme} links \u2014 in web pages, documents and other apps \u2014 in {app} instead of a desktop mail client. Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings \u203a Privacy and security \u203a Site settings \u203a Protocol handlers; Firefox: Settings \u203a General \u203a Applications).": "{scheme}-links \u2014 op webpagina's, in documenten en in andere programma's \u2014 openen in {app} in plaats van in een lokaal e-mailprogramma. Uw browser vraagt om bevestiging, en u kunt dit later wijzigen in zijn eigen instellingen (Chrome: Instellingen \u203a Privacy en beveiliging \u203a Site-instellingen \u203a Protocol-handlers; Firefox: Instellingen \u203a Algemeen \u203a Programma's).",
|
||||
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Aangevraagd in deze browser. Of het effect heeft gehad, bepaalt de browser — controleer zijn instellingen als e-mail links nog elders openen.",
|
||||
"For a system-wide default, install {app} as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer {app} directly wherever it asks which mail app to use.": "Installeer {app} eerst als app voor een systeembrede standaard (in Chrome: het installatiepictogram in de adresbalk). Uw besturingssysteem kan {app} dan overal direct aanbieden waar het vraagt welk e-mailprogramma gebruikt moet worden.",
|
||||
"Needs a browser with the Push API and a mail server that publishes a push key.": "Vereist een browser met de Push-API en een mailserver die een push-sleutel publiceert.",
|
||||
"Your mail server delivers these straight to your browser, so they arrive with no {app} tab open, naming the sender and subject. Your browser still has to be running \u2014 if you quit it completely, notifications wait and arrive when you open it again.": "Uw mailserver levert deze rechtstreeks bij uw browser af, dus ze komen aan zonder geopend {app}-tabblad, met afzender en onderwerp erbij. Uw browser moet wel draaien \u2014 sluit u hem helemaal af, dan wachten de meldingen en komen ze binnen zodra u hem weer opent.",
|
||||
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Uw mailserver kan deze browser wekken, maar vermeldt geen afzender of onderwerp. Uw browser moet wel draaien.",
|
||||
"This is what a new-mail notification looks like.": "Zo ziet een melding van nieuwe post eruit.",
|
||||
"Your mail server delivers these straight to your browser, so they arrive with no {app} tab open, naming the sender and subject. Your browser still has to be running \u2014 if you quit it completely, notifications wait and arrive when you open it again.": "Uw mailserver levert deze rechtstreeks bij uw browser af, dus ze komen aan zonder geopend {app}-tabblad, met afzender en onderwerp erbij. Uw browser moet wel open staan \u2014 sluit u hem helemaal af, dan wachten de meldingen en komen ze binnen zodra u hem weer opent.",
|
||||
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Uw mailserver kan deze browser activeren, maar vermeldt daarbij geen afzender of onderwerp. Uw browser moet wel open staan.",
|
||||
"This is what a new-mail notification looks like.": "Zo ziet een melding van nieuwe e-mails eruit.",
|
||||
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to the mail server.": "U bent ingelogd als {user}. Uw wachtwoord wordt nooit in de browser opgeslagen; de server bewaart het versleuteld per sessie om met de mailserver te communiceren.",
|
||||
"App passwords are managed by your mail administrator.": "App-wachtwoorden worden beheerd door uw mailbeheerder.",
|
||||
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Als u uw wachtwoord wijzigt, worden uw andere webmailsessies uitgelogd. App-wachtwoorden blijven werken.",
|
||||
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Als u uw wachtwoord wijzigt, worden uw andere webmailsessies afgemeld. App-wachtwoorden blijven werken.",
|
||||
"This account has two-factor authentication on. {app} can't sign you in with a code yet, so signing in on another device needs an app password \u2014 or you can turn two-factor authentication off here.": "Voor dit account staat tweefactorauthenticatie aan. {app} kan u nog niet met een code inloggen, dus inloggen op een ander apparaat vereist een app-wachtwoord \u2014 of u schakelt tweefactorauthenticatie hier uit.",
|
||||
"A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one.": "Een apart wachtwoord voor een e-mailprogramma of apparaat, dat u afzonderlijk kunt intrekken. App-wachtwoorden slaan tweefactorcodes over en blijven dus werken in programma's die er geen kunnen vragen.",
|
||||
"A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one.": "Een apart wachtwoord voor een e-mail app of apparaat, dat u afzonderlijk kunt intrekken. App-wachtwoorden omzeilen tweestapsverificatie, zodat ze blijven werken in apps die hier niet om kunnen vragen.",
|
||||
"Copy it into {name} now — it isn't shown again.": "Neem het nu over in {name} — het wordt niet opnieuw getoond.",
|
||||
"No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.": "Geen andere gebruikers gevonden in de directory, dus er kan niemand nieuws worden toegevoegd. Bestaande gedeelde items staan hieronder en kunnen nog worden verwijderd.",
|
||||
"Stalwart does not publish its version number to mail clients, so {app} reports the edition where the server gives one. {app} requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart geeft zijn versienummer niet door aan e-mailprogramma's, dus {app} noemt de editie als de server die opgeeft. {app} vereist 0.16 of nieuwer; inloggen weigert alles wat ouder is.",
|
||||
"It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.": "Het {damage}, dus de regels erin kunnen niet worden getoond of bewerkt — wat wél is aangekomen opslaan zou de rest overschrijven. Laad de pagina opnieuw om het nog eens te proberen. Uw regels staan nog op de server; hier is er niets aan veranderd.",
|
||||
"The visual rule editor only manages scripts it created. You can edit the script in the {tab} tab, or start fresh with rules (the existing script will be kept but deactivated).": "De visuele regeleditor beheert alleen scripts die hij zelf heeft gemaakt. U kunt het script bewerken op het tabblad {tab}, of opnieuw beginnen met regels (het bestaande script blijft bewaard maar wordt gedeactiveerd).",
|
||||
"Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.": "Uw filterscript {damage}, dus slechts een deel is aangekomen. Een regel toevoegen zou dat deel over het geheel heen schrijven. Laad de pagina opnieuw en probeer het nog eens.",
|
||||
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Uw filterscript kon zojuist niet worden gelezen; een regel toevoegen zou het kunnen overschrijven. Laad de pagina opnieuw en probeer het nog eens.",
|
||||
"Your active Sieve script was written by hand, so rules can't be added automatically. Open {where} to edit the script or switch to managed rules.": "Uw actieve Sieve-script is met de hand geschreven, dus regels kunnen niet automatisch worden toegevoegd. Open {where} om het script te bewerken of over te stappen op beheerde regels.",
|
||||
"Only languages {app} has been translated into appear here, so this list grows as translations land rather than ahead of them \u2014 a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Hier verschijnen alleen talen waarin {app} is vertaald; de lijst groeit dus mee met de vertalingen en niet erop vooruit \u2014 een taal die wordt aangeboden zonder teksten erachter zou de pagina laten beweren dat ze in een taal is die ze niet is.",
|
||||
"No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.": "Er zijn geen andere gebruikers in de directory gevonden, dus er kunnen geen nieuwe gebruikers worden toegevoegd. Hieronder ziet u de bestaande gedeelde items. U kunt deze nog steeds verwijderen.",
|
||||
"Stalwart does not publish its version number to mail clients, so {app} reports the edition where the server gives one. {app} requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart geeft zijn versienummer niet door aan e-mailprogramma's, dus {app} noemt de editie als de server die opgeeft. {app} vereist 0.16 of nieuwer; inloggen weigert alle oudere versies.",
|
||||
"It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.": "Het {damage}, waardoor de filterregels hierin niet kunnen worden weergegeven of bewerkt. Als u opslaat wat wel is binnengekomen, wordt de rest overschreven. Laad de pagina opnieuw om het nogmaals te proberen. Uw filterregels staan nog steeds op de server; hier is niets aan gewijzigd.",
|
||||
"The visual rule editor only manages scripts it created. You can edit the script in the {tab} tab, or start fresh with rules (the existing script will be kept but deactivated).": "De visuele filterregel-editor beheert alleen scripts die hij zelf heeft gemaakt. U kunt het script bewerken op het tabblad {tab}, of opnieuw beginnen met filterregels (het bestaande script blijft bewaard maar wordt gedeactiveerd).",
|
||||
"Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.": "Uw filterregel-script {damage}, dus slechts een deel is aangekomen. Als u een filterregel toevoegt, wordt dat deel over het volledige script heen geschreven. Laad de pagina opnieuw en probeer het nogmaals.",
|
||||
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Uw filterregel-script kon niet worden gelezen; een regel toevoegen zou het kunnen overschrijven. Laad de pagina opnieuw en probeer het nog eens.",
|
||||
"Your active Sieve script was written by hand, so rules can't be added automatically. Open {where} to edit the script or switch to managed rules.": "Uw actieve Sieve-script is met de hand geschreven, dus filterregels kunnen niet automatisch worden toegevoegd. Open {where} om het script te bewerken of over te stappen op beheerde filterregels.",
|
||||
"Only languages {app} has been translated into appear here, so this list grows as translations land rather than ahead of them \u2014 a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Hier verschijnen alleen de talen waarin {app} is vertaald; de lijst wordt uitgebreid zodra vertalingen beschikbaar komen, in plaats van vooraf \u2014 een aangeboden taal zonder bijbehorende vertalingen zou ervoor zorgen dat de pagina beweert in een taal te zijn die niet beschikbaar is.",
|
||||
"tell us about it": "laat het ons weten",
|
||||
"This translation was generated by AI and has not been checked by a native speaker, so it is marked Beta until somebody who speaks it signs it off. Anything that reads wrongly is worth reporting — {report}.": "Deze vertaling is door AI gemaakt en niet gecontroleerd door iemand met Nederlands als moedertaal; ze is daarom als Beta gemarkeerd tot iemand haar goedkeurt. Alles wat verkeerd klinkt, is een melding waard — {report}.",
|
||||
"{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 \u2014 {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "De eigen versie van {app} is de datum van de commit waaruit het is gebouwd, gevolgd door waar die commit vandaan kwam: {example} is gebouwd uit een commit van 30 augustus 2026 die via pull request 129 binnenkwam. Een commit die niet via zo'n verzoek kwam, draagt in plaats daarvan zijn korte SHA \u2014 {sha}. De versie zegt bewust niets over Stalwart; wat deze build van de server nodig heeft, staat op de regel hierboven.",
|
||||
@@ -1322,6 +1336,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Toch verzenden",
|
||||
"Send canceled — the message is back in Drafts": "Verzenden geannuleerd — het bericht staat weer bij Concepten",
|
||||
"Send failed: {error}": "Verzenden mislukt: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Kon niet controleren of dit bericht al is verzonden. Kijk in Verzonden voordat je het opnieuw verstuurt.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Kon niet bevestigen of dit bericht is verzonden. Kijk in Verzonden voordat je het opnieuw verstuurt.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Dit bericht was al verzonden en is daarom niet opnieuw verstuurd.",
|
||||
"Send invites": "Uitnodigingen verzenden",
|
||||
"Send scheduled for {when}": "Verzenden gepland voor {when}",
|
||||
"Send without a subject?": "Verzenden zonder een onderwerp?",
|
||||
@@ -1376,6 +1393,8 @@ export const catalog: Catalog = {
|
||||
"Collapse all": "Alles samenvouwen",
|
||||
"Expand all": "Alles uitvouwen",
|
||||
"Send now instead": "Toch nu verzenden",
|
||||
"This message is rich text": "Dit bericht is opgemaakte tekst",
|
||||
"This message is plain text": "Dit bericht is platte tekst",
|
||||
"Switch to plain text": "Overschakelen naar platte tekst",
|
||||
"Switch to rich text": "Overschakelen naar opgemaakte tekst",
|
||||
"{used} of {total} used": "{used} van {total} gebruikt",
|
||||
@@ -1722,6 +1741,28 @@ export const catalog: Catalog = {
|
||||
// ── 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",
|
||||
// inbuxa: legacy mail protocols, one switch per protocol
|
||||
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Uw organisatie heeft {protocols} uitgeschakeld voor mail-apps. Mail-apps die dat gebruiken, kunnen geen verbinding maken met dit account; andere nog wel.",
|
||||
"Some legacy mail protocols are off for your organization: {protocols}.": "Sommige verouderde mailprotocollen zijn uitgeschakeld voor uw organisatie: {protocols}.",
|
||||
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Sommige zijn uit voor {tenant}: {protocols}. In de beheerconsole zijn ze één voor één aan en uit te zetten.",
|
||||
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
|
||||
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Verwijderen, vergrendelen en juridische bewaarplichten gebeuren in de beheerconsole, die de reden vastlegt en bewaart wat onder een bewaarplicht valt.",
|
||||
"Open in the console": "Openen in de console",
|
||||
// inbuxa AL-7, AL-8: a locked account handed to the reader
|
||||
"You no longer have access to {name}. Back to your own mail.": "U hebt geen toegang meer tot {name}. Terug naar uw eigen mail.",
|
||||
"You can't send from {name}. It was handed to you to read, not to send as.": "U kunt niet verzenden vanuit {name}. Het account is u gegeven om te lezen, niet om namens te verzenden.",
|
||||
"This account was handed to you to read. Nothing in it can be changed.": "Dit account is u gegeven om te lezen. Er kan niets in worden gewijzigd.",
|
||||
"You can file and move mail in this account, but not delete it.": "U kunt mail in dit account ordenen en verplaatsen, maar niet verwijderen.",
|
||||
"Read only": "Alleen lezen",
|
||||
"Read and organize": "Lezen en ordenen",
|
||||
"Full access": "Volledige toegang",
|
||||
"Locked account:": "Vergrendeld account:",
|
||||
"You can send as this account": "U kunt namens dit account verzenden",
|
||||
"Back to my mail": "Terug naar mijn mail",
|
||||
"Send or close the message you're writing first.": "Verzend of sluit eerst het bericht dat u schrijft.",
|
||||
"Mail to show": "Mail tonen van",
|
||||
"My mail": "Mijn mail",
|
||||
"Locked account": "Vergrendeld account",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -1,71 +1,80 @@
|
||||
import type { PermissionCatalog } from "@/lib/permissionLabels";
|
||||
|
||||
/** Stalwart 0.16.22's permission labels in Dutch, keyed by permission name. Checked against source.json. */
|
||||
/**
|
||||
* Stalwart 0.16.22's permission labels in Dutch, keyed by permission name. Checked against source.json.
|
||||
* Reviewed and corrected by Michael (mbjboon82, also mbjboon-netizen), a native speaker, in September 2026: https://git.coffeylabs.org/mbjboon82
|
||||
*/
|
||||
|
||||
// Dutch speaker note: For example “Cluster Nodes Management” is a bit difficult to translate naturally into Dutch.
|
||||
// “Nodes” is plural, so the Dutch term should also be plural: “Clusterknooppunten”.
|
||||
// A literal translation such as “Clusterknooppuntbeheer” sounds awkward and also loses the natural plural form.
|
||||
// For a menu item, “Clusterknooppunten” is much more natural, or “Clusterknooppunten beheren” if the action needs to be explicit.
|
||||
|
||||
export const permissionCatalog: PermissionCatalog = {
|
||||
categories: {
|
||||
"AI models Management": "AI-modelbeheer",
|
||||
"API keys Management": "API-sleutelbeheer",
|
||||
"Accounts Management": "Accountbeheer",
|
||||
"AI models Management": "AI-modellen beheren",
|
||||
"API keys Management": "API-sleutels beheren",
|
||||
"Accounts Management": "Accounts beheren",
|
||||
"Action": "Actie",
|
||||
"Actions Management": "Actiebeheer",
|
||||
"Addresses Management": "Adresbeheer",
|
||||
"Alerts Management": "Waarschuwingsbeheer",
|
||||
"App passwords Management": "App-wachtwoordbeheer",
|
||||
"Applications Management": "Applicatiebeheer",
|
||||
"Archived items Management": "Beheer van gearchiveerde items",
|
||||
"Actions Management": "Acties beheren",
|
||||
"Addresses Management": "Adressen beheren",
|
||||
"Alerts Management": "Waarschuwingen beheren",
|
||||
"App passwords Management": "App-wachtwoorden beheren",
|
||||
"Applications Management": "Applicaties beheren",
|
||||
"Archived items Management": "Gearchiveerde items beheren",
|
||||
"Calendar": "Agenda",
|
||||
"Certificates Management": "Certificaatbeheer",
|
||||
"Cluster nodes Management": "Clusterknooppuntbeheer",
|
||||
"DNS servers Management": "DNS-serverbeheer",
|
||||
"Directories Management": "Adreslijstbeheer",
|
||||
"Domains Management": "Domeinbeheer",
|
||||
"Certificates Management": "Certificaten beheren",
|
||||
"Cluster nodes Management": "Clusterknooppunten beheren",
|
||||
"DNS servers Management": "DNS-servers beheren",
|
||||
"Directories Management": "Adreslijsten beheren",
|
||||
"Domains Management": "Domeinen beheren",
|
||||
"Email": "E-mail",
|
||||
"Events Management": "Gebeurtenisbeheer",
|
||||
"Extensions Management": "Extensiebeheer",
|
||||
"Hooks Management": "Hookbeheer",
|
||||
"Events Management": "Gebeurtenissen beheren",
|
||||
"Extensions Management": "Extensies beheren",
|
||||
"Hooks Management": "Hooks beheren",
|
||||
"IMAP": "IMAP",
|
||||
"JMAP": "JMAP",
|
||||
"Keys Management": "Sleutelbeheer",
|
||||
"Listeners Management": "Listenerbeheer",
|
||||
"Lists Management": "Lijstbeheer",
|
||||
"Log entries Management": "Logboekbeheer",
|
||||
"Lookups Management": "Lookupbeheer",
|
||||
"Mailing lists Management": "Mailinglijstbeheer",
|
||||
"Keys Management": "Sleutels beheren",
|
||||
"Listeners Management": "Listeners beheren",
|
||||
"Lists Management": "Lijsten beheren",
|
||||
"Log entries Management": "Logboeken beheren",
|
||||
"Lookups Management": "Lookups beheren",
|
||||
"Mailing lists Management": "Mailinglijsten beheren",
|
||||
"ManageSieve": "ManageSieve",
|
||||
"Masked emails Management": "Beheer van gemaskeerde e-mailadressen",
|
||||
"Messages Management": "Berichtbeheer",
|
||||
"Metrics Management": "Statistiekbeheer",
|
||||
"Milters Management": "Milterbeheer",
|
||||
"OAuth clients Management": "OAuth-clientbeheer",
|
||||
"Masked emails Management": "Gemaskeerde e-mailadressen beheren",
|
||||
"Messages Management": "Berichten beheren",
|
||||
"Metrics Management": "Statistieken beheren",
|
||||
"Milters Management": "Milters beheren",
|
||||
"OAuth clients Management": "OAuth-clients beheren",
|
||||
"POP3": "POP3",
|
||||
"Providers Management": "Providerbeheer",
|
||||
"Public keys Management": "Beheer van openbare sleutels",
|
||||
"Queues Management": "Wachtrijbeheer",
|
||||
"Quotas Management": "Quotabeheer",
|
||||
"Reports Management": "Rapportbeheer",
|
||||
"Roles Management": "Rolbeheer",
|
||||
"Routes Management": "Routebeheer",
|
||||
"Rules Management": "Regelbeheer",
|
||||
"Samples Management": "Voorbeeldbeheer",
|
||||
"Schedules Management": "Schemabeheer",
|
||||
"Scripts Management": "Scriptbeheer",
|
||||
"Servers Management": "Serverbeheer",
|
||||
"Providers Management": "Providers beheren",
|
||||
"Public keys Management": "Openbare sleutels beheren",
|
||||
"Queues Management": "Wachtrijen beheren",
|
||||
"Quotas Management": "Quotas beheren",
|
||||
"Reports Management": "Rapporten beheren",
|
||||
"Roles Management": "Rollen beheren",
|
||||
"Routes Management": "Routes beheren",
|
||||
"Rules Management": "Filterregels beheren",
|
||||
"Samples Management": "Voorbeelden beheren",
|
||||
"Schedules Management": "Schema's beheren",
|
||||
"Scripts Management": "Scripts beheren",
|
||||
"Servers Management": "Servers beheren",
|
||||
"Settings": "Instellingen",
|
||||
"Signatures Management": "Handtekeningbeheer",
|
||||
"Strategies Management": "Strategiebeheer",
|
||||
"Tags Management": "Tagbeheer",
|
||||
"Signatures Management": "Handtekeningen beheren",
|
||||
"Strategies Management": "Strategieën beheren",
|
||||
"Tags Management": "Tags beheren",
|
||||
"Task": "Taak",
|
||||
"Tasks Management": "Takenbeheer",
|
||||
"Tenants Management": "Tenantbeheer",
|
||||
"Throttles Management": "Beheer van snelheidsbeperkingen",
|
||||
"Tracers Management": "Tracerbeheer",
|
||||
"Traces Management": "Tracebeheer",
|
||||
"Tasks Management": "Taken beheren",
|
||||
"Tenants Management": "Tenants beheren",
|
||||
"Throttles Management": "Snelheidsbeperkingen beheren",
|
||||
"Tracers Management": "Tracers beheren",
|
||||
"Traces Management": "Traces beheren",
|
||||
"WebDAV": "WebDAV",
|
||||
"Webhooks Management": "Webhookbeheer",
|
||||
"Webhooks Management": "Webhooks beheren",
|
||||
},
|
||||
labels: {
|
||||
authenticate: "Inloggen op de server",
|
||||
authenticateWithAlias: "Inloggen met e-mailaliasadressen",
|
||||
authenticateWithAlias: "Inloggen met e-mailalias-adressen",
|
||||
interactAi: "AI-modellen gebruiken",
|
||||
impersonate: "Namens een andere gebruiker handelen",
|
||||
unlimitedRequests: "Snelheidslimieten voor verzoeken omzeilen",
|
||||
@@ -283,23 +292,23 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
oAuthClientOverride: "Registratievereisten voor OAuth-clients omzeilen",
|
||||
liveTracing: "Realtime traces van servergebeurtenissen bekijken",
|
||||
liveMetrics: "Realtime prestatiestatistieken van de server bekijken",
|
||||
liveDeliveryTest: "Test-e-mails verzenden om de bezorgconfiguratie te controleren",
|
||||
liveDeliveryTest: "Test e-mails verzenden om de bezorgconfiguratie te controleren",
|
||||
scimAccess: "Gebruikers en groepen inrichten via het SCIM-eindpunt",
|
||||
sysAccountGet: "Accounts ophalen",
|
||||
sysAccountCreate: "Accounts aanmaken",
|
||||
sysAccountUpdate: "Accounts bijwerken",
|
||||
sysAccountDestroy: "Accounts verwijderen",
|
||||
sysAccountQuery: "Accounts opvragen",
|
||||
sysAccountPasswordGet: "Wachtwoordinstellingen van accounts ophalen",
|
||||
sysAccountPasswordUpdate: "Wachtwoordinstellingen van accounts bijwerken",
|
||||
sysAccountSettingsGet: "Accountinstellingen ophalen",
|
||||
sysAccountSettingsUpdate: "Accountinstellingen bijwerken",
|
||||
sysAccountPasswordGet: "Wachtwoord instellingen van accounts ophalen",
|
||||
sysAccountPasswordUpdate: "Wachtwoord instellingen van accounts bijwerken",
|
||||
sysAccountSettingsGet: "Account instellingen ophalen",
|
||||
sysAccountSettingsUpdate: "Account instellingen bijwerken",
|
||||
sysAcmeProviderGet: "Providers ophalen",
|
||||
sysAcmeProviderCreate: "Providers aanmaken",
|
||||
sysAcmeProviderUpdate: "Providers bijwerken",
|
||||
sysAcmeProviderDestroy: "Providers verwijderen",
|
||||
sysAcmeProviderQuery: "Providers opvragen",
|
||||
actionReloadSettings: "Opnieuw laden: serverinstellingen",
|
||||
actionReloadSettings: "Opnieuw laden: server instellingen",
|
||||
actionReloadTlsCertificates: "Opnieuw laden: TLS-certificaten",
|
||||
actionReloadLookupStores: "Opnieuw laden: lookup-opslag",
|
||||
actionReloadBlockedIps: "Opnieuw laden: lijst met geblokkeerde IP's",
|
||||
@@ -315,8 +324,8 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysActionUpdate: "Acties bijwerken",
|
||||
sysActionDestroy: "Acties verwijderen",
|
||||
sysActionQuery: "Acties opvragen",
|
||||
sysAddressBookGet: "Adresboekinstellingen ophalen",
|
||||
sysAddressBookUpdate: "Adresboekinstellingen bijwerken",
|
||||
sysAddressBookGet: "Adresboek instellingen ophalen",
|
||||
sysAddressBookUpdate: "Adresboek instellingen bijwerken",
|
||||
sysAiModelGet: "AI-modellen ophalen",
|
||||
sysAiModelCreate: "AI-modellen aanmaken",
|
||||
sysAiModelUpdate: "AI-modellen bijwerken",
|
||||
@@ -357,23 +366,23 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysArfExternalReportUpdate: "Rapporten bijwerken",
|
||||
sysArfExternalReportDestroy: "Rapporten verwijderen",
|
||||
sysArfExternalReportQuery: "Rapporten opvragen",
|
||||
sysAsnGet: "ASN-instellingen ophalen",
|
||||
sysAsnUpdate: "ASN-instellingen bijwerken",
|
||||
sysAuthenticationGet: "Authenticatie-instellingen ophalen",
|
||||
sysAuthenticationUpdate: "Authenticatie-instellingen bijwerken",
|
||||
sysBlobStoreGet: "Blob-opslaginstellingen ophalen",
|
||||
sysBlobStoreUpdate: "Blob-opslaginstellingen bijwerken",
|
||||
sysAsnGet: "ASN instellingen ophalen",
|
||||
sysAsnUpdate: "ASN instellingen bijwerken",
|
||||
sysAuthenticationGet: "Authenticatie instellingen ophalen",
|
||||
sysAuthenticationUpdate: "Authenticatie instellingen bijwerken",
|
||||
sysBlobStoreGet: "Blob-opslag instellingen ophalen",
|
||||
sysBlobStoreUpdate: "Blob-opslag instellingen bijwerken",
|
||||
sysBlockedIpGet: "Adressen ophalen",
|
||||
sysBlockedIpCreate: "Adressen aanmaken",
|
||||
sysBlockedIpUpdate: "Adressen bijwerken",
|
||||
sysBlockedIpDestroy: "Adressen verwijderen",
|
||||
sysBlockedIpQuery: "Adressen opvragen",
|
||||
sysBootstrapGet: "Bootstrap-instellingen ophalen",
|
||||
sysBootstrapUpdate: "Bootstrap-instellingen bijwerken",
|
||||
sysCacheGet: "Cache-instellingen ophalen",
|
||||
sysCacheUpdate: "Cache-instellingen bijwerken",
|
||||
sysCalendarGet: "Agenda-instellingen ophalen",
|
||||
sysCalendarUpdate: "Agenda-instellingen bijwerken",
|
||||
sysBootstrapGet: "Bootstrap instellingen ophalen",
|
||||
sysBootstrapUpdate: "Bootstrap instellingen bijwerken",
|
||||
sysCacheGet: "Cache instellingen ophalen",
|
||||
sysCacheUpdate: "Cache instellingen bijwerken",
|
||||
sysCalendarGet: "Agenda instellingen ophalen",
|
||||
sysCalendarUpdate: "Agenda instellingen bijwerken",
|
||||
sysCalendarAlarmGet: "Instellingen voor agendaherinneringen ophalen",
|
||||
sysCalendarAlarmUpdate: "Instellingen voor agendaherinneringen bijwerken",
|
||||
sysCalendarSchedulingGet: "Instellingen voor agendaplanning ophalen",
|
||||
@@ -393,19 +402,19 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysClusterRoleUpdate: "Rollen bijwerken",
|
||||
sysClusterRoleDestroy: "Rollen verwijderen",
|
||||
sysClusterRoleQuery: "Rollen opvragen",
|
||||
sysCoordinatorGet: "Coördinatorinstellingen ophalen",
|
||||
sysCoordinatorUpdate: "Coördinatorinstellingen bijwerken",
|
||||
sysCoordinatorGet: "Coördinator instellingen ophalen",
|
||||
sysCoordinatorUpdate: "Coördinator instellingen bijwerken",
|
||||
sysDataRetentionGet: "Instellingen voor gegevensbewaring ophalen",
|
||||
sysDataRetentionUpdate: "Instellingen voor gegevensbewaring bijwerken",
|
||||
sysDataStoreGet: "Gegevensopslaginstellingen ophalen",
|
||||
sysDataStoreUpdate: "Gegevensopslaginstellingen bijwerken",
|
||||
sysDataStoreGet: "Gegevensopslag instellingen ophalen",
|
||||
sysDataStoreUpdate: "Gegevensopslag instellingen bijwerken",
|
||||
sysDirectoryGet: "Adreslijsten ophalen",
|
||||
sysDirectoryCreate: "Adreslijsten aanmaken",
|
||||
sysDirectoryUpdate: "Adreslijsten bijwerken",
|
||||
sysDirectoryDestroy: "Adreslijsten verwijderen",
|
||||
sysDirectoryQuery: "Adreslijsten opvragen",
|
||||
sysDkimReportSettingsGet: "DKIM-rapportinstellingen ophalen",
|
||||
sysDkimReportSettingsUpdate: "DKIM-rapportinstellingen bijwerken",
|
||||
sysDkimReportSettingsGet: "DKIM-rapport instellingen ophalen",
|
||||
sysDkimReportSettingsUpdate: "DKIM-rapport instellingen bijwerken",
|
||||
sysDkimSignatureGet: "Handtekeningen ophalen",
|
||||
sysDkimSignatureCreate: "Handtekeningen aanmaken",
|
||||
sysDkimSignatureUpdate: "Handtekeningen bijwerken",
|
||||
@@ -421,10 +430,10 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysDmarcInternalReportUpdate: "Rapporten bijwerken",
|
||||
sysDmarcInternalReportDestroy: "Rapporten verwijderen",
|
||||
sysDmarcInternalReportQuery: "Rapporten opvragen",
|
||||
sysDmarcReportSettingsGet: "DMARC-rapportinstellingen ophalen",
|
||||
sysDmarcReportSettingsUpdate: "DMARC-rapportinstellingen bijwerken",
|
||||
sysDnsResolverGet: "DNS-resolverinstellingen ophalen",
|
||||
sysDnsResolverUpdate: "DNS-resolverinstellingen bijwerken",
|
||||
sysDmarcReportSettingsGet: "DMARC-rapport instellingen ophalen",
|
||||
sysDmarcReportSettingsUpdate: "DMARC-rapport instellingen bijwerken",
|
||||
sysDnsResolverGet: "DNS-resolver instellingen ophalen",
|
||||
sysDnsResolverUpdate: "DNS-resolver instellingen bijwerken",
|
||||
sysDnsServerGet: "DNS-servers ophalen",
|
||||
sysDnsServerCreate: "DNS-servers aanmaken",
|
||||
sysDnsServerUpdate: "DNS-servers bijwerken",
|
||||
@@ -435,34 +444,34 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysDomainUpdate: "Domeinen bijwerken",
|
||||
sysDomainDestroy: "Domeinen verwijderen",
|
||||
sysDomainQuery: "Domeinen opvragen",
|
||||
sysDsnReportSettingsGet: "DSN-rapportinstellingen ophalen",
|
||||
sysDsnReportSettingsUpdate: "DSN-rapportinstellingen bijwerken",
|
||||
sysEmailGet: "E-mailinstellingen ophalen",
|
||||
sysEmailUpdate: "E-mailinstellingen bijwerken",
|
||||
sysEnterpriseGet: "Enterprise-instellingen ophalen",
|
||||
sysEnterpriseUpdate: "Enterprise-instellingen bijwerken",
|
||||
sysDsnReportSettingsGet: "DSN-rapport instellingen ophalen",
|
||||
sysDsnReportSettingsUpdate: "DSN-rapport instellingen bijwerken",
|
||||
sysEmailGet: "E-mail instellingen ophalen",
|
||||
sysEmailUpdate: "E-mail instellingen bijwerken",
|
||||
sysEnterpriseGet: "Enterprise instellingen ophalen",
|
||||
sysEnterpriseUpdate: "Enterprise instellingen bijwerken",
|
||||
sysEventTracingLevelGet: "Gebeurtenissen ophalen",
|
||||
sysEventTracingLevelCreate: "Gebeurtenissen aanmaken",
|
||||
sysEventTracingLevelUpdate: "Gebeurtenissen bijwerken",
|
||||
sysEventTracingLevelDestroy: "Gebeurtenissen verwijderen",
|
||||
sysEventTracingLevelQuery: "Gebeurtenissen opvragen",
|
||||
sysFileStorageGet: "Bestandsopslaginstellingen ophalen",
|
||||
sysFileStorageUpdate: "Bestandsopslaginstellingen bijwerken",
|
||||
sysHttpGet: "HTTP-instellingen ophalen",
|
||||
sysHttpUpdate: "HTTP-instellingen bijwerken",
|
||||
sysHttpFormGet: "HTTP-formulierinstellingen ophalen",
|
||||
sysHttpFormUpdate: "HTTP-formulierinstellingen bijwerken",
|
||||
sysFileStorageGet: "Bestandsopslag instellingen ophalen",
|
||||
sysFileStorageUpdate: "Bestandsopslag instellingen bijwerken",
|
||||
sysHttpGet: "HTTP instellingen ophalen",
|
||||
sysHttpUpdate: "HTTP instellingen bijwerken",
|
||||
sysHttpFormGet: "HTTP-formulier instellingen ophalen",
|
||||
sysHttpFormUpdate: "HTTP-formulier instellingen bijwerken",
|
||||
sysHttpLookupGet: "Lijsten ophalen",
|
||||
sysHttpLookupCreate: "Lijsten aanmaken",
|
||||
sysHttpLookupUpdate: "Lijsten bijwerken",
|
||||
sysHttpLookupDestroy: "Lijsten verwijderen",
|
||||
sysHttpLookupQuery: "Lijsten opvragen",
|
||||
sysImapGet: "IMAP-instellingen ophalen",
|
||||
sysImapUpdate: "IMAP-instellingen bijwerken",
|
||||
sysImapGet: "IMAP instellingen ophalen",
|
||||
sysImapUpdate: "IMAP instellingen bijwerken",
|
||||
sysInMemoryStoreGet: "Instellingen voor in-memory-opslag ophalen",
|
||||
sysInMemoryStoreUpdate: "Instellingen voor in-memory-opslag bijwerken",
|
||||
sysJmapGet: "JMAP-instellingen ophalen",
|
||||
sysJmapUpdate: "JMAP-instellingen bijwerken",
|
||||
sysJmapGet: "JMAP instellingen ophalen",
|
||||
sysJmapUpdate: "JMAP instellingen bijwerken",
|
||||
sysLogGet: "Logboekvermeldingen ophalen",
|
||||
sysLogCreate: "Logboekvermeldingen aanmaken",
|
||||
sysLogUpdate: "Logboekvermeldingen bijwerken",
|
||||
@@ -493,8 +502,8 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysMetricUpdate: "Statistieken bijwerken",
|
||||
sysMetricDestroy: "Statistieken verwijderen",
|
||||
sysMetricQuery: "Statistieken opvragen",
|
||||
sysMetricsGet: "Statistiekinstellingen ophalen",
|
||||
sysMetricsUpdate: "Statistiekinstellingen bijwerken",
|
||||
sysMetricsGet: "Statistiek instellingen ophalen",
|
||||
sysMetricsUpdate: "Statistiek instellingen bijwerken",
|
||||
sysMetricsStoreGet: "Instellingen voor statistiekopslag ophalen",
|
||||
sysMetricsStoreUpdate: "Instellingen voor statistiekopslag bijwerken",
|
||||
sysMtaConnectionStrategyGet: "Strategieën ophalen",
|
||||
@@ -507,8 +516,8 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysMtaDeliveryScheduleUpdate: "Schema's bijwerken",
|
||||
sysMtaDeliveryScheduleDestroy: "Schema's verwijderen",
|
||||
sysMtaDeliveryScheduleQuery: "Schema's opvragen",
|
||||
sysMtaExtensionsGet: "MTA-extensie-instellingen ophalen",
|
||||
sysMtaExtensionsUpdate: "MTA-extensie-instellingen bijwerken",
|
||||
sysMtaExtensionsGet: "MTA-extensie instellingen ophalen",
|
||||
sysMtaExtensionsUpdate: "MTA-extensie instellingen bijwerken",
|
||||
sysMtaHookGet: "Hooks ophalen",
|
||||
sysMtaHookCreate: "Hooks aanmaken",
|
||||
sysMtaHookUpdate: "Hooks bijwerken",
|
||||
@@ -555,8 +564,8 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysMtaStageMailUpdate: "Instellingen voor MTA-fase MAIL bijwerken",
|
||||
sysMtaStageRcptGet: "Instellingen voor MTA-fase RCPT ophalen",
|
||||
sysMtaStageRcptUpdate: "Instellingen voor MTA-fase RCPT bijwerken",
|
||||
sysMtaStsGet: "MTA-STS-instellingen ophalen",
|
||||
sysMtaStsUpdate: "MTA-STS-instellingen bijwerken",
|
||||
sysMtaStsGet: "MTA-STS instellingen ophalen",
|
||||
sysMtaStsUpdate: "MTA-STS instellingen bijwerken",
|
||||
sysMtaTlsStrategyGet: "Strategieën ophalen",
|
||||
sysMtaTlsStrategyCreate: "Strategieën aanmaken",
|
||||
sysMtaTlsStrategyUpdate: "Strategieën bijwerken",
|
||||
@@ -577,8 +586,8 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysOAuthClientUpdate: "OAuth-clients bijwerken",
|
||||
sysOAuthClientDestroy: "OAuth-clients verwijderen",
|
||||
sysOAuthClientQuery: "OAuth-clients opvragen",
|
||||
sysOidcProviderGet: "OIDC-providerinstellingen ophalen",
|
||||
sysOidcProviderUpdate: "OIDC-providerinstellingen bijwerken",
|
||||
sysOidcProviderGet: "OIDC-provider instellingen ophalen",
|
||||
sysOidcProviderUpdate: "OIDC-provider instellingen bijwerken",
|
||||
sysPublicKeyGet: "Openbare sleutels ophalen",
|
||||
sysPublicKeyCreate: "Openbare sleutels aanmaken",
|
||||
sysPublicKeyUpdate: "Openbare sleutels bijwerken",
|
||||
@@ -589,19 +598,19 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysQueuedMessageUpdate: "Berichten bijwerken",
|
||||
sysQueuedMessageDestroy: "Berichten verwijderen",
|
||||
sysQueuedMessageQuery: "Berichten opvragen",
|
||||
sysReportSettingsGet: "Rapportinstellingen ophalen",
|
||||
sysReportSettingsUpdate: "Rapportinstellingen bijwerken",
|
||||
sysReportSettingsGet: "Rapport instellingen ophalen",
|
||||
sysReportSettingsUpdate: "Rapport instellingen bijwerken",
|
||||
sysRoleGet: "Rollen ophalen",
|
||||
sysRoleCreate: "Rollen aanmaken",
|
||||
sysRoleUpdate: "Rollen bijwerken",
|
||||
sysRoleDestroy: "Rollen verwijderen",
|
||||
sysRoleQuery: "Rollen opvragen",
|
||||
sysSearchGet: "Zoekinstellingen ophalen",
|
||||
sysSearchUpdate: "Zoekinstellingen bijwerken",
|
||||
sysSearchGet: "Zoek instellingen ophalen",
|
||||
sysSearchUpdate: "Zoek instellingen bijwerken",
|
||||
sysSearchStoreGet: "Instellingen voor zoekopslag ophalen",
|
||||
sysSearchStoreUpdate: "Instellingen voor zoekopslag bijwerken",
|
||||
sysSecurityGet: "Beveiligingsinstellingen ophalen",
|
||||
sysSecurityUpdate: "Beveiligingsinstellingen bijwerken",
|
||||
sysSecurityGet: "Beveiliging instellingen ophalen",
|
||||
sysSecurityUpdate: "Beveiliging instellingen bijwerken",
|
||||
sysSenderAuthGet: "Instellingen voor afzenderauthenticatie ophalen",
|
||||
sysSenderAuthUpdate: "Instellingen voor afzenderauthenticatie bijwerken",
|
||||
sysSharingGet: "Instellingen voor delen ophalen",
|
||||
@@ -634,17 +643,17 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysSpamFileExtensionUpdate: "Extensies bijwerken",
|
||||
sysSpamFileExtensionDestroy: "Extensies verwijderen",
|
||||
sysSpamFileExtensionQuery: "Extensies opvragen",
|
||||
sysSpamLlmGet: "Spam-LLM-instellingen ophalen",
|
||||
sysSpamLlmUpdate: "Spam-LLM-instellingen bijwerken",
|
||||
sysSpamPyzorGet: "Spam-Pyzor-instellingen ophalen",
|
||||
sysSpamPyzorUpdate: "Spam-Pyzor-instellingen bijwerken",
|
||||
sysSpamLlmGet: "Spam-LLM instellingen ophalen",
|
||||
sysSpamLlmUpdate: "Spam-LLM instellingen bijwerken",
|
||||
sysSpamPyzorGet: "Spam-Pyzor instellingen ophalen",
|
||||
sysSpamPyzorUpdate: "Spam-Pyzor instellingen bijwerken",
|
||||
sysSpamRuleGet: "Regels ophalen",
|
||||
sysSpamRuleCreate: "Regels aanmaken",
|
||||
sysSpamRuleUpdate: "Regels bijwerken",
|
||||
sysSpamRuleDestroy: "Regels verwijderen",
|
||||
sysSpamRuleQuery: "Regels opvragen",
|
||||
sysSpamSettingsGet: "Spaminstellingen ophalen",
|
||||
sysSpamSettingsUpdate: "Spaminstellingen bijwerken",
|
||||
sysSpamSettingsGet: "Spam instellingen ophalen",
|
||||
sysSpamSettingsUpdate: "Spam instellingen bijwerken",
|
||||
sysSpamTagGet: "Tags ophalen",
|
||||
sysSpamTagCreate: "Tags aanmaken",
|
||||
sysSpamTagUpdate: "Tags bijwerken",
|
||||
@@ -655,15 +664,15 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysSpamTrainingSampleUpdate: "Voorbeelden bijwerken",
|
||||
sysSpamTrainingSampleDestroy: "Voorbeelden verwijderen",
|
||||
sysSpamTrainingSampleQuery: "Voorbeelden opvragen",
|
||||
sysSpfReportSettingsGet: "SPF-rapportinstellingen ophalen",
|
||||
sysSpfReportSettingsUpdate: "SPF-rapportinstellingen bijwerken",
|
||||
sysSpfReportSettingsGet: "SPF-rapport instellingen ophalen",
|
||||
sysSpfReportSettingsUpdate: "SPF-rapport instellingen bijwerken",
|
||||
sysStoreLookupGet: "Lookups ophalen",
|
||||
sysStoreLookupCreate: "Lookups aanmaken",
|
||||
sysStoreLookupUpdate: "Lookups bijwerken",
|
||||
sysStoreLookupDestroy: "Lookups verwijderen",
|
||||
sysStoreLookupQuery: "Lookups opvragen",
|
||||
sysSystemSettingsGet: "Systeeminstellingen ophalen",
|
||||
sysSystemSettingsUpdate: "Systeeminstellingen bijwerken",
|
||||
sysSystemSettingsGet: "Systeem instellingen ophalen",
|
||||
sysSystemSettingsUpdate: "Systeem instellingen bijwerken",
|
||||
taskIndexDocument: "Document indexeren",
|
||||
taskUnindexDocument: "Document uit de index verwijderen",
|
||||
taskIndexTrace: "Telemetrietrace indexeren",
|
||||
@@ -704,8 +713,8 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysTlsInternalReportUpdate: "Rapporten bijwerken",
|
||||
sysTlsInternalReportDestroy: "Rapporten verwijderen",
|
||||
sysTlsInternalReportQuery: "Rapporten opvragen",
|
||||
sysTlsReportSettingsGet: "TLS-rapportinstellingen ophalen",
|
||||
sysTlsReportSettingsUpdate: "TLS-rapportinstellingen bijwerken",
|
||||
sysTlsReportSettingsGet: "TLS-rapport instellingen ophalen",
|
||||
sysTlsReportSettingsUpdate: "TLS-rapport instellingen bijwerken",
|
||||
sysTraceGet: "Traces ophalen",
|
||||
sysTraceCreate: "Traces aanmaken",
|
||||
sysTraceUpdate: "Traces bijwerken",
|
||||
@@ -718,8 +727,8 @@ export const permissionCatalog: PermissionCatalog = {
|
||||
sysTracerQuery: "Tracers opvragen",
|
||||
sysTracingStoreGet: "Instellingen voor tracing-opslag ophalen",
|
||||
sysTracingStoreUpdate: "Instellingen voor tracing-opslag bijwerken",
|
||||
sysWebDavGet: "WebDAV-instellingen ophalen",
|
||||
sysWebDavUpdate: "WebDAV-instellingen bijwerken",
|
||||
sysWebDavGet: "WebDAV instellingen ophalen",
|
||||
sysWebDavUpdate: "WebDAV instellingen bijwerken",
|
||||
sysWebHookGet: "Webhooks ophalen",
|
||||
sysWebHookCreate: "Webhooks aanmaken",
|
||||
sysWebHookUpdate: "Webhooks bijwerken",
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
import type { PermissionCatalog } from "@/lib/permissionLabels";
|
||||
|
||||
/** Stalwart 0.16.22's permission labels in Turkish, keyed by permission name. Checked against source.json. */
|
||||
export const permissionCatalog: PermissionCatalog = {
|
||||
categories: {
|
||||
"AI models Management": "Yapay Zeka Modelleri Yönetimi",
|
||||
"API keys Management": "API Anahtarları Yönetimi",
|
||||
"Accounts Management": "Hesap Yönetimi",
|
||||
"Action": "Eylem",
|
||||
"Actions Management": "Eylem Yönetimi",
|
||||
"Addresses Management": "Adres Yönetimi",
|
||||
"Alerts Management": "Uyarı Yönetimi",
|
||||
"App passwords Management": "Uygulama Şifreleri Yönetimi",
|
||||
"Applications Management": "Uygulama Yönetimi",
|
||||
"Archived items Management": "Arşivlenmiş Ögeler Yönetimi",
|
||||
"Calendar": "Takvim",
|
||||
"Certificates Management": "Sertifika Yönetimi",
|
||||
"Cluster nodes Management": "Küme Düğümleri Yönetimi",
|
||||
"DNS servers Management": "DNS Sunucuları Yönetimi",
|
||||
"Directories Management": "Dizin Yönetimi",
|
||||
"Domains Management": "Alan Adı Yönetimi",
|
||||
"Email": "E-posta",
|
||||
"Events Management": "Etkinlik Yönetimi",
|
||||
"Extensions Management": "Uzantı Yönetimi",
|
||||
"Hooks Management": "Kanca (Hook) Yönetimi",
|
||||
"IMAP": "IMAP",
|
||||
"JMAP": "JMAP",
|
||||
"Keys Management": "Anahtar Yönetimi",
|
||||
"Listeners Management": "Dinleyici (Listener) Yönetimi",
|
||||
"Lists Management": "Liste Yönetimi",
|
||||
"Log entries Management": "Günlük Kayıtları Yönetimi",
|
||||
"Lookups Management": "Arama (Lookup) Yönetimi",
|
||||
"Mailing lists Management": "E-posta Listeleri Yönetimi",
|
||||
"ManageSieve": "ManageSieve",
|
||||
"Masked emails Management": "Maskelenmiş E-posta Yönetimi",
|
||||
"Messages Management": "İleti Yönetimi",
|
||||
"Metrics Management": "Metrik Yönetimi",
|
||||
"Milters Management": "Milter Yönetimi",
|
||||
"OAuth clients Management": "OAuth İstemcileri Yönetimi",
|
||||
"POP3": "POP3",
|
||||
"Providers Management": "Sağlayıcı Yönetimi",
|
||||
"Public keys Management": "Genel Anahtar Yönetimi",
|
||||
"Queues Management": "Kuyruk Yönetimi",
|
||||
"Quotas Management": "Kota Yönetimi",
|
||||
"Reports Management": "Rapor Yönetimi",
|
||||
"Roles Management": "Rol Yönetimi",
|
||||
"Routes Management": "Rota Yönetimi",
|
||||
"Rules Management": "Kural Yönetimi",
|
||||
"Samples Management": "Örnek Yönetimi",
|
||||
"Schedules Management": "Zamanlama Yönetimi",
|
||||
"Scripts Management": "Betik Yönetimi",
|
||||
"Servers Management": "Sunucu Yönetimi",
|
||||
"Settings": "Ayarlar",
|
||||
"Signatures Management": "İmza Yönetimi",
|
||||
"Strategies Management": "Strateji Yönetimi",
|
||||
"Tags Management": "Etiket Yönetimi",
|
||||
"Task": "Görev",
|
||||
"Tasks Management": "Görev Yönetimi",
|
||||
"Tenants Management": "Kiracı (Tenant) Yönetimi",
|
||||
"Throttles Management": "Hız Sınırlama (Throttle) Yönetimi",
|
||||
"Tracers Management": "İzleyici (Tracer) Yönetimi",
|
||||
"Traces Management": "İzleme Kayıtları (Trace) Yönetimi",
|
||||
"WebDAV": "WebDAV",
|
||||
"Webhooks Management": "Webhook Yönetimi",
|
||||
},
|
||||
labels: {
|
||||
authenticate: "Sunucuya giriş yap",
|
||||
authenticateWithAlias: "E-posta takma adları kullanarak giriş yap",
|
||||
interactAi: "Yapay zeka modelleriyle etkileşim kur",
|
||||
impersonate: "Başka bir kullanıcı adına işlem yap",
|
||||
unlimitedRequests: "İstek hız sınırlarını atla",
|
||||
unlimitedUploads: "Yükleme boyutu ve hız sınırlarını atla",
|
||||
fetchAnyBlob: "Hesaba ait olmayanlar dahil herhangi bir blob nesnesini al",
|
||||
emailSend: "E-postaları gönder",
|
||||
emailReceive: "E-postaları al",
|
||||
calendarAlarmsSend: "Takvim anımsatıcı bildirimlerini e-posta ile gönder",
|
||||
calendarSchedulingSend: "Takvim davetlerini ve güncellemelerini e-posta ile gönder",
|
||||
calendarSchedulingReceive: "Gelen takvim davetlerini ve yanıtlarını işle",
|
||||
jmapPushSubscriptionGet: "Push aboneliğini ayrıntılarını al",
|
||||
jmapPushSubscriptionCreate: "Yeni push aboneliklerini oluştur",
|
||||
jmapPushSubscriptionUpdate: "Mevcut push aboneliklerini düzenle",
|
||||
jmapPushSubscriptionDestroy: "Push aboneliklerini kaldır",
|
||||
jmapMailboxGet: "Posta kutusunu ayrıntılarını ve özelliklerini al",
|
||||
jmapMailboxChanges: "Belirli bir durumdan bu yana posta kutularını üzerindeki değişiklikleri izle",
|
||||
jmapMailboxQuery: "Kriterlerle eşleşen posta kutularını ara",
|
||||
jmapMailboxQueryChanges: "Posta kutusunu sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapMailboxCreate: "Yeni posta kutularını oluştur",
|
||||
jmapMailboxUpdate: "Mevcut posta kutularını düzenle",
|
||||
jmapMailboxDestroy: "Posta kutularını kaldır",
|
||||
jmapThreadGet: "Email thread ayrıntılarını al",
|
||||
jmapThreadChanges: "Belirli bir durumdan bu yana yazışmaları üzerindeki değişiklikleri izle",
|
||||
jmapEmailGet: "E-postayı ayrıntılarını ve özelliklerini al",
|
||||
jmapEmailChanges: "Belirli bir durumdan bu yana e-postaları üzerindeki değişiklikleri izle",
|
||||
jmapEmailQuery: "Kriterlerle eşleşen e-postaları ara",
|
||||
jmapEmailQueryChanges: "E-postayı sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapEmailCreate: "Yeni e-postaları oluştur",
|
||||
jmapEmailUpdate: "Mevcut e-postaları düzenle",
|
||||
jmapEmailDestroy: "E-postaları kaldır",
|
||||
jmapEmailCopy: "E-postaları başka bir hesaba kopyala",
|
||||
jmapEmailImport: "Blob verilerinden e-postaları içe aktar",
|
||||
jmapEmailParse: "Ham e-posta verilerini kaydetmeden ayrıştır",
|
||||
jmapSearchSnippetGet: "E-postalar için arama sonucu pasajlarını al",
|
||||
jmapIdentityGet: "Gönderen kimliğini ayrıntılarını al",
|
||||
jmapIdentityChanges: "Belirli bir durumdan bu yana kimlikleri üzerindeki değişiklikleri izle",
|
||||
jmapIdentityCreate: "Yeni gönderen kimliklerini oluştur",
|
||||
jmapIdentityUpdate: "Mevcut gönderen kimliklerini düzenle",
|
||||
jmapIdentityDestroy: "Gönderen kimliklerini kaldır",
|
||||
jmapEmailSubmissionGet: "E-posta gönderimini ayrıntılarını al",
|
||||
jmapEmailSubmissionChanges: "Belirli bir durumdan bu yana e-posta gönderimlerini üzerindeki değişiklikleri izle",
|
||||
jmapEmailSubmissionQuery: "Kriterlerle eşleşen e-posta gönderimlerini ara",
|
||||
jmapEmailSubmissionQueryChanges: "E-posta gönderimini sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapEmailSubmissionCreate: "Yeni email submissions for sending oluştur",
|
||||
jmapEmailSubmissionUpdate: "Mevcut e-posta gönderimlerini düzenle",
|
||||
jmapEmailSubmissionDestroy: "E-posta gönderimlerini kaldır",
|
||||
jmapVacationResponseGet: "Tatil yanıtı yapılandırmasını al",
|
||||
jmapVacationResponseCreate: "Tatil yanıtı ayarlarını oluştur",
|
||||
jmapVacationResponseUpdate: "Tatil / otomatik yanıt ayarlarını düzenle",
|
||||
jmapVacationResponseDestroy: "Tatil yanıtı ayarlarını kaldır",
|
||||
jmapSieveScriptGet: "Sieve filtre betiğini ayrıntılarını al",
|
||||
jmapSieveScriptQuery: "Kriterlerle eşleşen Sieve betiklerini ara",
|
||||
jmapSieveScriptValidate: "Bir Sieve betiğini kaydetmeden doğrula",
|
||||
jmapSieveScriptCreate: "Yeni Sieve filtre betiklerini oluştur",
|
||||
jmapSieveScriptUpdate: "Mevcut Sieve filtre betiklerini düzenle",
|
||||
jmapSieveScriptDestroy: "Sieve filtre betiklerini kaldır",
|
||||
jmapPrincipalGet: "Kullanıcıyı/grubu (principal) ayrıntılarını ve özelliklerini al",
|
||||
jmapPrincipalQuery: "Kriterlerle eşleşen kullanıcıları/grupları (principal) ara",
|
||||
jmapPrincipalChanges: "Belirli bir durumdan bu yana kullanıcıları/grupları (principal) üzerindeki değişiklikleri izle",
|
||||
jmapPrincipalQueryChanges: "Kullanıcıyı/grubu (principal) sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapPrincipalGetAvailability: "Availability information for principals sorgula",
|
||||
jmapPrincipalCreate: "Yeni kullanıcıları/grupları (principal) oluştur",
|
||||
jmapPrincipalUpdate: "Mevcut kullanıcıları/grupları (principal) düzenle",
|
||||
jmapPrincipalDestroy: "Kullanıcıları/grupları (principal) kaldır",
|
||||
jmapQuotaGet: "Kota kullanımını ve sınırlarını al",
|
||||
jmapQuotaChanges: "Belirli bir durumdan bu yana kotaları üzerindeki değişiklikleri izle",
|
||||
jmapQuotaQuery: "Kriterlerle eşleşen kotaları ara",
|
||||
jmapQuotaQueryChanges: "Kotayı sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapBlobGet: "Blob data indir",
|
||||
jmapBlobCopy: "Blob nesnelerini başka bir hesaba kopyala",
|
||||
jmapBlobLookup: "Veri türleri genelinde blob kullanımını sorgula",
|
||||
jmapBlobUpload: "New blob data yükle",
|
||||
jmapAddressBookGet: "Adres defterini ayrıntılarını ve özelliklerini al",
|
||||
jmapAddressBookChanges: "Belirli bir durumdan bu yana adres defterlerini üzerindeki değişiklikleri izle",
|
||||
jmapAddressBookCreate: "Yeni adres defterlerini oluştur",
|
||||
jmapAddressBookUpdate: "Mevcut adres defterlerini düzenle",
|
||||
jmapAddressBookDestroy: "Adres defterlerini kaldır",
|
||||
jmapContactCardGet: "Kişi kartını ayrıntılarını al",
|
||||
jmapContactCardChanges: "Belirli bir durumdan bu yana kişi kartlarını üzerindeki değişiklikleri izle",
|
||||
jmapContactCardQuery: "Kriterlerle eşleşen kişi kartlarını ara",
|
||||
jmapContactCardQueryChanges: "Kişi kartını sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapContactCardCreate: "Yeni kişi kartlarını oluştur",
|
||||
jmapContactCardUpdate: "Mevcut kişi kartlarını düzenle",
|
||||
jmapContactCardDestroy: "Kişi kartlarını kaldır",
|
||||
jmapContactCardCopy: "Kişi kartlarını başka bir hesaba kopyala",
|
||||
jmapContactCardParse: "Ham kişi kartı verilerini kaydetmeden ayrıştır",
|
||||
jmapFileNodeGet: "Dosya düğümünü ayrıntılarını ve özelliklerini al",
|
||||
jmapFileNodeChanges: "Belirli bir durumdan bu yana dosya düğümlerini üzerindeki değişiklikleri izle",
|
||||
jmapFileNodeQuery: "Kriterlerle eşleşen dosya düğümlerini ara",
|
||||
jmapFileNodeQueryChanges: "Dosya düğümünü sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapFileNodeCreate: "Yeni dosya düğümlerini oluştur",
|
||||
jmapFileNodeUpdate: "Mevcut dosya düğümlerini düzenle",
|
||||
jmapFileNodeDestroy: "Dosya düğümlerini kaldır",
|
||||
jmapFileNodeCopy: "Dosya düğümlerini başka bir hesaba kopyala",
|
||||
jmapShareNotificationGet: "Paylaşım bildirimini ayrıntılarını al",
|
||||
jmapShareNotificationChanges: "Belirli bir durumdan bu yana paylaşım bildirimlerini üzerindeki değişiklikleri izle",
|
||||
jmapShareNotificationQuery: "Kriterlerle eşleşen paylaşım bildirimlerini ara",
|
||||
jmapShareNotificationQueryChanges: "Paylaşım bildirimini sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapShareNotificationCreate: "Yeni paylaşım bildirimlerini oluştur",
|
||||
jmapShareNotificationUpdate: "Mevcut paylaşım bildirimlerini düzenle",
|
||||
jmapShareNotificationDestroy: "Paylaşım bildirimlerini kaldır",
|
||||
jmapCalendarGet: "Takvimi ayrıntılarını ve özelliklerini al",
|
||||
jmapCalendarChanges: "Belirli bir durumdan bu yana takvimleri üzerindeki değişiklikleri izle",
|
||||
jmapCalendarCreate: "Yeni takvimleri oluştur",
|
||||
jmapCalendarUpdate: "Mevcut takvimleri düzenle",
|
||||
jmapCalendarDestroy: "Takvimleri kaldır",
|
||||
jmapCalendarEventGet: "Takvim etkinliğini ayrıntılarını al",
|
||||
jmapCalendarEventChanges: "Belirli bir durumdan bu yana takvim etkinliklerini üzerindeki değişiklikleri izle",
|
||||
jmapCalendarEventQuery: "Kriterlerle eşleşen takvim etkinliklerini ara",
|
||||
jmapCalendarEventQueryChanges: "Takvim etkinliğini sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapCalendarEventCreate: "Yeni takvim etkinliklerini oluştur",
|
||||
jmapCalendarEventUpdate: "Mevcut takvim etkinliklerini düzenle",
|
||||
jmapCalendarEventDestroy: "Takvim etkinliklerini kaldır",
|
||||
jmapCalendarEventCopy: "Takvim etkinliklerini başka bir hesaba kopyala",
|
||||
jmapCalendarEventParse: "Ham takvim etkinliği verilerini kaydetmeden ayrıştır",
|
||||
jmapCalendarEventNotificationGet: "Takvim etkinliği bildirimini ayrıntılarını al",
|
||||
jmapCalendarEventNotificationChanges: "Belirli bir durumdan bu yana takvim etkinliği bildirimlerini üzerindeki değişiklikleri izle",
|
||||
jmapCalendarEventNotificationQuery: "Kriterlerle eşleşen takvim etkinliği bildirimlerini ara",
|
||||
jmapCalendarEventNotificationQueryChanges: "Takvim etkinliği bildirimini sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapCalendarEventNotificationCreate: "Yeni takvim etkinliği bildirimlerini oluştur",
|
||||
jmapCalendarEventNotificationUpdate: "Mevcut takvim etkinliği bildirimlerini düzenle",
|
||||
jmapCalendarEventNotificationDestroy: "Takvim etkinliği bildirimlerini kaldır",
|
||||
jmapParticipantIdentityGet: "Participant identity ayrıntılarını al",
|
||||
jmapParticipantIdentityChanges: "Belirli bir durumdan bu yana participant identities üzerindeki değişiklikleri izle",
|
||||
jmapParticipantIdentityCreate: "Yeni participant identities oluştur",
|
||||
jmapParticipantIdentityUpdate: "Mevcut participant identities düzenle",
|
||||
jmapParticipantIdentityDestroy: "Participant identities kaldır",
|
||||
jmapCoreEcho: "Test amacıyla giriş verisini geri yankıla (echo)",
|
||||
imapAuthenticate: "IMAP sunucusuna giriş yap",
|
||||
imapAclGet: "Posta kutusu erişim kontrol listelerini (ACL) görüntüle",
|
||||
imapAclSet: "Posta kutusu erişim kontrol listelerini (ACL) düzenle",
|
||||
imapMyRights: "Bir posta kutusundaki kendi yetkilerini görüntüle",
|
||||
imapListRights: "Bir posta kutusu için kullanılabilir yetki bayraklarını listele",
|
||||
imapAppend: "Messages to a mailbox yükle",
|
||||
imapCapability: "Supported imap extensions sorgula",
|
||||
imapId: "Server identification and version sorgula",
|
||||
imapCopy: "Posta kutuları arasında iletileri kopyala",
|
||||
imapMove: "Posta kutuları arasında iletileri taşı",
|
||||
imapCreate: "Yeni posta kutularını oluştur",
|
||||
imapDelete: "Mailboxes or mark messages for deletion sil",
|
||||
imapEnable: "İsteğe bağlı IMAP uzantılarını etkinleştir",
|
||||
imapExpunge: "Silinmek üzere işaretlenmiş iletileri kalıcı olarak kaldır (expunge)",
|
||||
imapFetch: "Message content and metadata indir",
|
||||
imapIdle: "Posta kutusu değişiklikleri için gerçek zamanlı bildirimler al (idle)",
|
||||
imapList: "Kullanılabilir posta kutularını ve özniteliklerini listele",
|
||||
imapLsub: "Kullanıcının abone olduğu posta kutularını listele",
|
||||
imapNamespace: "Available mailbox namespace prefixes sorgula",
|
||||
imapRename: "Posta kutularını yeniden adlandır veya taşı",
|
||||
imapSearch: "Kriterlere göre iletileri ara",
|
||||
imapSort: "İletileri belirli bir sıralama düzeninde al",
|
||||
imapSelect: "Bir posta kutusunu okuma-yazma erişimi için aç",
|
||||
imapExamine: "Bir posta kutusunu salt okunur erişim için aç",
|
||||
imapStatus: "Mailbox message counts and state sorgula",
|
||||
imapStore: "İleti bayraklarını ayarla veya temizle (okundu, yıldızlı, silindi vb.)",
|
||||
imapSubscribe: "Posta kutularına abone ol veya abonelikten çık",
|
||||
imapThread: "İletileri yazışma dizileri halinde grupla",
|
||||
pop3Authenticate: "POP3 sunucusuna giriş yap",
|
||||
pop3List: "İletileri ve boyutlarını listele",
|
||||
pop3Uidl: "Benzersiz ileti tanımlayıcılarını al (UIDL)",
|
||||
pop3Stat: "Mailbox message count and total size sorgula",
|
||||
pop3Retr: "Message content indir",
|
||||
pop3Dele: "Bağlantı kesildiğinde silinmek üzere iletileri işaretle",
|
||||
sieveAuthenticate: "ManageSieve sunucusuna giriş yap",
|
||||
sieveListScripts: "Yüklenen Sieve betiklerini listele",
|
||||
sieveSetActive: "Hangi Sieve betiğinin aktif olduğunu belirle",
|
||||
sieveGetScript: "Sieve script content indir",
|
||||
sievePutScript: "Or replace a sieve script yükle",
|
||||
sieveDeleteScript: "A sieve script kaldır",
|
||||
sieveRenameScript: "Bir Sieve betiğini yeniden adlandır",
|
||||
sieveCheckScript: "Sieve betiği sözdizimini kaydetmeden doğrula",
|
||||
sieveHaveSpace: "Kotanın belirli boyuttaki bir betiği yüklemeye izin verip vermediğini denetle",
|
||||
davSyncCollection: "Koleksiyon değişikliklerini istemciyle eşitle",
|
||||
davExpandProperty: "Diğer kaynaklara başvuran özellikleri genişlet",
|
||||
davPrincipalAcl: "Kullanıcılar/gruplar (principal) üzerindeki erişim kontrol özelliklerini yönet",
|
||||
davPrincipalList: "Sistemdeki kullanılabilir kullanıcıları/grupları (principal) listele",
|
||||
davPrincipalMatch: "Belirtilen kriterlere göre kullanıcıları/grupları (principal) eşleştir",
|
||||
davPrincipalSearch: "Özellik değerlerine göre kullanıcıları/grupları (principal) ara",
|
||||
davPrincipalSearchPropSet: "Which properties can be searched on principals sorgula",
|
||||
davFilePropFind: "Dosya kaynaklarının özelliklerini al",
|
||||
davFilePropPatch: "Dosya kaynaklarının özelliklerini düzenle",
|
||||
davFileGet: "File resources indir",
|
||||
davFileMkCol: "Yeni file collections or directories oluştur",
|
||||
davFileDelete: "File resources kaldır",
|
||||
davFilePut: "Or modify file resources yükle",
|
||||
davFileCopy: "Dosya kaynaklarını yeni konumlara kopyala",
|
||||
davFileMove: "Dosya kaynaklarını yeni konumlara taşı",
|
||||
davFileLock: "Eşzamanlı değişiklikleri önlemek için dosya kaynaklarını kilitle",
|
||||
davFileAcl: "Dosya kaynakları için erişim kontrol listelerini (ACL) yönet",
|
||||
davCardPropFind: "Adres defteri kayıtlarının özelliklerini al",
|
||||
davCardPropPatch: "Adres defteri kayıtlarının özelliklerini düzenle",
|
||||
davCardGet: "Address book entries indir",
|
||||
davCardMkCol: "Yeni address book collections oluştur",
|
||||
davCardDelete: "Address book entries or collections kaldır",
|
||||
davCardPut: "Or modify address book entries yükle",
|
||||
davCardCopy: "Adres defteri kayıtlarını yeni konumlara kopyala",
|
||||
davCardMove: "Adres defteri kayıtlarını yeni konumlara taşı",
|
||||
davCardLock: "Eşzamanlı değişiklikleri önlemek için adres defteri kayıtlarını kilitle",
|
||||
davCardAcl: "Adres defteri kayıtları için erişim kontrol listelerini (ACL) yönet",
|
||||
davCardQuery: "Kriterlerle eşleşen address book entries ara",
|
||||
davCardMultiGet: "Tek bir istekte birden çok adres defteri kaydı al",
|
||||
davCalPropFind: "Takvim kayıtlarının özelliklerini al",
|
||||
davCalPropPatch: "Takvim kayıtlarının özelliklerini düzenle",
|
||||
davCalGet: "Calendar entries indir",
|
||||
davCalMkCol: "Yeni calendar collections oluştur",
|
||||
davCalDelete: "Calendar entries or collections kaldır",
|
||||
davCalPut: "Or modify calendar entries yükle",
|
||||
davCalCopy: "Takvim kayıtlarını yeni konumlara kopyala",
|
||||
davCalMove: "Takvim kayıtlarını yeni konumlara taşı",
|
||||
davCalLock: "Eşzamanlı değişiklikleri önlemek için takvim kayıtlarını kilitle",
|
||||
davCalAcl: "Takvim kayıtları için erişim kontrol listelerini (ACL) yönet",
|
||||
davCalQuery: "Kriterlerle eşleşen calendar entries ara",
|
||||
davCalMultiGet: "Tek bir istekte birden çok takvim kaydı al",
|
||||
davCalFreeBusyQuery: "Free/busy time information for scheduling sorgula",
|
||||
oAuthClientRegistration: "Yeni OAuth istemci uygulamaları kaydet",
|
||||
oAuthClientOverride: "OAuth istemci kayıt gereksinimlerini atla",
|
||||
liveTracing: "Gerçek zamanlı sunucu etkinlik izlerini (trace) görüntüle",
|
||||
liveMetrics: "Gerçek zamanlı sunucu performans metriklerini görüntüle",
|
||||
liveDeliveryTest: "Teslimat yapılandırmasını doğrulamak için test e-postaları gönder",
|
||||
scimAccess: "SCIM uç noktası üzerinden kullanıcı ve grup sağla (provision)",
|
||||
sysAccountGet: "Hesapları getir",
|
||||
sysAccountCreate: "Hesapları oluştur",
|
||||
sysAccountUpdate: "Hesapları güncelle",
|
||||
sysAccountDestroy: "Hesapları sil",
|
||||
sysAccountQuery: "Hesapları sorgula",
|
||||
sysAccountPasswordGet: "Account password settings getir",
|
||||
sysAccountPasswordUpdate: "Account password settings güncelle",
|
||||
sysAccountSettingsGet: "Account settings settings getir",
|
||||
sysAccountSettingsUpdate: "Account settings settings güncelle",
|
||||
sysAcmeProviderGet: "Sağlayıcıları getir",
|
||||
sysAcmeProviderCreate: "Sağlayıcıları oluştur",
|
||||
sysAcmeProviderUpdate: "Sağlayıcıları güncelle",
|
||||
sysAcmeProviderDestroy: "Sağlayıcıları sil",
|
||||
sysAcmeProviderQuery: "Sağlayıcıları sorgula",
|
||||
actionReloadSettings: "Yeniden yükle: Sunucu ayarları",
|
||||
actionReloadTlsCertificates: "Yeniden yükle: TLS sertifikaları",
|
||||
actionReloadLookupStores: "Yeniden yükle: Arama (lookup) depoları",
|
||||
actionReloadBlockedIps: "Yeniden yükle: Engellenen IP listesi",
|
||||
actionUpdateApps: "Uygulama Yönetimi: Uygulamaları güncelle",
|
||||
actionTroubleshootDmarc: "DMARC: Sorun giderme",
|
||||
actionClassifySpam: "Spam Filtresi: Bir iletiyi sınıflandır",
|
||||
actionInvalidateCaches: "Önbellek: Tüm önbellekleri geçersiz kıl",
|
||||
actionInvalidateNegativeCaches: "Önbellek: Negatif önbellekleri geçersiz kıl",
|
||||
actionPauseMtaQueue: "MTA: Kuyruk işlemeyi duraklat",
|
||||
actionResumeMtaQueue: "MTA: Kuyruk işlemeyi sürdür",
|
||||
sysActionGet: "Eylemleri getir",
|
||||
sysActionCreate: "Eylemleri oluştur",
|
||||
sysActionUpdate: "Eylemleri güncelle",
|
||||
sysActionDestroy: "Eylemleri sil",
|
||||
sysActionQuery: "Eylemleri sorgula",
|
||||
sysAddressBookGet: "Address book settings getir",
|
||||
sysAddressBookUpdate: "Address book settings güncelle",
|
||||
sysAiModelGet: "Yapay zeka modellerini getir",
|
||||
sysAiModelCreate: "Yapay zeka modellerini oluştur",
|
||||
sysAiModelUpdate: "Yapay zeka modellerini güncelle",
|
||||
sysAiModelDestroy: "Yapay zeka modellerini sil",
|
||||
sysAiModelQuery: "Yapay zeka modellerini sorgula",
|
||||
sysAlertGet: "Uyarıları getir",
|
||||
sysAlertCreate: "Uyarıları oluştur",
|
||||
sysAlertUpdate: "Uyarıları güncelle",
|
||||
sysAlertDestroy: "Uyarıları sil",
|
||||
sysAlertQuery: "Uyarıları sorgula",
|
||||
sysAllowedIpGet: "Adresleri getir",
|
||||
sysAllowedIpCreate: "Adresleri oluştur",
|
||||
sysAllowedIpUpdate: "Adresleri güncelle",
|
||||
sysAllowedIpDestroy: "Adresleri sil",
|
||||
sysAllowedIpQuery: "Adresleri sorgula",
|
||||
sysApiKeyGet: "Api anahtarlarını getir",
|
||||
sysApiKeyCreate: "Api anahtarlarını oluştur",
|
||||
sysApiKeyUpdate: "Api anahtarlarını güncelle",
|
||||
sysApiKeyDestroy: "Api anahtarlarını sil",
|
||||
sysApiKeyQuery: "Api anahtarlarını sorgula",
|
||||
sysAppPasswordGet: "Uygulama şifrelerini getir",
|
||||
sysAppPasswordCreate: "Uygulama şifrelerini oluştur",
|
||||
sysAppPasswordUpdate: "Uygulama şifrelerini güncelle",
|
||||
sysAppPasswordDestroy: "Uygulama şifrelerini sil",
|
||||
sysAppPasswordQuery: "Uygulama şifrelerini sorgula",
|
||||
sysApplicationGet: "Uygulamaları getir",
|
||||
sysApplicationCreate: "Uygulamaları oluştur",
|
||||
sysApplicationUpdate: "Uygulamaları güncelle",
|
||||
sysApplicationDestroy: "Uygulamaları sil",
|
||||
sysApplicationQuery: "Uygulamaları sorgula",
|
||||
sysArchivedItemGet: "Arşivlenmiş ögeleri getir",
|
||||
sysArchivedItemCreate: "Arşivlenmiş ögeleri oluştur",
|
||||
sysArchivedItemUpdate: "Arşivlenmiş ögeleri güncelle",
|
||||
sysArchivedItemDestroy: "Arşivlenmiş ögeleri sil",
|
||||
sysArchivedItemQuery: "Arşivlenmiş ögeleri sorgula",
|
||||
sysArfExternalReportGet: "Raporları getir",
|
||||
sysArfExternalReportCreate: "Raporları oluştur",
|
||||
sysArfExternalReportUpdate: "Raporları güncelle",
|
||||
sysArfExternalReportDestroy: "Raporları sil",
|
||||
sysArfExternalReportQuery: "Raporları sorgula",
|
||||
sysAsnGet: "Asn settings getir",
|
||||
sysAsnUpdate: "Asn settings güncelle",
|
||||
sysAuthenticationGet: "Authentication settings getir",
|
||||
sysAuthenticationUpdate: "Authentication settings güncelle",
|
||||
sysBlobStoreGet: "Blob store settings getir",
|
||||
sysBlobStoreUpdate: "Blob store settings güncelle",
|
||||
sysBlockedIpGet: "Adresleri getir",
|
||||
sysBlockedIpCreate: "Adresleri oluştur",
|
||||
sysBlockedIpUpdate: "Adresleri güncelle",
|
||||
sysBlockedIpDestroy: "Adresleri sil",
|
||||
sysBlockedIpQuery: "Adresleri sorgula",
|
||||
sysBootstrapGet: "Bootstrap settings getir",
|
||||
sysBootstrapUpdate: "Bootstrap settings güncelle",
|
||||
sysCacheGet: "Cache settings getir",
|
||||
sysCacheUpdate: "Cache settings güncelle",
|
||||
sysCalendarGet: "Calendar settings getir",
|
||||
sysCalendarUpdate: "Calendar settings güncelle",
|
||||
sysCalendarAlarmGet: "Calendar alarm settings getir",
|
||||
sysCalendarAlarmUpdate: "Calendar alarm settings güncelle",
|
||||
sysCalendarSchedulingGet: "Calendar scheduling settings getir",
|
||||
sysCalendarSchedulingUpdate: "Calendar scheduling settings güncelle",
|
||||
sysCertificateGet: "Sertifikaları getir",
|
||||
sysCertificateCreate: "Sertifikaları oluştur",
|
||||
sysCertificateUpdate: "Sertifikaları güncelle",
|
||||
sysCertificateDestroy: "Sertifikaları sil",
|
||||
sysCertificateQuery: "Sertifikaları sorgula",
|
||||
sysClusterNodeGet: "Küme düğümlerini getir",
|
||||
sysClusterNodeCreate: "Küme düğümlerini oluştur",
|
||||
sysClusterNodeUpdate: "Küme düğümlerini güncelle",
|
||||
sysClusterNodeDestroy: "Küme düğümlerini sil",
|
||||
sysClusterNodeQuery: "Küme düğümlerini sorgula",
|
||||
sysClusterRoleGet: "Rolleri getir",
|
||||
sysClusterRoleCreate: "Rolleri oluştur",
|
||||
sysClusterRoleUpdate: "Rolleri güncelle",
|
||||
sysClusterRoleDestroy: "Rolleri sil",
|
||||
sysClusterRoleQuery: "Rolleri sorgula",
|
||||
sysCoordinatorGet: "Coordinator settings getir",
|
||||
sysCoordinatorUpdate: "Coordinator settings güncelle",
|
||||
sysDataRetentionGet: "Data retention settings getir",
|
||||
sysDataRetentionUpdate: "Data retention settings güncelle",
|
||||
sysDataStoreGet: "Data store settings getir",
|
||||
sysDataStoreUpdate: "Data store settings güncelle",
|
||||
sysDirectoryGet: "Dizinleri getir",
|
||||
sysDirectoryCreate: "Dizinleri oluştur",
|
||||
sysDirectoryUpdate: "Dizinleri güncelle",
|
||||
sysDirectoryDestroy: "Dizinleri sil",
|
||||
sysDirectoryQuery: "Dizinleri sorgula",
|
||||
sysDkimReportSettingsGet: "Dkim report settings settings getir",
|
||||
sysDkimReportSettingsUpdate: "Dkim report settings settings güncelle",
|
||||
sysDkimSignatureGet: "Imzaları getir",
|
||||
sysDkimSignatureCreate: "Imzaları oluştur",
|
||||
sysDkimSignatureUpdate: "Imzaları güncelle",
|
||||
sysDkimSignatureDestroy: "Imzaları sil",
|
||||
sysDkimSignatureQuery: "Imzaları sorgula",
|
||||
sysDmarcExternalReportGet: "Raporları getir",
|
||||
sysDmarcExternalReportCreate: "Raporları oluştur",
|
||||
sysDmarcExternalReportUpdate: "Raporları güncelle",
|
||||
sysDmarcExternalReportDestroy: "Raporları sil",
|
||||
sysDmarcExternalReportQuery: "Raporları sorgula",
|
||||
sysDmarcInternalReportGet: "Raporları getir",
|
||||
sysDmarcInternalReportCreate: "Raporları oluştur",
|
||||
sysDmarcInternalReportUpdate: "Raporları güncelle",
|
||||
sysDmarcInternalReportDestroy: "Raporları sil",
|
||||
sysDmarcInternalReportQuery: "Raporları sorgula",
|
||||
sysDmarcReportSettingsGet: "Dmarc report settings settings getir",
|
||||
sysDmarcReportSettingsUpdate: "Dmarc report settings settings güncelle",
|
||||
sysDnsResolverGet: "Dns resolver settings getir",
|
||||
sysDnsResolverUpdate: "Dns resolver settings güncelle",
|
||||
sysDnsServerGet: "Dns sunucularını getir",
|
||||
sysDnsServerCreate: "Dns sunucularını oluştur",
|
||||
sysDnsServerUpdate: "Dns sunucularını güncelle",
|
||||
sysDnsServerDestroy: "Dns sunucularını sil",
|
||||
sysDnsServerQuery: "Dns sunucularını sorgula",
|
||||
sysDomainGet: "Alan adlarını getir",
|
||||
sysDomainCreate: "Alan adlarını oluştur",
|
||||
sysDomainUpdate: "Alan adlarını güncelle",
|
||||
sysDomainDestroy: "Alan adlarını sil",
|
||||
sysDomainQuery: "Alan adlarını sorgula",
|
||||
sysDsnReportSettingsGet: "Dsn report settings settings getir",
|
||||
sysDsnReportSettingsUpdate: "Dsn report settings settings güncelle",
|
||||
sysEmailGet: "Email settings getir",
|
||||
sysEmailUpdate: "Email settings güncelle",
|
||||
sysEnterpriseGet: "Enterprise settings getir",
|
||||
sysEnterpriseUpdate: "Enterprise settings güncelle",
|
||||
sysEventTracingLevelGet: "Etkinlikleri getir",
|
||||
sysEventTracingLevelCreate: "Etkinlikleri oluştur",
|
||||
sysEventTracingLevelUpdate: "Etkinlikleri güncelle",
|
||||
sysEventTracingLevelDestroy: "Etkinlikleri sil",
|
||||
sysEventTracingLevelQuery: "Etkinlikleri sorgula",
|
||||
sysFileStorageGet: "File storage settings getir",
|
||||
sysFileStorageUpdate: "File storage settings güncelle",
|
||||
sysHttpGet: "Http settings getir",
|
||||
sysHttpUpdate: "Http settings güncelle",
|
||||
sysHttpFormGet: "Http form settings getir",
|
||||
sysHttpFormUpdate: "Http form settings güncelle",
|
||||
sysHttpLookupGet: "Listeleri getir",
|
||||
sysHttpLookupCreate: "Listeleri oluştur",
|
||||
sysHttpLookupUpdate: "Listeleri güncelle",
|
||||
sysHttpLookupDestroy: "Listeleri sil",
|
||||
sysHttpLookupQuery: "Listeleri sorgula",
|
||||
sysImapGet: "Imap settings getir",
|
||||
sysImapUpdate: "Imap settings güncelle",
|
||||
sysInMemoryStoreGet: "In memory store settings getir",
|
||||
sysInMemoryStoreUpdate: "In memory store settings güncelle",
|
||||
sysJmapGet: "Jmap settings getir",
|
||||
sysJmapUpdate: "Jmap settings güncelle",
|
||||
sysLogGet: "Günlük kayıtlarını getir",
|
||||
sysLogCreate: "Günlük kayıtlarını oluştur",
|
||||
sysLogUpdate: "Günlük kayıtlarını güncelle",
|
||||
sysLogDestroy: "Günlük kayıtlarını sil",
|
||||
sysLogQuery: "Günlük kayıtlarını sorgula",
|
||||
sysMailingListGet: "E-posta listelerini getir",
|
||||
sysMailingListCreate: "E-posta listelerini oluştur",
|
||||
sysMailingListUpdate: "E-posta listelerini güncelle",
|
||||
sysMailingListDestroy: "E-posta listelerini sil",
|
||||
sysMailingListQuery: "E-posta listelerini sorgula",
|
||||
sysMaskedEmailGet: "Maskelenmiş e-postaları getir",
|
||||
sysMaskedEmailCreate: "Maskelenmiş e-postaları oluştur",
|
||||
sysMaskedEmailUpdate: "Maskelenmiş e-postaları güncelle",
|
||||
sysMaskedEmailDestroy: "Maskelenmiş e-postaları sil",
|
||||
sysMaskedEmailQuery: "Maskelenmiş e-postaları sorgula",
|
||||
sysMemoryLookupKeyGet: "Anahtarları getir",
|
||||
sysMemoryLookupKeyCreate: "Anahtarları oluştur",
|
||||
sysMemoryLookupKeyUpdate: "Anahtarları güncelle",
|
||||
sysMemoryLookupKeyDestroy: "Anahtarları sil",
|
||||
sysMemoryLookupKeyQuery: "Anahtarları sorgula",
|
||||
sysMemoryLookupKeyValueGet: "Anahtarları getir",
|
||||
sysMemoryLookupKeyValueCreate: "Anahtarları oluştur",
|
||||
sysMemoryLookupKeyValueUpdate: "Anahtarları güncelle",
|
||||
sysMemoryLookupKeyValueDestroy: "Anahtarları sil",
|
||||
sysMemoryLookupKeyValueQuery: "Anahtarları sorgula",
|
||||
sysMetricGet: "Metrikleri getir",
|
||||
sysMetricCreate: "Metrikleri oluştur",
|
||||
sysMetricUpdate: "Metrikleri güncelle",
|
||||
sysMetricDestroy: "Metrikleri sil",
|
||||
sysMetricQuery: "Metrikleri sorgula",
|
||||
sysMetricsGet: "Metrics settings getir",
|
||||
sysMetricsUpdate: "Metrics settings güncelle",
|
||||
sysMetricsStoreGet: "Metrics store settings getir",
|
||||
sysMetricsStoreUpdate: "Metrics store settings güncelle",
|
||||
sysMtaConnectionStrategyGet: "Stratejileri getir",
|
||||
sysMtaConnectionStrategyCreate: "Stratejileri oluştur",
|
||||
sysMtaConnectionStrategyUpdate: "Stratejileri güncelle",
|
||||
sysMtaConnectionStrategyDestroy: "Stratejileri sil",
|
||||
sysMtaConnectionStrategyQuery: "Stratejileri sorgula",
|
||||
sysMtaDeliveryScheduleGet: "Zamanlamaları getir",
|
||||
sysMtaDeliveryScheduleCreate: "Zamanlamaları oluştur",
|
||||
sysMtaDeliveryScheduleUpdate: "Zamanlamaları güncelle",
|
||||
sysMtaDeliveryScheduleDestroy: "Zamanlamaları sil",
|
||||
sysMtaDeliveryScheduleQuery: "Zamanlamaları sorgula",
|
||||
sysMtaExtensionsGet: "Mta extensions settings getir",
|
||||
sysMtaExtensionsUpdate: "Mta extensions settings güncelle",
|
||||
sysMtaHookGet: "Kancaları (hook) getir",
|
||||
sysMtaHookCreate: "Kancaları (hook) oluştur",
|
||||
sysMtaHookUpdate: "Kancaları (hook) güncelle",
|
||||
sysMtaHookDestroy: "Kancaları (hook) sil",
|
||||
sysMtaHookQuery: "Kancaları (hook) sorgula",
|
||||
sysMtaInboundSessionGet: "Mta inbound session settings getir",
|
||||
sysMtaInboundSessionUpdate: "Mta inbound session settings güncelle",
|
||||
sysMtaInboundThrottleGet: "Hız sınırlamalarını (throttle) getir",
|
||||
sysMtaInboundThrottleCreate: "Hız sınırlamalarını (throttle) oluştur",
|
||||
sysMtaInboundThrottleUpdate: "Hız sınırlamalarını (throttle) güncelle",
|
||||
sysMtaInboundThrottleDestroy: "Hız sınırlamalarını (throttle) sil",
|
||||
sysMtaInboundThrottleQuery: "Hız sınırlamalarını (throttle) sorgula",
|
||||
sysMtaMilterGet: "Milter'ları getir",
|
||||
sysMtaMilterCreate: "Milter'ları oluştur",
|
||||
sysMtaMilterUpdate: "Milter'ları güncelle",
|
||||
sysMtaMilterDestroy: "Milter'ları sil",
|
||||
sysMtaMilterQuery: "Milter'ları sorgula",
|
||||
sysMtaOutboundStrategyGet: "Mta outbound strategy settings getir",
|
||||
sysMtaOutboundStrategyUpdate: "Mta outbound strategy settings güncelle",
|
||||
sysMtaOutboundThrottleGet: "Hız sınırlamalarını (throttle) getir",
|
||||
sysMtaOutboundThrottleCreate: "Hız sınırlamalarını (throttle) oluştur",
|
||||
sysMtaOutboundThrottleUpdate: "Hız sınırlamalarını (throttle) güncelle",
|
||||
sysMtaOutboundThrottleDestroy: "Hız sınırlamalarını (throttle) sil",
|
||||
sysMtaOutboundThrottleQuery: "Hız sınırlamalarını (throttle) sorgula",
|
||||
sysMtaQueueQuotaGet: "Kotaları getir",
|
||||
sysMtaQueueQuotaCreate: "Kotaları oluştur",
|
||||
sysMtaQueueQuotaUpdate: "Kotaları güncelle",
|
||||
sysMtaQueueQuotaDestroy: "Kotaları sil",
|
||||
sysMtaQueueQuotaQuery: "Kotaları sorgula",
|
||||
sysMtaRouteGet: "Rotaları getir",
|
||||
sysMtaRouteCreate: "Rotaları oluştur",
|
||||
sysMtaRouteUpdate: "Rotaları güncelle",
|
||||
sysMtaRouteDestroy: "Rotaları sil",
|
||||
sysMtaRouteQuery: "Rotaları sorgula",
|
||||
sysMtaStageAuthGet: "Mta stage auth settings getir",
|
||||
sysMtaStageAuthUpdate: "Mta stage auth settings güncelle",
|
||||
sysMtaStageConnectGet: "Mta stage connect settings getir",
|
||||
sysMtaStageConnectUpdate: "Mta stage connect settings güncelle",
|
||||
sysMtaStageDataGet: "Mta stage data settings getir",
|
||||
sysMtaStageDataUpdate: "Mta stage data settings güncelle",
|
||||
sysMtaStageEhloGet: "Mta stage ehlo settings getir",
|
||||
sysMtaStageEhloUpdate: "Mta stage ehlo settings güncelle",
|
||||
sysMtaStageMailGet: "Mta stage mail settings getir",
|
||||
sysMtaStageMailUpdate: "Mta stage mail settings güncelle",
|
||||
sysMtaStageRcptGet: "Mta stage rcpt settings getir",
|
||||
sysMtaStageRcptUpdate: "Mta stage rcpt settings güncelle",
|
||||
sysMtaStsGet: "Mta sts settings getir",
|
||||
sysMtaStsUpdate: "Mta sts settings güncelle",
|
||||
sysMtaTlsStrategyGet: "Stratejileri getir",
|
||||
sysMtaTlsStrategyCreate: "Stratejileri oluştur",
|
||||
sysMtaTlsStrategyUpdate: "Stratejileri güncelle",
|
||||
sysMtaTlsStrategyDestroy: "Stratejileri sil",
|
||||
sysMtaTlsStrategyQuery: "Stratejileri sorgula",
|
||||
sysMtaVirtualQueueGet: "Kuyrukları getir",
|
||||
sysMtaVirtualQueueCreate: "Kuyrukları oluştur",
|
||||
sysMtaVirtualQueueUpdate: "Kuyrukları güncelle",
|
||||
sysMtaVirtualQueueDestroy: "Kuyrukları sil",
|
||||
sysMtaVirtualQueueQuery: "Kuyrukları sorgula",
|
||||
sysNetworkListenerGet: "Dinleyicileri (listener) getir",
|
||||
sysNetworkListenerCreate: "Dinleyicileri (listener) oluştur",
|
||||
sysNetworkListenerUpdate: "Dinleyicileri (listener) güncelle",
|
||||
sysNetworkListenerDestroy: "Dinleyicileri (listener) sil",
|
||||
sysNetworkListenerQuery: "Dinleyicileri (listener) sorgula",
|
||||
sysOAuthClientGet: "Oauth istemcilerini getir",
|
||||
sysOAuthClientCreate: "Oauth istemcilerini oluştur",
|
||||
sysOAuthClientUpdate: "Oauth istemcilerini güncelle",
|
||||
sysOAuthClientDestroy: "Oauth istemcilerini sil",
|
||||
sysOAuthClientQuery: "Oauth istemcilerini sorgula",
|
||||
sysOidcProviderGet: "Oidc provider settings getir",
|
||||
sysOidcProviderUpdate: "Oidc provider settings güncelle",
|
||||
sysPublicKeyGet: "Genel anahtarları getir",
|
||||
sysPublicKeyCreate: "Genel anahtarları oluştur",
|
||||
sysPublicKeyUpdate: "Genel anahtarları güncelle",
|
||||
sysPublicKeyDestroy: "Genel anahtarları sil",
|
||||
sysPublicKeyQuery: "Genel anahtarları sorgula",
|
||||
sysQueuedMessageGet: "Iletileri getir",
|
||||
sysQueuedMessageCreate: "Iletileri oluştur",
|
||||
sysQueuedMessageUpdate: "Iletileri güncelle",
|
||||
sysQueuedMessageDestroy: "Iletileri sil",
|
||||
sysQueuedMessageQuery: "Iletileri sorgula",
|
||||
sysReportSettingsGet: "Report settings settings getir",
|
||||
sysReportSettingsUpdate: "Report settings settings güncelle",
|
||||
sysRoleGet: "Rolleri getir",
|
||||
sysRoleCreate: "Rolleri oluştur",
|
||||
sysRoleUpdate: "Rolleri güncelle",
|
||||
sysRoleDestroy: "Rolleri sil",
|
||||
sysRoleQuery: "Rolleri sorgula",
|
||||
sysSearchGet: "Search settings getir",
|
||||
sysSearchUpdate: "Search settings güncelle",
|
||||
sysSearchStoreGet: "Search store settings getir",
|
||||
sysSearchStoreUpdate: "Search store settings güncelle",
|
||||
sysSecurityGet: "Security settings getir",
|
||||
sysSecurityUpdate: "Security settings güncelle",
|
||||
sysSenderAuthGet: "Sender auth settings getir",
|
||||
sysSenderAuthUpdate: "Sender auth settings güncelle",
|
||||
sysSharingGet: "Sharing settings getir",
|
||||
sysSharingUpdate: "Sharing settings güncelle",
|
||||
sysSieveSystemInterpreterGet: "Sieve system interpreter settings getir",
|
||||
sysSieveSystemInterpreterUpdate: "Sieve system interpreter settings güncelle",
|
||||
sysSieveSystemScriptGet: "Betikleri getir",
|
||||
sysSieveSystemScriptCreate: "Betikleri oluştur",
|
||||
sysSieveSystemScriptUpdate: "Betikleri güncelle",
|
||||
sysSieveSystemScriptDestroy: "Betikleri sil",
|
||||
sysSieveSystemScriptQuery: "Betikleri sorgula",
|
||||
sysSieveUserInterpreterGet: "Sieve user interpreter settings getir",
|
||||
sysSieveUserInterpreterUpdate: "Sieve user interpreter settings güncelle",
|
||||
sysSieveUserScriptGet: "Betikleri getir",
|
||||
sysSieveUserScriptCreate: "Betikleri oluştur",
|
||||
sysSieveUserScriptUpdate: "Betikleri güncelle",
|
||||
sysSieveUserScriptDestroy: "Betikleri sil",
|
||||
sysSieveUserScriptQuery: "Betikleri sorgula",
|
||||
sysSpamClassifierGet: "Spam classifier settings getir",
|
||||
sysSpamClassifierUpdate: "Spam classifier settings güncelle",
|
||||
sysSpamDnsblServerGet: "Sunucuları getir",
|
||||
sysSpamDnsblServerCreate: "Sunucuları oluştur",
|
||||
sysSpamDnsblServerUpdate: "Sunucuları güncelle",
|
||||
sysSpamDnsblServerDestroy: "Sunucuları sil",
|
||||
sysSpamDnsblServerQuery: "Sunucuları sorgula",
|
||||
sysSpamDnsblSettingsGet: "Spam dnsbl settings settings getir",
|
||||
sysSpamDnsblSettingsUpdate: "Spam dnsbl settings settings güncelle",
|
||||
sysSpamFileExtensionGet: "Uzantıları getir",
|
||||
sysSpamFileExtensionCreate: "Uzantıları oluştur",
|
||||
sysSpamFileExtensionUpdate: "Uzantıları güncelle",
|
||||
sysSpamFileExtensionDestroy: "Uzantıları sil",
|
||||
sysSpamFileExtensionQuery: "Uzantıları sorgula",
|
||||
sysSpamLlmGet: "Spam llm settings getir",
|
||||
sysSpamLlmUpdate: "Spam llm settings güncelle",
|
||||
sysSpamPyzorGet: "Spam pyzor settings getir",
|
||||
sysSpamPyzorUpdate: "Spam pyzor settings güncelle",
|
||||
sysSpamRuleGet: "Kuralları getir",
|
||||
sysSpamRuleCreate: "Kuralları oluştur",
|
||||
sysSpamRuleUpdate: "Kuralları güncelle",
|
||||
sysSpamRuleDestroy: "Kuralları sil",
|
||||
sysSpamRuleQuery: "Kuralları sorgula",
|
||||
sysSpamSettingsGet: "Spam settings settings getir",
|
||||
sysSpamSettingsUpdate: "Spam settings settings güncelle",
|
||||
sysSpamTagGet: "Etiketleri getir",
|
||||
sysSpamTagCreate: "Etiketleri oluştur",
|
||||
sysSpamTagUpdate: "Etiketleri güncelle",
|
||||
sysSpamTagDestroy: "Etiketleri sil",
|
||||
sysSpamTagQuery: "Etiketleri sorgula",
|
||||
sysSpamTrainingSampleGet: "Örnekleri getir",
|
||||
sysSpamTrainingSampleCreate: "Örnekleri oluştur",
|
||||
sysSpamTrainingSampleUpdate: "Örnekleri güncelle",
|
||||
sysSpamTrainingSampleDestroy: "Örnekleri sil",
|
||||
sysSpamTrainingSampleQuery: "Örnekleri sorgula",
|
||||
sysSpfReportSettingsGet: "Spf report settings settings getir",
|
||||
sysSpfReportSettingsUpdate: "Spf report settings settings güncelle",
|
||||
sysStoreLookupGet: "Aramaları (lookup) getir",
|
||||
sysStoreLookupCreate: "Aramaları (lookup) oluştur",
|
||||
sysStoreLookupUpdate: "Aramaları (lookup) güncelle",
|
||||
sysStoreLookupDestroy: "Aramaları (lookup) sil",
|
||||
sysStoreLookupQuery: "Aramaları (lookup) sorgula",
|
||||
sysSystemSettingsGet: "System settings settings getir",
|
||||
sysSystemSettingsUpdate: "System settings settings güncelle",
|
||||
taskIndexDocument: "Belgeyi dizine ekle",
|
||||
taskUnindexDocument: "Belgeyi dizinden çıkar",
|
||||
taskIndexTrace: "Telemetri izini (trace) dizine ekle",
|
||||
taskCalendarAlarmEmail: "Takvim anımsatıcı e-postası",
|
||||
taskCalendarAlarmNotification: "Takvim anımsatıcı bildirimi",
|
||||
taskCalendarItipMessage: "Takvim iTIP iletisi",
|
||||
taskMergeThreads: "E-posta yazışma dizilerini birleştir",
|
||||
taskDmarcReport: "Uzak sunucuya DMARC raporu gönder",
|
||||
taskTlsReport: "Uzak sunucuya TLS raporu gönder",
|
||||
taskRestoreArchivedItem: "Arşivlenmiş ögeyi geri yükle",
|
||||
taskDestroyAccount: "Account and all associated data sil",
|
||||
taskAccountMaintenance: "account maintenance operations işlemini gerçekleştir",
|
||||
taskTenantMaintenance: "tenant maintenance operations işlemini gerçekleştir",
|
||||
taskStoreMaintenance: "store maintenance operations işlemini gerçekleştir",
|
||||
taskSpamFilterMaintenance: "spam filter maintenance operations işlemini gerçekleştir",
|
||||
taskAcmeRenewal: "ACME certificate renewal for a domain işlemini gerçekleştir",
|
||||
taskDkimManagement: "DKIM key rotation for a domain işlemini gerçekleştir",
|
||||
taskDnsManagement: "DNS management for a domain işlemini gerçekleştir",
|
||||
sysTaskGet: "Görevleri getir",
|
||||
sysTaskCreate: "Görevleri oluştur",
|
||||
sysTaskUpdate: "Görevleri güncelle",
|
||||
sysTaskDestroy: "Görevleri sil",
|
||||
sysTaskQuery: "Görevleri sorgula",
|
||||
sysTaskManagerGet: "Task manager settings getir",
|
||||
sysTaskManagerUpdate: "Task manager settings güncelle",
|
||||
sysTenantGet: "Kiracıları (tenant) getir",
|
||||
sysTenantCreate: "Kiracıları (tenant) oluştur",
|
||||
sysTenantUpdate: "Kiracıları (tenant) güncelle",
|
||||
sysTenantDestroy: "Kiracıları (tenant) sil",
|
||||
sysTenantQuery: "Kiracıları (tenant) sorgula",
|
||||
sysTlsExternalReportGet: "Raporları getir",
|
||||
sysTlsExternalReportCreate: "Raporları oluştur",
|
||||
sysTlsExternalReportUpdate: "Raporları güncelle",
|
||||
sysTlsExternalReportDestroy: "Raporları sil",
|
||||
sysTlsExternalReportQuery: "Raporları sorgula",
|
||||
sysTlsInternalReportGet: "Raporları getir",
|
||||
sysTlsInternalReportCreate: "Raporları oluştur",
|
||||
sysTlsInternalReportUpdate: "Raporları güncelle",
|
||||
sysTlsInternalReportDestroy: "Raporları sil",
|
||||
sysTlsInternalReportQuery: "Raporları sorgula",
|
||||
sysTlsReportSettingsGet: "Tls report settings settings getir",
|
||||
sysTlsReportSettingsUpdate: "Tls report settings settings güncelle",
|
||||
sysTraceGet: "Izleme kayıtlarını (trace) getir",
|
||||
sysTraceCreate: "Izleme kayıtlarını (trace) oluştur",
|
||||
sysTraceUpdate: "Izleme kayıtlarını (trace) güncelle",
|
||||
sysTraceDestroy: "Izleme kayıtlarını (trace) sil",
|
||||
sysTraceQuery: "Izleme kayıtlarını (trace) sorgula",
|
||||
sysTracerGet: "Izleyicileri (tracer) getir",
|
||||
sysTracerCreate: "Izleyicileri (tracer) oluştur",
|
||||
sysTracerUpdate: "Izleyicileri (tracer) güncelle",
|
||||
sysTracerDestroy: "Izleyicileri (tracer) sil",
|
||||
sysTracerQuery: "Izleyicileri (tracer) sorgula",
|
||||
sysTracingStoreGet: "Tracing store settings getir",
|
||||
sysTracingStoreUpdate: "Tracing store settings güncelle",
|
||||
sysWebDavGet: "Web dav settings getir",
|
||||
sysWebDavUpdate: "Web dav settings güncelle",
|
||||
sysWebHookGet: "Webhook'ları getir",
|
||||
sysWebHookCreate: "Webhook'ları oluştur",
|
||||
sysWebHookUpdate: "Webhook'ları güncelle",
|
||||
sysWebHookDestroy: "Webhook'ları sil",
|
||||
sysWebHookQuery: "Webhook'ları sorgula",
|
||||
},
|
||||
};
|
||||
@@ -646,6 +646,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} outros",
|
||||
|
||||
"Contacts": "Contatos",
|
||||
|
||||
"Collected": "Coletados",
|
||||
|
||||
"Save people you write to as contacts": "Salvar como contatos as pessoas para quem você escreve",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todos para quem você envia e que ainda não são contatos são adicionados à agenda Coletados, para serem sugeridos em todos os dispositivos. Seus próprios endereços nunca são adicionados.",
|
||||
"Contacts are not available": "Os contatos não estão disponíveis",
|
||||
"New contact": "Novo contato",
|
||||
"Edit contact": "Editar o contato",
|
||||
@@ -730,6 +736,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Configurações",
|
||||
"Show folder list": "Mostrar lista de pastas",
|
||||
"Hide folder list": "Ocultar lista de pastas",
|
||||
"All settings": "Todas as configurações",
|
||||
"Sections": "Seções",
|
||||
"General": "Geral",
|
||||
@@ -811,6 +819,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Tamanho do texto",
|
||||
"Font size": "Tamanho da fonte",
|
||||
"Small": "Pequeno",
|
||||
"Original size": "Tamanho original",
|
||||
"Drag to resize": "Arraste para redimensionar",
|
||||
"Image size": "Tamanho da imagem",
|
||||
"Medium": "Médio",
|
||||
"Large": "Grande",
|
||||
"Huge": "Muito grande",
|
||||
@@ -1328,6 +1339,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Enviar mesmo assim",
|
||||
"Send canceled — the message is back in Drafts": "Envio cancelado — a mensagem voltou para Rascunhos",
|
||||
"Send failed: {error}": "Falha ao enviar: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Não foi possível verificar se esta mensagem já foi enviada. Confira Enviados antes de enviá-la novamente.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Não foi possível confirmar se esta mensagem foi enviada. Confira Enviados antes de enviá-la novamente.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Esta mensagem já tinha sido enviada, então não foi enviada de novo.",
|
||||
"Send invites": "Enviar convites",
|
||||
"Send scheduled for {when}": "Envio agendado para {when}",
|
||||
"Send without a subject?": "Enviar sem assunto?",
|
||||
@@ -1382,6 +1396,8 @@ export const catalog: Catalog = {
|
||||
"Collapse all": "Recolher tudo",
|
||||
"Expand all": "Expandir tudo",
|
||||
"Send now instead": "Enviar agora mesmo",
|
||||
"This message is rich text": "Esta mensagem está em texto formatado",
|
||||
"This message is plain text": "Esta mensagem está em texto simples",
|
||||
"Switch to plain text": "Mudar para texto simples",
|
||||
"Switch to rich text": "Mudar para texto formatado",
|
||||
"{used} of {total} used": "{used} de {total} usados",
|
||||
@@ -1728,6 +1744,28 @@ export const catalog: Catalog = {
|
||||
// ── 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",
|
||||
// inbuxa: legacy mail protocols, one switch per protocol
|
||||
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Sua organização desativou {protocols} para aplicativos de e-mail. Os que usam isso não conseguem se conectar a esta conta; os outros ainda conseguem.",
|
||||
"Some legacy mail protocols are off for your organization: {protocols}.": "Alguns protocolos de e-mail legados estão desativados para sua organização: {protocols}.",
|
||||
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Alguns estão desativados para {tenant}: {protocols}. Ative ou desative um de cada vez no console de administração.",
|
||||
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
|
||||
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Excluir, bloquear e retenções legais são feitos no console de administração, que registra o motivo e preserva o que uma retenção abrange.",
|
||||
"Open in the console": "Abrir no console",
|
||||
// inbuxa AL-7, AL-8: a locked account handed to the reader
|
||||
"You no longer have access to {name}. Back to your own mail.": "Você não tem mais acesso a {name}. De volta ao seu próprio e-mail.",
|
||||
"You can't send from {name}. It was handed to you to read, not to send as.": "Você não pode enviar de {name}. A conta foi entregue a você para leitura, não para enviar em nome dela.",
|
||||
"This account was handed to you to read. Nothing in it can be changed.": "Esta conta foi entregue a você para leitura. Nada nela pode ser alterado.",
|
||||
"You can file and move mail in this account, but not delete it.": "Você pode arquivar e mover e-mails nesta conta, mas não excluí-los.",
|
||||
"Read only": "Somente leitura",
|
||||
"Read and organize": "Ler e organizar",
|
||||
"Full access": "Acesso total",
|
||||
"Locked account:": "Conta bloqueada:",
|
||||
"You can send as this account": "Você pode enviar como esta conta",
|
||||
"Back to my mail": "Voltar ao meu e-mail",
|
||||
"Send or close the message you're writing first.": "Envie ou feche primeiro a mensagem que está escrevendo.",
|
||||
"Mail to show": "E-mail a exibir",
|
||||
"My mail": "Meu e-mail",
|
||||
"Locked account": "Conta bloqueada",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -645,6 +645,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n}",
|
||||
|
||||
"Contacts": "Контакты",
|
||||
|
||||
"Collected": "Собранные",
|
||||
|
||||
"Save people you write to as contacts": "Сохранять адресатов как контакты",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Все, кому вы пишете и кого ещё нет в контактах, добавляются в адресную книгу «Собранные» и предлагаются на всех устройствах. Ваши собственные адреса не добавляются.",
|
||||
"Contacts are not available": "Контакты недоступны",
|
||||
"New contact": "Новый контакт",
|
||||
"Edit contact": "Изменить контакт",
|
||||
@@ -730,6 +736,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Настройки",
|
||||
"Show folder list": "Показать список папок",
|
||||
"Hide folder list": "Скрыть список папок",
|
||||
"All settings": "Все настройки",
|
||||
"Sections": "Разделы",
|
||||
"General": "Общие",
|
||||
@@ -811,6 +819,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Размер текста",
|
||||
"Font size": "Размер шрифта",
|
||||
"Small": "Мелкий",
|
||||
"Original size": "Исходный размер",
|
||||
"Drag to resize": "Перетащите, чтобы изменить размер",
|
||||
"Image size": "Размер изображения",
|
||||
"Medium": "Средний",
|
||||
"Large": "Крупный",
|
||||
"Huge": "Очень крупный",
|
||||
@@ -1327,6 +1338,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Всё равно отправить",
|
||||
"Send canceled — the message is back in Drafts": "Отправка отменена — письмо вернулось в черновики",
|
||||
"Send failed: {error}": "Не удалось отправить: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Не удалось проверить, было ли это письмо уже отправлено. Проверьте «Отправленные», прежде чем отправлять его снова.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Не удалось подтвердить, было ли это письмо отправлено. Проверьте «Отправленные», прежде чем отправлять его снова.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Это письмо уже было отправлено, поэтому повторно оно не отправлялось.",
|
||||
"Send invites": "Отправить приглашения",
|
||||
"Send scheduled for {when}": "Отправка запланирована на {when}",
|
||||
"Send without a subject?": "Отправить без темы?",
|
||||
@@ -1381,6 +1395,8 @@ export const catalog: Catalog = {
|
||||
"Collapse all": "Свернуть все",
|
||||
"Expand all": "Развернуть все",
|
||||
"Send now instead": "Отправить сейчас",
|
||||
"This message is rich text": "Это письмо в формате HTML",
|
||||
"This message is plain text": "Это письмо в виде простого текста",
|
||||
"Switch to plain text": "Переключиться на обычный текст",
|
||||
"Switch to rich text": "Переключиться на форматированный текст",
|
||||
"{used} of {total} used": "Использовано {used} из {total}",
|
||||
@@ -1727,6 +1743,28 @@ export const catalog: Catalog = {
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "Мнение языковой модели",
|
||||
"One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр",
|
||||
// inbuxa: legacy mail protocols, one switch per protocol
|
||||
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Ваша организация отключила {protocols} для почтовых приложений. Приложения, которые его используют, не могут подключиться к этому аккаунту; остальные могут.",
|
||||
"Some legacy mail protocols are off for your organization: {protocols}.": "Некоторые устаревшие почтовые протоколы отключены для вашей организации: {protocols}.",
|
||||
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Для {tenant} отключены некоторые: {protocols}. Включайте и отключайте их по одному в консоли администрирования.",
|
||||
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
|
||||
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Удаление, блокировка и юридическое удержание выполняются в консоли администрирования: она записывает причину и сохраняет всё, что охватывает удержание.",
|
||||
"Open in the console": "Открыть в консоли",
|
||||
// inbuxa AL-7, AL-8: a locked account handed to the reader
|
||||
"You no longer have access to {name}. Back to your own mail.": "У вас больше нет доступа к {name}. Возвращаемся к вашей почте.",
|
||||
"You can't send from {name}. It was handed to you to read, not to send as.": "Вы не можете отправлять письма от имени {name}. Учётная запись передана вам для чтения, а не для отправки.",
|
||||
"This account was handed to you to read. Nothing in it can be changed.": "Эта учётная запись передана вам для чтения. Изменить в ней ничего нельзя.",
|
||||
"You can file and move mail in this account, but not delete it.": "В этой учётной записи вы можете раскладывать и перемещать письма, но не удалять их.",
|
||||
"Read only": "Только чтение",
|
||||
"Read and organize": "Чтение и упорядочивание",
|
||||
"Full access": "Полный доступ",
|
||||
"Locked account:": "Заблокированная учётная запись:",
|
||||
"You can send as this account": "Вы можете отправлять от имени этой учётной записи",
|
||||
"Back to my mail": "Вернуться к моей почте",
|
||||
"Send or close the message you're writing first.": "Сначала отправьте или закройте письмо, которое пишете.",
|
||||
"Mail to show": "Какую почту показать",
|
||||
"My mail": "Моя почта",
|
||||
"Locked account": "Заблокированная учётная запись",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -639,6 +639,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n}",
|
||||
|
||||
"Contacts": "Контакти",
|
||||
|
||||
"Collected": "Зібрані",
|
||||
|
||||
"Save people you write to as contacts": "Зберігати адресатів як контакти",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Усі, кому ви пишете і кого ще немає в контактах, додаються до адресної книги «Зібрані» й пропонуються на всіх пристроях. Ваші власні адреси не додаються.",
|
||||
"Contacts are not available": "Контакти недоступні",
|
||||
"New contact": "Новий контакт",
|
||||
"Edit contact": "Змінити контакт",
|
||||
@@ -724,6 +730,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Налаштування",
|
||||
"Show folder list": "Показати список тек",
|
||||
"Hide folder list": "Сховати список тек",
|
||||
"All settings": "Усі налаштування",
|
||||
"Sections": "Розділи",
|
||||
"General": "Загальні",
|
||||
@@ -805,6 +813,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Розмір тексту",
|
||||
"Font size": "Розмір шрифту",
|
||||
"Small": "Дрібний",
|
||||
"Original size": "Початковий розмір",
|
||||
"Drag to resize": "Перетягніть, щоб змінити розмір",
|
||||
"Image size": "Розмір зображення",
|
||||
"Medium": "Середній",
|
||||
"Large": "Великий",
|
||||
"Huge": "Дуже великий",
|
||||
@@ -1321,6 +1332,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Усе одно надіслати",
|
||||
"Send canceled — the message is back in Drafts": "Надсилання скасовано — лист повернувся до чернеток",
|
||||
"Send failed: {error}": "Не вдалося надіслати: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Не вдалося перевірити, чи цей лист уже надіслано. Перевірте «Надіслані», перш ніж надсилати його знову.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Не вдалося підтвердити, чи цей лист надіслано. Перевірте «Надіслані», перш ніж надсилати його знову.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Цей лист уже було надіслано, тому повторно його не надсилали.",
|
||||
"Send invites": "Надіслати запрошення",
|
||||
"Send scheduled for {when}": "Надсилання заплановано на {when}",
|
||||
"Send without a subject?": "Надіслати без теми?",
|
||||
@@ -1375,6 +1389,8 @@ export const catalog: Catalog = {
|
||||
"Collapse all": "Згорнути все",
|
||||
"Expand all": "Розгорнути все",
|
||||
"Send now instead": "Надіслати зараз",
|
||||
"This message is rich text": "Цей лист у форматі HTML",
|
||||
"This message is plain text": "Цей лист у вигляді простого тексту",
|
||||
"Switch to plain text": "Перейти на звичайний текст",
|
||||
"Switch to rich text": "Перейти на форматований текст",
|
||||
"{used} of {total} used": "Використано {used} з {total}",
|
||||
@@ -1721,6 +1737,28 @@ export const catalog: Catalog = {
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "Думка мовної моделі",
|
||||
"One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр",
|
||||
// inbuxa: legacy mail protocols, one switch per protocol
|
||||
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Ваша організація вимкнула {protocols} для поштових програм. Програми, які його використовують, не можуть підключитися до цього облікового запису; інші можуть.",
|
||||
"Some legacy mail protocols are off for your organization: {protocols}.": "Деякі застарілі поштові протоколи вимкнено для вашої організації: {protocols}.",
|
||||
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Для {tenant} вимкнено деякі: {protocols}. Вмикайте й вимикайте їх по одному в консолі адміністрування.",
|
||||
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
|
||||
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Видалення, блокування та юридичне утримання виконуються в консолі адміністрування: вона записує причину й зберігає все, що охоплює утримання.",
|
||||
"Open in the console": "Відкрити в консолі",
|
||||
// inbuxa AL-7, AL-8: a locked account handed to the reader
|
||||
"You no longer have access to {name}. Back to your own mail.": "У вас більше немає доступу до {name}. Повертаємося до вашої пошти.",
|
||||
"You can't send from {name}. It was handed to you to read, not to send as.": "Ви не можете надсилати листи від імені {name}. Обліковий запис передано вам для читання, а не для надсилання.",
|
||||
"This account was handed to you to read. Nothing in it can be changed.": "Цей обліковий запис передано вам для читання. Змінити в ньому нічого не можна.",
|
||||
"You can file and move mail in this account, but not delete it.": "У цьому обліковому записі ви можете впорядковувати й переміщувати листи, але не видаляти їх.",
|
||||
"Read only": "Лише читання",
|
||||
"Read and organize": "Читання й упорядкування",
|
||||
"Full access": "Повний доступ",
|
||||
"Locked account:": "Заблокований обліковий запис:",
|
||||
"You can send as this account": "Ви можете надсилати від імені цього облікового запису",
|
||||
"Back to my mail": "Повернутися до моєї пошти",
|
||||
"Send or close the message you're writing first.": "Спочатку надішліть або закрийте лист, який пишете.",
|
||||
"Mail to show": "Яку пошту показати",
|
||||
"My mail": "Моя пошта",
|
||||
"Locked account": "Заблокований обліковий запис",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -641,6 +641,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "还有 {n} 项",
|
||||
|
||||
"Contacts": "联系人",
|
||||
|
||||
"Collected": "已收集",
|
||||
|
||||
"Save people you write to as contacts": "将您发信的联系人保存为联系人",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "您发信的对象如果还不是联系人,会被添加到“已收集”通讯录,在所有设备上都会作为建议显示。您自己的地址不会被添加。",
|
||||
"Contacts are not available": "联系人不可用",
|
||||
"New contact": "新建联系人",
|
||||
"Edit contact": "编辑联系人",
|
||||
@@ -726,6 +732,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "设置",
|
||||
"Show folder list": "显示文件夹列表",
|
||||
"Hide folder list": "隐藏文件夹列表",
|
||||
"All settings": "全部设置",
|
||||
"Sections": "分区",
|
||||
"General": "常规",
|
||||
@@ -807,6 +815,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "文字大小",
|
||||
"Font size": "字号",
|
||||
"Small": "小",
|
||||
"Original size": "原始大小",
|
||||
"Drag to resize": "拖动以调整大小",
|
||||
"Image size": "图片大小",
|
||||
"Medium": "中",
|
||||
"Large": "大",
|
||||
"Huge": "特大",
|
||||
@@ -1332,6 +1343,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "仍然发送",
|
||||
"Send canceled — the message is back in Drafts": "已取消发送——邮件已回到草稿箱",
|
||||
"Send failed: {error}": "发送失败:{error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "无法检查这封邮件是否已发送。请先查看“已发送”,再决定是否重新发送。",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "无法确认这封邮件是否已发送。请先查看“已发送”,再决定是否重新发送。",
|
||||
"This message had already been sent, so it wasn't sent again.": "这封邮件已经发送过,因此没有再次发送。",
|
||||
"Send invites": "发送邀请",
|
||||
"Send scheduled for {when}": "已定时于 {when} 发送",
|
||||
"Send without a subject?": "不填主题就发送吗?",
|
||||
@@ -1386,6 +1400,8 @@ export const catalog: Catalog = {
|
||||
"Collapse all": "全部折叠",
|
||||
"Expand all": "全部展开",
|
||||
"Send now instead": "改为立即发送",
|
||||
"This message is rich text": "这封邮件是富文本",
|
||||
"This message is plain text": "这封邮件是纯文本",
|
||||
"Switch to plain text": "切换为纯文本",
|
||||
"Switch to rich text": "切换为富文本",
|
||||
"{used} of {total} used": "已使用 {used},共 {total}",
|
||||
@@ -1732,6 +1748,28 @@ export const catalog: Catalog = {
|
||||
// ── Spam filter: the language model's opinion (inbuxa) ──────────
|
||||
"Language model's opinion": "语言模型的判断",
|
||||
"One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一",
|
||||
// inbuxa: legacy mail protocols, one switch per protocol
|
||||
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "您的组织已为邮件应用关闭 {protocols}。使用它的邮件应用无法连接到此账户;其他应用仍可连接。",
|
||||
"Some legacy mail protocols are off for your organization: {protocols}.": "您的组织已关闭部分传统邮件协议:{protocols}。",
|
||||
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "{tenant} 已关闭部分协议:{protocols}。请在管理控制台中逐个开启或关闭。",
|
||||
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
|
||||
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "删除、锁定和法律保留均在管理控制台中进行,控制台会记录原因,并保留保留范围内的内容。",
|
||||
"Open in the console": "在控制台中打开",
|
||||
// inbuxa AL-7, AL-8: a locked account handed to the reader
|
||||
"You no longer have access to {name}. Back to your own mail.": "您已无法访问 {name}。已返回您自己的邮件。",
|
||||
"You can't send from {name}. It was handed to you to read, not to send as.": "您不能从 {name} 发送邮件。该账户交给您是为了阅读,而不是代其发送。",
|
||||
"This account was handed to you to read. Nothing in it can be changed.": "该账户交给您用于阅读,其中的任何内容都不能更改。",
|
||||
"You can file and move mail in this account, but not delete it.": "您可以在此账户中整理和移动邮件,但不能删除。",
|
||||
"Read only": "只读",
|
||||
"Read and organize": "阅读并整理",
|
||||
"Full access": "完全访问",
|
||||
"Locked account:": "已锁定的账户:",
|
||||
"You can send as this account": "您可以以此账户的身份发送",
|
||||
"Back to my mail": "返回我的邮件",
|
||||
"Send or close the message you're writing first.": "请先发送或关闭您正在撰写的邮件。",
|
||||
"Mail to show": "要显示的邮件",
|
||||
"My mail": "我的邮件",
|
||||
"Locked account": "已锁定的账户",
|
||||
},
|
||||
plurals: {
|
||||
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSession } from "@/store/session";
|
||||
|
||||
/**
|
||||
* inbuxa MA-B: the accounts signed in in this browser. The server lists them
|
||||
* and swaps the one in front; the web app clears what it cached for the
|
||||
* previous account and reloads, and signing out of one reloads into the next.
|
||||
*/
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
let calls: { url: string; method: string }[];
|
||||
let answers: Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, "location", { configurable: true, value: { ...window.location, reload } });
|
||||
calls = [];
|
||||
answers = {};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const path = String(url);
|
||||
calls.push({ url: path, method: init?.method ?? "GET" });
|
||||
const key = Object.keys(answers).find((k) => path.endsWith(k));
|
||||
const body = key ? answers[key] : { ok: true };
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response;
|
||||
}),
|
||||
);
|
||||
localStorage.setItem("ihasmail:cached-thing", "from the account in front");
|
||||
useSession.setState({ status: "authenticated", signedIn: [], canAddAccount: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("account switcher", () => {
|
||||
it("lists the accounts and whether one more may be added", async () => {
|
||||
answers["/api/auth/accounts"] = {
|
||||
accounts: [
|
||||
{ id: "a", username: "[email protected]", front: true },
|
||||
{ id: "b", username: "[email protected]", front: false },
|
||||
],
|
||||
canAdd: true,
|
||||
};
|
||||
await useSession.getState().loadSignedIn();
|
||||
expect(useSession.getState().signedIn.map((a) => a.username)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(useSession.getState().canAddAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("switching asks the server, forgets the cache and reloads", async () => {
|
||||
await useSession.getState().switchTo("b");
|
||||
expect(calls.some((c) => c.url.endsWith("/api/auth/accounts/b/front") && c.method === "POST")).toBe(true);
|
||||
expect(localStorage.getItem("ihasmail:cached-thing")).toBeNull();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("signing out of one reloads into the next when there is one", async () => {
|
||||
answers["/api/auth/logout"] = { ok: true, next: true };
|
||||
await useSession.getState().logout();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("signing out of the last one, or of all, ends at the sign-in page", async () => {
|
||||
answers["/api/auth/logout-all"] = { ok: true };
|
||||
await useSession.getState().logoutAll();
|
||||
expect(calls.some((c) => c.url.endsWith("/api/auth/logout-all"))).toBe(true);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(useSession.getState().status).toBe("anonymous");
|
||||
// The next ordinary sign-out goes back to signing out of one
|
||||
useSession.setState({ status: "authenticated" });
|
||||
answers["/api/auth/logout"] = { ok: true };
|
||||
await useSession.getState().logout();
|
||||
expect(calls.filter((c) => c.url.endsWith("/api/auth/logout")).length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/* A confirmed send hands its recipients to the contacts store, unless the reader turned that off. */
|
||||
|
||||
function okServer() {
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "Email/set" && args.create) return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: "e1" } } }, id];
|
||||
if (name === "EmailSubmission/set") return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: "s1" } } }, id];
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
}
|
||||
|
||||
let collect: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: {}, [CAP.mail]: {}, [CAP.submission]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
|
||||
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: [{ id: "i1", name: "Me", email: "[email protected]", replyTo: null }] as never,
|
||||
mailboxes: { mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" } } as never,
|
||||
});
|
||||
collect = vi.fn(async () => 1);
|
||||
useContacts.setState({ collectRecipients: collect as never });
|
||||
okServer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const send = async () => {
|
||||
const key = useCompose.getState().open({ to: [{ name: "Ann", email: "[email protected]" }], cc: [{ name: null, email: "[email protected]" }], subject: "Hi" });
|
||||
await useCompose.getState().send(key);
|
||||
};
|
||||
|
||||
describe("collecting recipients when a message is sent", () => {
|
||||
it("passes every recipient, and the sender's own addresses to leave out", async () => {
|
||||
await send();
|
||||
expect(collect).toHaveBeenCalledTimes(1);
|
||||
const [addrs, own] = collect.mock.calls[0]!;
|
||||
expect((addrs as { email: string }[]).map((a) => a.email)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(own).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("does not when the setting is off", async () => {
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, collectRecipients: false } }));
|
||||
await send();
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { ContactCard, JmapSession } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* People written to used to be remembered only in this browser, as a list of
|
||||
* recent addresses, so a new device suggested nobody. Each confirmed send now
|
||||
* saves the recipients who are not contacts yet, in an address book of their
|
||||
* own, on the server.
|
||||
*/
|
||||
|
||||
interface Calls {
|
||||
books: Record<string, unknown>[];
|
||||
cards: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function server(): Calls {
|
||||
const calls: Calls = { books: [], cards: [] };
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "AddressBook/set" && args.create) {
|
||||
calls.books.push(args.create as Record<string, unknown>);
|
||||
useContacts.setState((s) => ({ books: { ...s.books, bNew: { id: "bNew", name: "Collected" } as never } }));
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { b: { id: "bNew" } } }, id];
|
||||
}
|
||||
if (name === "ContactCard/set" && args.create) {
|
||||
const create = args.create as Record<string, unknown>;
|
||||
calls.cards.push(...Object.values(create) as Record<string, unknown>[]);
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: Object.fromEntries(Object.keys(create).map((k, i) => [k, { id: `n${i}` }])) }, id];
|
||||
}
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false, changed: [], created: [], updated: [], destroyed: [], hasMoreChanges: false, oldState: "1", newState: "1" }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
return calls;
|
||||
}
|
||||
|
||||
const card = (id: string, email: string): ContactCard =>
|
||||
({ id, uid: id, addressBookIds: { b1: true }, name: { full: id }, emails: { e: { address: email } } }) as unknown as ContactCard;
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInSet: 500 }, [CAP.contacts]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.contacts]: {} } } },
|
||||
primaryAccounts: { [CAP.contacts]: "a1" },
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
|
||||
useContacts.setState({
|
||||
accountId: "a1",
|
||||
available: true,
|
||||
loaded: true,
|
||||
books: { b1: { id: "b1", name: "Personal" } as never },
|
||||
cards: { c1: card("c1", "[email protected]") },
|
||||
sharedCards: {},
|
||||
syncCards: (async () => undefined) as never,
|
||||
loadBooks: (async () => undefined) as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("saving the people written to", () => {
|
||||
it("adds only addresses that are not contacts yet, and never your own", async () => {
|
||||
const calls = server();
|
||||
const n = await useContacts.getState().collectRecipients(
|
||||
[
|
||||
{ name: "Known Person", email: "[email protected]" },
|
||||
{ name: "Smith, Jane", email: "[email protected]" },
|
||||
{ name: null, email: "[email protected]" },
|
||||
{ name: "Jane again", email: "[email protected]" },
|
||||
],
|
||||
["[email protected]"],
|
||||
);
|
||||
expect(n).toBe(1);
|
||||
expect(calls.cards).toHaveLength(1);
|
||||
const added = calls.cards[0]!;
|
||||
expect(Object.values(added.emails as Record<string, { address: string }>)[0]!.address).toBe("[email protected]");
|
||||
// Filed under the Collected book, with the name split the way the editor does.
|
||||
expect(added.addressBookIds).toEqual({ bNew: true });
|
||||
expect((added.name as { components: { kind: string; value: string }[] }).components.map((c) => c.value)).toEqual(expect.arrayContaining(["Jane", "Smith"]));
|
||||
});
|
||||
|
||||
it("creates the Collected book once and remembers it", async () => {
|
||||
const calls = server();
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
expect(useSettings.getState().settings.collectedBookId).toBe("bNew");
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
expect(calls.cards.at(-1)!.addressBookIds).toEqual({ bNew: true });
|
||||
});
|
||||
|
||||
it("makes a new book when the remembered one has been deleted", async () => {
|
||||
const calls = server();
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, collectedBookId: "gone" } }));
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does nothing, and makes no book, when everyone is already a contact", async () => {
|
||||
const calls = server();
|
||||
const n = await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(n).toBe(0);
|
||||
expect(calls.books).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
// inbuxa: DLP on send: the override travels with the submission, and the
|
||||
// server's refusals are told apart from other errors.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSubmission, dlpRefusalOf } from "../compose";
|
||||
|
||||
const base = {
|
||||
identityId: "i1",
|
||||
fromEmail: "[email protected]",
|
||||
emailRef: "#m",
|
||||
rcpts: [{ email: "[email protected]" }],
|
||||
sentId: "sent",
|
||||
draftsId: "drafts",
|
||||
scheduledId: null,
|
||||
sendAt: null,
|
||||
};
|
||||
|
||||
describe("DLP on send", () => {
|
||||
it("sends the override reason only when there is one", () => {
|
||||
expect(buildSubmission(base).create["inbuxa:dlpOverride"]).toBeUndefined();
|
||||
expect(buildSubmission({ ...base, dlpOverride: " " }).create["inbuxa:dlpOverride"]).toBeUndefined();
|
||||
expect(buildSubmission({ ...base, dlpOverride: " Client asked " }).create["inbuxa:dlpOverride"]).toEqual({ reason: "Client asked" });
|
||||
});
|
||||
|
||||
it("recognizes the server's refusals", () => {
|
||||
expect(dlpRefusalOf({ type: "inbuxa:dlpWarning", rules: [{ name: "Cards", notice: "Looks like a card." }] })).toEqual({
|
||||
kind: "warning",
|
||||
rules: [{ name: "Cards", notice: "Looks like a card." }],
|
||||
});
|
||||
expect(dlpRefusalOf({ type: "inbuxa:dlpBlocked" })).toEqual({ kind: "blocked", rules: [] });
|
||||
expect(dlpRefusalOf({ type: "forbiddenToSend" })).toBeNull();
|
||||
expect(dlpRefusalOf(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ function draft(over: Partial<Draft> = {}): Draft {
|
||||
requestReceipt: false, priority: "normal",
|
||||
showCc: false, showBcc: false, showReplyTo: false,
|
||||
minimized: false, maximized: false, dirty: false, savedAt: null,
|
||||
saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, sendAt: null,
|
||||
saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, quoteHtml: "", quoteText: "", formatOffer: null, sendAt: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { composeBlocked, useCompose } from "@/store/compose";
|
||||
import { delegatedAccounts, delegationOf, mayDestroy, mayWrite } from "@/lib/delegation";
|
||||
|
||||
/**
|
||||
* inbuxa AL-7: a locked account handed to the reader shows in place of their
|
||||
* own mail, and only mail follows it; the reader never loses their own
|
||||
* account, and a delegation taken away takes them back.
|
||||
*/
|
||||
|
||||
const delegated = (access: string, sendAs = false) => ({
|
||||
name: "[email protected]",
|
||||
isPersonal: false,
|
||||
isReadOnly: access === "read",
|
||||
accountCapabilities: {
|
||||
[CAP.mail]: {},
|
||||
[CAP.calendars]: {},
|
||||
[CAP.contacts]: {},
|
||||
[CAP.filenode]: {},
|
||||
"urn:inbuxa:jmap": { delegation: { locked: true, access, sendAs, until: null } },
|
||||
},
|
||||
});
|
||||
|
||||
const sessionWith = (locked: Record<string, unknown> | null) =>
|
||||
({
|
||||
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} },
|
||||
accounts: {
|
||||
own: {
|
||||
name: "[email protected]",
|
||||
isPersonal: true,
|
||||
accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} },
|
||||
},
|
||||
shared: { name: "[email protected]", isPersonal: false, accountCapabilities: { [CAP.mail]: {} } },
|
||||
...(locked ? { locked } : {}),
|
||||
},
|
||||
primaryAccounts: { [CAP.mail]: "own", [CAP.calendars]: "own", [CAP.contacts]: "own", [CAP.filenode]: "own" },
|
||||
state: "s",
|
||||
}) as unknown as JmapSession;
|
||||
|
||||
let nextSession: JmapSession;
|
||||
|
||||
beforeEach(() => {
|
||||
nextSession = sessionWith(delegated("read"));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (String(url).includes("/api/auth/session")) {
|
||||
return { ok: true, status: 200, json: async () => nextSession } as Response;
|
||||
}
|
||||
const { methodCalls } = JSON.parse((init?.body as string) ?? "{}") as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = methodCalls.map(([name, args, id]) => [name, { accountId: args.accountId, state: "1", list: [], notFound: [] }, id]);
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
|
||||
}),
|
||||
);
|
||||
const session = sessionWith(delegated("read"));
|
||||
client.session = session;
|
||||
useSession.setState({ status: "authenticated", session, accountId: "own", viewing: null, delegationEnded: null });
|
||||
useCompose.setState({ drafts: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSession.setState({ viewing: null });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("delegation", () => {
|
||||
it("is read only from the session's mark, never from a shared account's capabilities", () => {
|
||||
const session = sessionWith(delegated("organize", true));
|
||||
expect(delegationOf(session, "locked")).toEqual({ locked: true, kind: "lock", access: "organize", sendAs: true, until: null });
|
||||
expect(delegationOf(session, "shared")).toBeNull();
|
||||
expect(delegationOf(session, "own")).toBeNull();
|
||||
expect(delegatedAccounts(session).map((a) => a.id)).toEqual(["locked"]);
|
||||
});
|
||||
|
||||
it("never lets a read delegate send, whatever the server says", () => {
|
||||
const session = sessionWith(delegated("read", true));
|
||||
expect(delegationOf(session, "locked")?.sendAs).toBe(false);
|
||||
});
|
||||
|
||||
it("says what each level may do", () => {
|
||||
const read = delegationOf(sessionWith(delegated("read")), "locked");
|
||||
const organize = delegationOf(sessionWith(delegated("organize")), "locked");
|
||||
expect(mayWrite(read)).toBe(false);
|
||||
expect(mayWrite(organize)).toBe(true);
|
||||
expect(mayDestroy(organize)).toBe(false);
|
||||
expect(mayWrite(null) && mayDestroy(null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewing a locked account", () => {
|
||||
it("moves only the mail store; the session's own account stays", () => {
|
||||
useSession.getState().view("locked");
|
||||
expect(useMail.getState().accountId).toBe("locked");
|
||||
expect(useSession.getState().accountId).toBe("own");
|
||||
expect(useSession.getState().ownAccountFor(CAP.mail)).toBe("own");
|
||||
useSession.getState().view(null);
|
||||
expect(useMail.getState().accountId).toBe("own");
|
||||
});
|
||||
|
||||
it("brings the whole account: calendar, contacts and files, never the reader's settings", () => {
|
||||
const s = () => useSession.getState();
|
||||
expect(s().viewAccountFor(CAP.calendars)).toBe("own");
|
||||
s().view("locked");
|
||||
for (const cap of [CAP.calendars, CAP.contacts, CAP.filenode]) {
|
||||
expect(s().viewAccountFor(cap)).toBe("locked");
|
||||
}
|
||||
// Settings live in the reader's own Files, whatever is in view
|
||||
expect(s().ownAccountFor(CAP.filenode)).toBe("own");
|
||||
s().view(null);
|
||||
expect(s().viewAccountFor(CAP.contacts)).toBe("own");
|
||||
});
|
||||
|
||||
it("refuses an account that isn't delegated", () => {
|
||||
useSession.getState().view("shared");
|
||||
expect(useSession.getState().viewing).toBeNull();
|
||||
expect(useMail.getState().accountId).toBe("own");
|
||||
});
|
||||
|
||||
it("goes back to the reader's own mail when the delegation ends", async () => {
|
||||
useSession.getState().view("locked");
|
||||
nextSession = sessionWith(null);
|
||||
await useSession.getState().refresh();
|
||||
expect(useSession.getState().viewing).toBeNull();
|
||||
expect(useSession.getState().delegationEnded).toBe("[email protected]");
|
||||
expect(useMail.getState().accountId).toBe("own");
|
||||
});
|
||||
|
||||
it("blocks writing mail where the delegate can't send", () => {
|
||||
expect(composeBlocked()).toBeNull();
|
||||
useSession.getState().view("locked");
|
||||
expect(composeBlocked()).toMatch(/[email protected]/);
|
||||
expect(useCompose.getState().open()).toBe("");
|
||||
expect(useCompose.getState().drafts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lets a send-as delegate write", () => {
|
||||
const session = sessionWith(delegated("full", true));
|
||||
client.session = session;
|
||||
useSession.setState({ session });
|
||||
useSession.getState().view("locked");
|
||||
expect(composeBlocked()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildEmailObject, useCompose } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { Email, EmailAddress, Identity } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* Remote images in a quoted message (#410).
|
||||
*
|
||||
* Quoting renders the message a second time. The reply was fetching every
|
||||
* remote image in it, whatever the reader had decided — so replying to a
|
||||
* message whose images had been left blocked told the tracker the mail was
|
||||
* read and the address live. The composer is a window like any other.
|
||||
*/
|
||||
|
||||
const PIXEL = "https://tracker.example/open.gif?id=42";
|
||||
|
||||
const MESSAGE = {
|
||||
id: "m1", messageId: ["<[email protected]>"], subject: "Sale", references: [], inReplyTo: [], keywords: {},
|
||||
attachments: [], receivedAt: "2026-09-04T10:00:00Z", mailboxIds: {},
|
||||
from: [{ name: "Shop", email: "[email protected]" }], to: [{ name: "John", email: "[email protected]" }], cc: [],
|
||||
htmlBody: [{ partId: "2", type: "text/html" }],
|
||||
textBody: [{ partId: "1", type: "text/plain" }],
|
||||
bodyValues: {
|
||||
"1": { value: "Sale on now", isEncodingProblem: false, isTruncated: false },
|
||||
"2": { value: `<p>Sale on now</p><img src="${PIXEL}" width="1" height="1">`, isEncodingProblem: false, isTruncated: false },
|
||||
},
|
||||
} as unknown as Email;
|
||||
|
||||
const IDENTITIES = [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as unknown as Identity[];
|
||||
|
||||
/**
|
||||
* Whether the draft will actually load the image. Allowed images go through
|
||||
* the server's proxy where the deployment has one (#412), so the address is
|
||||
* escaped inside an `/api/image` URL rather than sitting in `src` as it is.
|
||||
*/
|
||||
const fetched = (html: string) => html.includes(`/api/image?url=${encodeURIComponent(PIXEL)}`) || html.includes(`src="${PIXEL}"`);
|
||||
|
||||
function replyDraft() {
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: IDENTITIES as never,
|
||||
getEmails: (async () => [MESSAGE]) as never,
|
||||
defaultIdentity: (() => IDENTITIES[0]) as never,
|
||||
loadIdentities: (async () => IDENTITIES) as never,
|
||||
roleId: (() => null) as never,
|
||||
});
|
||||
return useCompose.getState().reply(MESSAGE, "reply").then((key) => useCompose.getState().drafts.find((d) => d.key === key)!);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||
useMail.setState({ imagesShown: {} });
|
||||
useContacts.setState({ loaded: false } as never);
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, imagePolicy: "ask", composeFormat: "html" } });
|
||||
});
|
||||
|
||||
describe("quoting a message whose images were not allowed", () => {
|
||||
it("does not put a fetchable address in the draft", async () => {
|
||||
const d = await replyDraft();
|
||||
expect(d.html).not.toContain(PIXEL.split("?")[0]! + '"');
|
||||
expect(d.html).toContain("data-ihm-blocked");
|
||||
// The src is what the browser would fetch; nothing else in the draft is.
|
||||
expect(/<img[^>]+src="https:/.test(d.html)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the address, so the sent copy is the quote as it was written", async () => {
|
||||
const d = await replyDraft();
|
||||
expect(d.html).toContain(PIXEL);
|
||||
const email = await buildEmailObject({ ...d, to: [{ name: null, email: "[email protected]" }] as EmailAddress[] }, { forSend: true });
|
||||
const sent = JSON.stringify(email);
|
||||
expect(sent).toContain(PIXEL);
|
||||
expect(sent).not.toContain("data-ihm-blocked");
|
||||
});
|
||||
|
||||
it("fetches them once the reader has shown images on that message", async () => {
|
||||
useMail.setState({ imagesShown: { m1: true } });
|
||||
const d = await replyDraft();
|
||||
expect(fetched(d.html)).toBe(true);
|
||||
expect(d.html).not.toContain("data-ihm-blocked");
|
||||
});
|
||||
|
||||
it("fetches them when the policy is to show images always", async () => {
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, imagePolicy: "always" } }));
|
||||
expect(fetched((await replyDraft()).html)).toBe(true);
|
||||
});
|
||||
|
||||
it("fetches them from a sender the reader trusts", async () => {
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, trustedImageSenders: ["[email protected]"] } }));
|
||||
expect(fetched((await replyDraft()).html)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves them blocked for a stranger when the policy is contacts only", async () => {
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, imagePolicy: "contacts" } }));
|
||||
expect((await replyDraft()).html).toContain("data-ihm-blocked");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildEmailObject, useCompose } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useSession } from "@/store/session";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import { unproxyImages } from "@/lib/mail/remoteImages";
|
||||
import type { Email, EmailAddress, Identity } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* Remote images in a quote go through this server, and come back out pointing
|
||||
* at their own addresses (#412).
|
||||
*
|
||||
* Reading a message proxies its images so the sender learns nothing about the
|
||||
* reader. Quoting fetched them directly, which handed the same pixel the
|
||||
* reader's IP and user agent. Proxying the quote is only half of it: those
|
||||
* URLs belong to this deployment, so the copy that is sent has to carry the
|
||||
* originals or the recipient gets images only this server can serve.
|
||||
*/
|
||||
|
||||
const IMAGE = "https://cdn.example/banner.png?id=7";
|
||||
|
||||
const MESSAGE = {
|
||||
id: "m1", messageId: ["<[email protected]>"], subject: "Sale", references: [], inReplyTo: [], keywords: {},
|
||||
attachments: [], receivedAt: "2026-09-04T10:00:00Z", mailboxIds: {},
|
||||
from: [{ name: "Shop", email: "[email protected]" }], to: [{ name: "John", email: "[email protected]" }], cc: [],
|
||||
htmlBody: [{ partId: "2", type: "text/html" }],
|
||||
textBody: [{ partId: "1", type: "text/plain" }],
|
||||
bodyValues: {
|
||||
"1": { value: "Sale on now", isEncodingProblem: false, isTruncated: false },
|
||||
"2": { value: `<p>Sale</p><img src="${IMAGE}">`, isEncodingProblem: false, isTruncated: false },
|
||||
},
|
||||
} as unknown as Email;
|
||||
|
||||
const IDENTITIES = [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as unknown as Identity[];
|
||||
|
||||
function draftFor(mode: "reply" | "forward") {
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: IDENTITIES as never,
|
||||
getEmails: (async () => [MESSAGE]) as never,
|
||||
defaultIdentity: (() => IDENTITIES[0]) as never,
|
||||
loadIdentities: (async () => IDENTITIES) as never,
|
||||
roleId: (() => null) as never,
|
||||
});
|
||||
return useCompose.getState().reply(MESSAGE, mode).then((key) => useCompose.getState().drafts.find((d) => d.key === key)!);
|
||||
}
|
||||
|
||||
const proxy = (on: boolean) => useSession.setState({ session: { ihasmail: { imageProxy: on } } } as never);
|
||||
|
||||
beforeEach(() => {
|
||||
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||
useMail.setState({ imagesShown: {} });
|
||||
useContacts.setState({ loaded: false } as never);
|
||||
// Images allowed, so the question is only how they are fetched.
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, imagePolicy: "always", composeFormat: "html" } });
|
||||
proxy(true);
|
||||
});
|
||||
|
||||
describe("images in a quote, while the reply is being written", () => {
|
||||
it("are fetched through this server, as reading the message does", async () => {
|
||||
const d = await draftFor("reply");
|
||||
expect(d.html).toContain("/api/image?url=");
|
||||
expect(d.html).not.toContain(`src="${IMAGE}"`);
|
||||
});
|
||||
|
||||
it("are fetched directly where the deployment has no proxy", async () => {
|
||||
proxy(false);
|
||||
const d = await draftFor("reply");
|
||||
expect(d.html).toContain(`src="${IMAGE}"`);
|
||||
expect(d.html).not.toContain("/api/image?url=");
|
||||
});
|
||||
|
||||
it("go through it on a forward too", async () => {
|
||||
expect((await draftFor("forward")).html).toContain("/api/image?url=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the copy that is sent", () => {
|
||||
it("points at the image's own address, not at this server", async () => {
|
||||
const d = await draftFor("reply");
|
||||
const sent = JSON.stringify(await buildEmailObject({ ...d, to: [{ name: null, email: "[email protected]" }] as EmailAddress[] }, { forSend: true }));
|
||||
expect(sent).toContain(IMAGE.replace(/&/g, "&"));
|
||||
expect(sent).not.toContain("/api/image?url=");
|
||||
});
|
||||
|
||||
it("restores a signature or template image that used the proxy as well", () => {
|
||||
const logo = "https://cdn.example/logo.png";
|
||||
const html = `<p>Regards</p><img src="/api/image?url=${encodeURIComponent(logo)}"><img src="cid:x@1">`;
|
||||
const out = unproxyImages(html);
|
||||
expect(out).toContain(`src="${logo}"`);
|
||||
expect(out).toContain('src="cid:x@1"');
|
||||
});
|
||||
|
||||
it("leaves everything else alone", () => {
|
||||
const html = '<img src="cid:logo@1"><img src="blob:http://localhost/abc"><a href="/api/image?url=x">link</a>';
|
||||
expect(unproxyImages(html)).toBe(html);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* What the recipient field offers as you type. Matching used to take the
|
||||
* typed text as one piece, so "jane smi" found nobody filed as "Smith, Jane",
|
||||
* and a nickname or a company name found nothing at all.
|
||||
*/
|
||||
|
||||
const card = (id: string, full: string, email: string, extra: Partial<ContactCard> = {}): ContactCard =>
|
||||
({ id, uid: id, addressBookIds: { b1: true }, name: { full }, emails: { e: { address: email } }, ...extra }) as unknown as ContactCard;
|
||||
|
||||
beforeEach(() => {
|
||||
useContacts.setState({
|
||||
accountId: "a1",
|
||||
available: false,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
principalsLoaded: true,
|
||||
principals: [],
|
||||
sharedCards: {},
|
||||
recent: [],
|
||||
cards: {
|
||||
c1: card("c1", "Smith, Jane", "[email protected]"),
|
||||
c2: card("c2", "Robert Jones", "[email protected]", { nicknames: { n: { name: "Bobby" } } } as Partial<ContactCard>),
|
||||
c3: card("c3", "Ann Lee", "[email protected]", { organizations: { o: { name: "Globex Industries" } } } as Partial<ContactCard>),
|
||||
c4: card("c4", "Ann Taylor", "[email protected]"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const emails = async (q: string) => (await useContacts.getState().suggest(q)).map((s) => s.email);
|
||||
|
||||
describe("recipient suggestions", () => {
|
||||
it("matches the words typed in any order", async () => {
|
||||
expect(await emails("jane smi")).toEqual(["[email protected]"]);
|
||||
expect(await emails("smi jan")).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("does not match when one of the words fits nobody", async () => {
|
||||
expect(await emails("jane xyz")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches a nickname and an organization", async () => {
|
||||
expect(await emails("bobby")).toEqual(["[email protected]"]);
|
||||
expect(await emails("globex")).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("puts someone written to lately ahead of an equal match", async () => {
|
||||
expect(await emails("ann")).toEqual(["[email protected]", "[email protected]"]);
|
||||
useContacts.setState({ recent: [{ name: "Ann Taylor", email: "[email protected]" }] });
|
||||
expect((await emails("ann"))[0]).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("still ranks a match at the start above a word inside the name", async () => {
|
||||
// "jo" starts Jones's surname word; it starts no other card's name or address.
|
||||
expect(await emails("jo")).toEqual(["[email protected]"]);
|
||||
});
|
||||
});
|
||||
@@ -153,6 +153,30 @@ describe("a message of mine with nobody obvious to reply to", () => {
|
||||
const d = await draftFor({ ...MINE, to: [ME], cc: [] } as Email, "reply");
|
||||
expect(addrs(d.to)).toEqual([ME.email]);
|
||||
});
|
||||
|
||||
it("answers the Reply-To rather than my own desk when nobody else is on it", async () => {
|
||||
/*
|
||||
* A contact form: the site mails itself, From and To both its own address,
|
||||
* and the person who filled the form in is in Reply-To. From alone makes
|
||||
* this look like mine, and the fallback used to reply to me (#415).
|
||||
*/
|
||||
const form = { ...MINE, to: [ME], cc: [], replyTo: [{ name: "Michael", email: "[email protected]" }] } as Email;
|
||||
const d = await draftFor(form, "reply");
|
||||
expect(addrs(d.to)).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("does the same on a reply all, without cc-ing myself", async () => {
|
||||
const form = { ...MINE, to: [ME], cc: [], replyTo: [{ name: "Michael", email: "[email protected]" }] } as Email;
|
||||
const d = await draftFor(form, "replyAll");
|
||||
expect(addrs(d.to)).toEqual(["[email protected]"]);
|
||||
expect(d.cc).toEqual([]);
|
||||
});
|
||||
|
||||
it("still prefers somebody I actually wrote to over my own Reply-To", async () => {
|
||||
// The Cc is a person; the Reply-To is where answers to me belong.
|
||||
const d = await draftFor({ ...MINE, to: [ME], replyTo: [{ name: null, email: "[email protected]" }] } as Email, "reply");
|
||||
expect(addrs(d.to)).toEqual([BOB.email]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forwarding", () => {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { Email, Identity } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* Offering to answer a message in the format it was written in (#407).
|
||||
*
|
||||
* The trap is `htmlBody`: RFC 8621 derives it, so a plain-text message has one
|
||||
* too, holding its text/plain part. Reading that as "there is HTML" would
|
||||
* offer a switch to rich text on every plain-text message, and never offer the
|
||||
* switch to plain text where it is actually wanted.
|
||||
*/
|
||||
|
||||
const base = {
|
||||
messageId: ["<[email protected]>"], subject: "Numbers", references: [], inReplyTo: [],
|
||||
keywords: {}, attachments: [], receivedAt: "2026-09-04T10:00:00Z", mailboxIds: {},
|
||||
from: [{ name: "Ann", email: "[email protected]" }], to: [{ name: "John", email: "[email protected]" }], cc: [],
|
||||
};
|
||||
|
||||
/** A real multipart/alternative: two parts, one of them text/html. */
|
||||
const RICH = {
|
||||
...base, id: "m1",
|
||||
htmlBody: [{ partId: "2", type: "text/html" }],
|
||||
textBody: [{ partId: "1", type: "text/plain" }],
|
||||
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false }, "2": { value: "<p>hi</p>", isEncodingProblem: false, isTruncated: false } },
|
||||
} as unknown as Email;
|
||||
|
||||
/** Plain text, as Stalwart returns it: both lists name the same text/plain part. */
|
||||
const PLAIN = {
|
||||
...base, id: "m2",
|
||||
htmlBody: [{ partId: "1", type: "text/plain" }],
|
||||
textBody: [{ partId: "1", type: "text/plain" }],
|
||||
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false } },
|
||||
} as unknown as Email;
|
||||
|
||||
const IDENTITIES = [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as unknown as Identity[];
|
||||
|
||||
function draftFor(email: Email, mode: "reply" | "replyAll" | "forward") {
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: IDENTITIES as never,
|
||||
getEmails: (async () => [email]) as never,
|
||||
defaultIdentity: (() => IDENTITIES[0]) as never,
|
||||
loadIdentities: (async () => IDENTITIES) as never,
|
||||
roleId: (() => null) as never,
|
||||
});
|
||||
return useCompose.getState().reply(email, mode).then((key) => useCompose.getState().drafts.find((d) => d.key === key)!);
|
||||
}
|
||||
|
||||
const composeIn = (format: "html" | "text") => useSettings.setState({ settings: { ...DEFAULT_SETTINGS, composeFormat: format } });
|
||||
|
||||
beforeEach(() => {
|
||||
useCompose.setState({ drafts: [], activeKey: null });
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
|
||||
});
|
||||
|
||||
describe("answering a message written in the other format", () => {
|
||||
it("offers rich text when a plain-text reply answers a rich message", async () => {
|
||||
composeIn("text");
|
||||
const d = await draftFor(RICH, "reply");
|
||||
expect(d.format).toBe("text");
|
||||
expect(d.formatOffer).toBe("html");
|
||||
});
|
||||
|
||||
it("offers plain text when a rich reply answers a plain-text message", async () => {
|
||||
composeIn("html");
|
||||
const d = await draftFor(PLAIN, "reply");
|
||||
expect(d.format).toBe("html");
|
||||
expect(d.formatOffer).toBe("text");
|
||||
});
|
||||
|
||||
it("offers nothing when the formats already agree", async () => {
|
||||
composeIn("html");
|
||||
expect((await draftFor(RICH, "reply")).formatOffer).toBeNull();
|
||||
composeIn("text");
|
||||
expect((await draftFor(PLAIN, "reply")).formatOffer).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the part's own type, not the derived htmlBody list", async () => {
|
||||
// PLAIN has an htmlBody; it names the text/plain part. Offering a switch
|
||||
// to rich text here would fire on every plain-text message there is.
|
||||
composeIn("text");
|
||||
expect((await draftFor(PLAIN, "reply")).formatOffer).toBeNull();
|
||||
});
|
||||
|
||||
it("offers on a reply all and on a forward, where the same formatting is lost", async () => {
|
||||
composeIn("text");
|
||||
expect((await draftFor(RICH, "replyAll")).formatOffer).toBe("html");
|
||||
expect((await draftFor(RICH, "forward")).formatOffer).toBe("html");
|
||||
});
|
||||
|
||||
it("carries both bodies either way, so switching has something to switch to", async () => {
|
||||
composeIn("text");
|
||||
const d = await draftFor(RICH, "reply");
|
||||
expect(d.text).toContain("hi");
|
||||
expect(d.html).toContain("hi");
|
||||
});
|
||||
|
||||
it("keeps the quoted message in both formats, so a switch can restore it", async () => {
|
||||
composeIn("text");
|
||||
const d = await draftFor(RICH, "reply");
|
||||
// The HTML quote is the original's markup, not the text one converted.
|
||||
expect(d.quoteHtml).toContain("<p>hi</p>");
|
||||
expect(d.quoteHtml).toContain("ihm-quote");
|
||||
expect(d.quoteText).toContain("Ann");
|
||||
expect(d.text.endsWith(d.quoteText)).toBe(true);
|
||||
});
|
||||
|
||||
it("quotes nothing on a message started from scratch", () => {
|
||||
composeIn("text");
|
||||
const key = useCompose.getState().open();
|
||||
const d = useCompose.getState().drafts.find((x) => x.key === key)!;
|
||||
expect(d.quoteHtml).toBe("");
|
||||
expect(d.quoteText).toBe("");
|
||||
});
|
||||
|
||||
it("makes no offer on a message started from scratch", () => {
|
||||
composeIn("text");
|
||||
const key = useCompose.getState().open();
|
||||
expect(useCompose.getState().drafts.find((x) => x.key === key)!.formatOffer).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, CAP, client } from "@/jmap/client";
|
||||
import { sendOutcomeUnknown, useCompose } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import { useToasts } from "@/ui/toast";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* A send whose reply goes missing may still have gone out. "Send failed ->
|
||||
* Open draft -> Send" then delivered it twice: nothing said which message
|
||||
* the first attempt was, so nothing could check. Each send now carries its
|
||||
* own Message-ID, and an unanswered send asks the server what happened to it
|
||||
* before calling it failed.
|
||||
*/
|
||||
|
||||
type Mode = "ok" | "lost-reply" | "never-arrived" | "orphan" | "gateway" | "refused" | "offline-after";
|
||||
|
||||
interface Server {
|
||||
emails: { id: string; messageId: string }[];
|
||||
submissions: { id: string; emailId: string }[];
|
||||
destroyed: string[];
|
||||
creates: number;
|
||||
}
|
||||
|
||||
function server(mode: Mode): Server {
|
||||
const st: Server = { emails: [], submissions: [], destroyed: [], creates: 0 };
|
||||
let first = true;
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const sending = body.methodCalls.some(([n]) => n === "EmailSubmission/set");
|
||||
if (sending && first && mode !== "ok") {
|
||||
first = false;
|
||||
if (mode === "never-arrived") throw new TypeError("Failed to fetch");
|
||||
if (mode === "gateway") return { ok: false, status: 502, statusText: "Bad Gateway", json: async () => ({ error: "bad_gateway" }) } as Response;
|
||||
if (mode === "refused") return { ok: false, status: 400, statusText: "Bad Request", json: async () => ({ type: "urn:ietf:params:jmap:error:notRequest" }) } as Response;
|
||||
// The server does the work; only the answer is lost.
|
||||
const [, args] = body.methodCalls.find(([n, a]) => n === "Email/set" && (a as { create?: unknown }).create)!;
|
||||
const m = (args.create as { m: { messageId: string[] } }).m;
|
||||
const id = `e${st.emails.length + 1}`;
|
||||
st.creates++;
|
||||
st.emails.push({ id, messageId: m.messageId[0]! });
|
||||
if (mode !== "orphan") st.submissions.push({ id: `s${id}`, emailId: id });
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
if (mode === "offline-after" && !first) throw new TypeError("Failed to fetch");
|
||||
if (mode === "offline-after" && sending) {
|
||||
first = false;
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "Email/set" && (args as { create?: unknown }).create) {
|
||||
const m = (args.create as { m: { messageId: string[] } }).m;
|
||||
const eid = `e${st.emails.length + 1}`;
|
||||
st.creates++;
|
||||
st.emails.push({ id: eid, messageId: m.messageId[0]! });
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: eid } } }, id];
|
||||
}
|
||||
if (name === "EmailSubmission/set") {
|
||||
const eid = st.emails[st.emails.length - 1]!.id;
|
||||
st.submissions.push({ id: `s${eid}`, emailId: eid });
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: `s${eid}` } } }, id];
|
||||
}
|
||||
if (name === "Email/set" && (args as { destroy?: string[] }).destroy) {
|
||||
st.destroyed.push(...((args as { destroy: string[] }).destroy));
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", destroyed: (args as { destroy: string[] }).destroy }, id];
|
||||
}
|
||||
if (name === "Email/query") {
|
||||
const [, value] = ((args.filter ?? {}) as { header?: [string, string] }).header ?? [];
|
||||
const ids = value ? st.emails.filter((e) => e.messageId === value && !st.destroyed.includes(e.id)).map((e) => e.id) : [];
|
||||
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids, total: ids.length }, id];
|
||||
}
|
||||
if (name === "EmailSubmission/query") {
|
||||
const want = ((args.filter ?? {}) as { emailIds?: string[] }).emailIds ?? [];
|
||||
const ids = st.submissions.filter((s) => want.includes(s.emailId)).map((s) => s.id);
|
||||
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids, total: ids.length }, id];
|
||||
}
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
return st;
|
||||
}
|
||||
|
||||
const toastTexts = () => useToasts.getState().toasts.map((t) => t.message);
|
||||
|
||||
async function sendOne(init: Record<string, unknown> = {}) {
|
||||
const key = useCompose.getState().open({ to: [{ name: null, email: "[email protected]" }], subject: "Hi", ...init });
|
||||
await useCompose.getState().send(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.mail]: {}, [CAP.submission]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
|
||||
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as never,
|
||||
mailboxes: {
|
||||
mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" },
|
||||
mbDrafts: { id: "mbDrafts", role: "drafts", parentId: null, name: "Drafts" },
|
||||
} as never,
|
||||
});
|
||||
useToasts.setState({ toasts: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("which failures leave the outcome open", () => {
|
||||
it("treats a refusal as not sent, and no answer or a gateway error as unknown", () => {
|
||||
expect(sendOutcomeUnknown(new ApiError(400, "bad"))).toBe(false);
|
||||
expect(sendOutcomeUnknown(new ApiError(401, "unauthenticated"))).toBe(false);
|
||||
expect(sendOutcomeUnknown(new ApiError(502, "bad_gateway"))).toBe(true);
|
||||
expect(sendOutcomeUnknown(new ApiError(408, "timeout"))).toBe(true);
|
||||
expect(sendOutcomeUnknown(new TypeError("Failed to fetch"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sending once, whatever goes wrong on the way back", () => {
|
||||
it("gives every send a Message-ID on the sender's domain", async () => {
|
||||
const st = server("ok");
|
||||
await sendOne();
|
||||
expect(st.emails).toHaveLength(1);
|
||||
expect(st.emails[0]!.messageId).toMatch(/^[0-9a-f-]{36}@example\.org$/);
|
||||
expect(toastTexts()).toContain("Message sent");
|
||||
});
|
||||
|
||||
it("calls it sent when the reply was lost but the server sent it", async () => {
|
||||
const st = server("lost-reply");
|
||||
await sendOne();
|
||||
expect(st.submissions).toHaveLength(1);
|
||||
expect(st.destroyed).toEqual([]);
|
||||
expect(toastTexts()).toContain("Message sent");
|
||||
expect(useCompose.getState().drafts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes a message that was created but never submitted, and says it failed", async () => {
|
||||
const st = server("orphan");
|
||||
await sendOne();
|
||||
expect(st.destroyed).toEqual(["e1"]);
|
||||
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("says it failed when the request never reached the server", async () => {
|
||||
const st = server("never-arrived");
|
||||
await sendOne();
|
||||
expect(st.emails).toHaveLength(0);
|
||||
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("asks the server after a gateway error too, rather than assuming", async () => {
|
||||
const st = server("gateway");
|
||||
await sendOne();
|
||||
expect(st.creates).toBe(0);
|
||||
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not ask after a refusal: the server did not run it", async () => {
|
||||
server("refused");
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
await sendOne();
|
||||
const asked = fetchMock.mock.calls.some(([, init]) => (init as RequestInit).body?.toString().includes("Email/query"));
|
||||
expect(asked).toBe(false);
|
||||
});
|
||||
|
||||
it("says plainly when it cannot tell, instead of offering a resend that could duplicate", async () => {
|
||||
server("offline-after");
|
||||
await sendOne();
|
||||
const msgs = toastTexts();
|
||||
expect(msgs.some((t) => t.includes("Couldn't confirm whether this message was sent"))).toBe(true);
|
||||
expect(msgs.some((t) => t.startsWith("Send failed"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sending a draft again after a failure", () => {
|
||||
it("keeps the Message-ID, and sends nothing when the first attempt went out", async () => {
|
||||
const st = server("ok");
|
||||
// The first attempt went out; the client never heard.
|
||||
st.emails.push({ id: "e1", messageId: "[email protected]" });
|
||||
st.submissions.push({ id: "se1", emailId: "e1" });
|
||||
await sendOne({ sendMessageId: "[email protected]" });
|
||||
expect(st.creates).toBe(0);
|
||||
expect(toastTexts().some((t) => t.includes("had already been sent"))).toBe(true);
|
||||
});
|
||||
|
||||
it("sends it, under the same Message-ID, when the first attempt did not go out", async () => {
|
||||
const st = server("ok");
|
||||
await sendOne({ sendMessageId: "[email protected]" });
|
||||
expect(st.creates).toBe(1);
|
||||
expect(st.emails[0]!.messageId).toBe("[email protected]");
|
||||
expect(toastTexts()).toContain("Message sent");
|
||||
});
|
||||
|
||||
it("reopens a failed draft with its Message-ID, so the next send can check", async () => {
|
||||
server("orphan");
|
||||
await sendOne();
|
||||
const toast = useToasts.getState().toasts.find((t) => t.message.startsWith("Send failed"))!;
|
||||
toast.action!.onClick();
|
||||
const reopened = useCompose.getState().drafts[0]!;
|
||||
expect(reopened.sendMessageId).toMatch(/@example\.org$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
import { useSession, viewingDelegation } from "@/store/session";
|
||||
import { findSharedMail, sharedMailCandidates } from "@/lib/sharedMail";
|
||||
import { delegatedAccounts } from "@/lib/delegation";
|
||||
|
||||
/**
|
||||
* MA-A: a group's mailbox, or folders someone shared, can be opened in place
|
||||
* of the reader's own mail. Only accounts that answer with a mailbox are
|
||||
* offered, only mail follows the switch, and losing the account takes the
|
||||
* reader back.
|
||||
*/
|
||||
|
||||
const sessionWith = (extra: Record<string, unknown> = {}) =>
|
||||
({
|
||||
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} },
|
||||
accounts: {
|
||||
own: {
|
||||
name: "[email protected]",
|
||||
isPersonal: true,
|
||||
accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} },
|
||||
},
|
||||
// A group: every capability, and mailboxes
|
||||
group: {
|
||||
name: "[email protected]",
|
||||
isPersonal: false,
|
||||
accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} },
|
||||
},
|
||||
// Someone who shared one calendar: mail is advertised, but no mailbox answers
|
||||
calendarOnly: { name: "[email protected]", isPersonal: false, accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {} } },
|
||||
// No mail at all
|
||||
filesOnly: { name: "[email protected]", isPersonal: false, accountCapabilities: { [CAP.filenode]: {} } },
|
||||
// A locked account handed over: listed by delegation.ts, never here
|
||||
locked: {
|
||||
name: "[email protected]",
|
||||
isPersonal: false,
|
||||
accountCapabilities: { [CAP.mail]: {}, "urn:inbuxa:jmap": { delegation: { locked: true, access: "read", sendAs: false, until: null } } },
|
||||
},
|
||||
// A shared mailbox an administrator assigned (MA-S): marked, so never asked about
|
||||
desk: {
|
||||
name: "[email protected]",
|
||||
isPersonal: false,
|
||||
accountCapabilities: {
|
||||
[CAP.mail]: {},
|
||||
[CAP.calendars]: {},
|
||||
"urn:inbuxa:jmap": { delegation: { locked: true, kind: "sharedMailbox", access: "organize", sendAs: true, until: null } },
|
||||
},
|
||||
},
|
||||
...extra,
|
||||
},
|
||||
primaryAccounts: { [CAP.mail]: "own", [CAP.calendars]: "own", [CAP.contacts]: "own", [CAP.filenode]: "own" },
|
||||
state: "s",
|
||||
}) as unknown as JmapSession;
|
||||
|
||||
/** Accounts whose Mailbox/get answers with a mailbox. */
|
||||
let withMailboxes = new Set(["group"]);
|
||||
let nextSession: JmapSession;
|
||||
|
||||
beforeEach(() => {
|
||||
withMailboxes = new Set(["group"]);
|
||||
nextSession = sessionWith();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (String(url).includes("/api/auth/session")) {
|
||||
return { ok: true, status: 200, json: async () => nextSession } as Response;
|
||||
}
|
||||
const { methodCalls } = JSON.parse((init?.body as string) ?? "{}") as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = methodCalls.map(([name, args, id]) => [
|
||||
name,
|
||||
{ accountId: args.accountId, state: "1", list: withMailboxes.has(String(args.accountId)) ? [{ id: "a" }] : [], notFound: [] },
|
||||
id,
|
||||
]);
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
|
||||
}),
|
||||
);
|
||||
const session = sessionWith();
|
||||
client.session = session;
|
||||
useSession.setState({ status: "authenticated", session, accountId: "own", viewing: null, sharedMail: [], delegationEnded: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSession.setState({ viewing: null, sharedMail: [] });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shared mail", () => {
|
||||
it("considers only other people's accounts that advertise mail, leaving locked accounts to delegation", () => {
|
||||
expect(sharedMailCandidates(sessionWith()).map((a) => a.id)).toEqual(["calendarOnly", "desk", "group"]);
|
||||
});
|
||||
|
||||
it("offers only accounts that answer with a mailbox", async () => {
|
||||
// The shared mailbox answers no Mailbox/get here, and is offered anyway
|
||||
expect((await findSharedMail(sessionWith())).map((a) => a.name)).toEqual(["[email protected]", "[email protected]"]);
|
||||
});
|
||||
|
||||
it("can't be opened until it is found, and then shows mail only", async () => {
|
||||
useSession.getState().view("group");
|
||||
expect(useSession.getState().viewing).toBeNull();
|
||||
|
||||
await useSession.getState().loadSharedMail();
|
||||
useSession.getState().view("group");
|
||||
expect(useSession.getState().viewing).toBe("group");
|
||||
// Calendars, contacts and files stay the reader's own
|
||||
expect(useSession.getState().viewAccountFor(CAP.calendars)).toBe("own");
|
||||
expect(useSession.getState().viewAccountFor(CAP.contacts)).toBe("own");
|
||||
expect(useSession.getState().viewAccountFor(CAP.filenode)).toBe("own");
|
||||
|
||||
// An account with no mailboxes is never offered
|
||||
useSession.getState().view("calendarOnly");
|
||||
expect(useSession.getState().viewing).toBe("group");
|
||||
});
|
||||
|
||||
it("takes the reader back to their own mail when the account goes away", async () => {
|
||||
await useSession.getState().loadSharedMail();
|
||||
useSession.getState().view("group");
|
||||
const session = sessionWith();
|
||||
delete (session.accounts as Record<string, unknown>).group;
|
||||
nextSession = session;
|
||||
withMailboxes = new Set();
|
||||
await useSession.getState().refresh();
|
||||
expect(useSession.getState().viewing).toBeNull();
|
||||
expect(useSession.getState().delegationEnded).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("shows an assigned shared mailbox as shared, not locked, keeping its access level", async () => {
|
||||
await useSession.getState().loadSharedMail();
|
||||
expect(delegatedAccounts(useSession.getState().session).map((a) => a.id)).toEqual(["locked"]);
|
||||
useSession.getState().view("desk");
|
||||
expect(useSession.getState().viewing).toBe("desk");
|
||||
expect(viewingDelegation()?.access).toBe("organize");
|
||||
// Mail only, like any shared mailbox
|
||||
expect(useSession.getState().viewAccountFor(CAP.calendars)).toBe("own");
|
||||
});
|
||||
});
|
||||
@@ -404,7 +404,8 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
|
||||
async init() {
|
||||
// The reader's own: a shared calendar is shown beside theirs, not instead.
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.calendars);
|
||||
// inbuxa AL-7: or the locked account in view, the whole of it
|
||||
const accountId = useSession.getState().viewAccountFor(CAP.calendars);
|
||||
const available = Boolean(accountId && client.hasCapability(CAP.calendars));
|
||||
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
|
||||
set({ available });
|
||||
@@ -431,7 +432,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
*/
|
||||
async loadSharedCalendars() {
|
||||
const session = useSession.getState();
|
||||
const own = session.ownAccountFor(CAP.calendars);
|
||||
const own = session.viewAccountFor(CAP.calendars);
|
||||
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
|
||||
// Every account at once, in one request, and listed in the session's order.
|
||||
const answers = await Promise.all(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { create } from "zustand";
|
||||
import { client, setErrorMessage } from "@/jmap/client";
|
||||
import { ApiError, client, setErrorMessage } from "@/jmap/client";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
|
||||
import { formatFullDate, uid } from "@/lib/format";
|
||||
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
|
||||
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text/text";
|
||||
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
|
||||
import { hasHtmlAlternative, sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
|
||||
import { remoteImagesAllowed, restoreBlockedImages, unproxyImages } from "@/lib/mail/remoteImages";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
|
||||
import { useSession } from "./session";
|
||||
@@ -13,9 +14,11 @@ import { formatScheduleTime, holdUntil } from "@/lib/schedule";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { BASE_PATH } from "@/lib/basePath";
|
||||
import { settings } from "./settings";
|
||||
import { useContacts } from "./contacts";
|
||||
import { emlFilename } from "@/lib/text/emlName";
|
||||
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
|
||||
import { shareBody, type SharedContent } from "@/lib/shareTarget";
|
||||
import { delegationOf } from "@/lib/delegation";
|
||||
|
||||
export interface ComposeAttachment {
|
||||
id: string;
|
||||
@@ -75,9 +78,60 @@ export interface Draft {
|
||||
/** Original identity signature HTML currently embedded, to replace on identity switch. */
|
||||
signatureHtml: string;
|
||||
replyMode: "reply" | "replyAll" | "forward" | null;
|
||||
/**
|
||||
* The quoted message as it was prepared in each format, kept so that
|
||||
* switching format re-attaches the original rather than a conversion of
|
||||
* whatever the other format flattened it into. Empty on a draft that quotes
|
||||
* nothing.
|
||||
*/
|
||||
quoteHtml: string;
|
||||
quoteText: string;
|
||||
/**
|
||||
* The format the message being answered was written in, when it is not the
|
||||
* one this draft opened in (#407). The composer offers the switch; answering
|
||||
* it either way, or dismissing it, clears this.
|
||||
*/
|
||||
formatOffer: "html" | "text" | null;
|
||||
mailboxIdOnSend?: Id | null;
|
||||
/** When set, hand the message to the server held until this instant. */
|
||||
sendAt: number | null;
|
||||
/**
|
||||
* The server's DLP rules refused the last send (inbuxa): a warning the
|
||||
* sender may answer with a reason, or a block. The composer shows it.
|
||||
*/
|
||||
dlp?: DlpRefusalInfo | null;
|
||||
/** The reason to send despite a DLP warning, for the next send only. */
|
||||
dlpOverride?: string | null;
|
||||
/**
|
||||
* The Message-ID this draft goes out under, fixed at its first send and
|
||||
* kept if the draft comes back after a failure. Sending it again asks the
|
||||
* server first whether a message with this id already went out.
|
||||
*/
|
||||
sendMessageId?: string;
|
||||
}
|
||||
|
||||
export interface DlpRefusalInfo {
|
||||
kind: "warning" | "blocked";
|
||||
rules: { name: string; notice: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A send the server's DLP rules refused (inbuxa: `inbuxa:dlpWarning`,
|
||||
* `inbuxa:dlpBlocked`): which rules, and what they say.
|
||||
*/
|
||||
export class DlpRefusal extends Error {
|
||||
constructor(public info: DlpRefusalInfo) {
|
||||
super(info.rules.map((r) => r.notice).join(" ") || translate("This message wasn't sent under the server's rules"));
|
||||
}
|
||||
}
|
||||
|
||||
/** The DLP refusal in a submission's set error, if it is one. */
|
||||
export function dlpRefusalOf(err: { type?: string; rules?: { name?: string; notice?: string }[] } | undefined): DlpRefusalInfo | null {
|
||||
if (!err || (err.type !== "inbuxa:dlpWarning" && err.type !== "inbuxa:dlpBlocked")) return null;
|
||||
return {
|
||||
kind: err.type === "inbuxa:dlpWarning" ? "warning" : "blocked",
|
||||
rules: (err.rules ?? []).map((r) => ({ name: r.name ?? "", notice: r.notice ?? "" })),
|
||||
};
|
||||
}
|
||||
|
||||
interface ComposeState {
|
||||
@@ -145,11 +199,39 @@ function blankDraft(init: Partial<Draft> = {}): Draft {
|
||||
error: null,
|
||||
signatureHtml: "",
|
||||
replyMode: null,
|
||||
quoteHtml: "",
|
||||
quoteText: "",
|
||||
formatOffer: null,
|
||||
sendAt: null,
|
||||
...init,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this message's remote images may be fetched into a composer.
|
||||
*
|
||||
* The same question the reader answered, asked with the same inputs: the
|
||||
* policy, the trusted senders, whether the sender is a contact, and whether
|
||||
* the reader pressed "Show images" on this message.
|
||||
*/
|
||||
/** Whether this deployment fetches remote images through its own server. */
|
||||
function imageProxyOn(): boolean {
|
||||
return useSession.getState().session?.ihasmail?.imageProxy ?? true;
|
||||
}
|
||||
|
||||
function remoteImagesForMessage(email: Email): boolean {
|
||||
const s = settings();
|
||||
const from = email.from?.[0]?.email;
|
||||
const contacts = useContacts.getState();
|
||||
return remoteImagesAllowed({
|
||||
from,
|
||||
policy: s.imagePolicy,
|
||||
trusted: s.trustedImageSenders,
|
||||
inContacts: Boolean(from && contacts.loaded && contacts.lookupByEmail(from)),
|
||||
shown: Boolean(useMail.getState().imagesShown[email.id]),
|
||||
});
|
||||
}
|
||||
|
||||
export function signatureBlock(identity: Identity | undefined, format: "html" | "text"): string {
|
||||
if (!identity) return "";
|
||||
if (format === "text") return identity.textSignature ? `\n\n-- \n${identity.textSignature}` : "";
|
||||
@@ -170,12 +252,33 @@ function defaultIdentity(identities: Identity[], email?: Email | null): Identity
|
||||
return useMail.getState().defaultIdentity() ?? identities[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Why nothing can be written from the account in view, if it can't: a locked
|
||||
* account handed to the reader without the right to send as it (inbuxa
|
||||
* AL-8). Nothing is then saved to its Drafts either.
|
||||
*/
|
||||
export function composeBlocked(): string | null {
|
||||
const s = useSession.getState();
|
||||
if (!s.viewing) return null;
|
||||
const delegation = delegationOf(s.session, s.viewing);
|
||||
if (!delegation || delegation.sendAs) return null;
|
||||
const name = s.session?.accounts[s.viewing]?.name ?? "";
|
||||
return translate("You can't send from {name}. It was handed to you to read, not to send as.", { name });
|
||||
}
|
||||
|
||||
function refuseCompose(): boolean {
|
||||
const why = composeBlocked();
|
||||
if (why) toast.show(why);
|
||||
return why !== null;
|
||||
}
|
||||
|
||||
export const useCompose = create<ComposeState>((set, get) => ({
|
||||
drafts: [],
|
||||
activeKey: null,
|
||||
pendingSends: {},
|
||||
|
||||
open(init = {}) {
|
||||
if (refuseCompose()) return "";
|
||||
const identities = useMail.getState().identities;
|
||||
const ident = init.identityId ? identities.find((i) => i.id === init.identityId) : useMail.getState().defaultIdentity();
|
||||
const d = blankDraft({ identityId: ident?.id ?? null, replyTo: ident?.replyTo ?? [], showReplyTo: Boolean(ident?.replyTo?.length), ...init });
|
||||
@@ -201,6 +304,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
* signature from every message that started as a share.
|
||||
*/
|
||||
openFromShare(share) {
|
||||
if (refuseCompose()) return "";
|
||||
const body = shareBody(share);
|
||||
const key = get().open({ subject: share.title.trim() });
|
||||
if (body) {
|
||||
@@ -212,6 +316,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
},
|
||||
|
||||
async openDraftEmail(email) {
|
||||
if (refuseCompose()) return "";
|
||||
const existing = get().drafts.find((d) => d.draftId === email.id);
|
||||
if (existing) {
|
||||
get().focus(existing.key);
|
||||
@@ -243,7 +348,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
showCc: Boolean(full.cc?.length),
|
||||
showBcc: Boolean(full.bcc?.length),
|
||||
subject: full.subject ?? "",
|
||||
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true, dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
|
||||
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: remoteImagesForMessage(full), proxyRemote: imageProxyOn(), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
|
||||
text: text || (html ? htmlToText(html) : ""),
|
||||
format: html ? "html" : settings().composeFormat,
|
||||
attachments,
|
||||
@@ -273,6 +378,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
* both are new without anything here asking for it.
|
||||
*/
|
||||
async composeAsNew(email) {
|
||||
if (refuseCompose()) return "";
|
||||
const mail = useMail.getState();
|
||||
const full = (await mail.getEmails([email.id], true))[0] ?? email;
|
||||
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
|
||||
@@ -309,7 +415,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
showCc: Boolean(full.cc?.length),
|
||||
showBcc: Boolean(full.bcc?.length),
|
||||
subject: full.subject ?? "",
|
||||
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true, dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
|
||||
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: remoteImagesForMessage(full), proxyRemote: imageProxyOn(), dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
|
||||
text: text || (html ? htmlToText(html) : ""),
|
||||
format: html ? "html" : settings().composeFormat,
|
||||
attachments,
|
||||
@@ -324,6 +430,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
},
|
||||
|
||||
async reply(email, mode) {
|
||||
if (refuseCompose()) return "";
|
||||
const mail = useMail.getState();
|
||||
const full = (await mail.getEmails([email.id], true))[0] ?? email;
|
||||
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
|
||||
@@ -366,6 +473,19 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
// Addressed only to myself, or only in Cc: there is still somebody this
|
||||
// is a reply to, and an empty To is not it.
|
||||
if (!to.length) { to = cc.length ? cc : withoutOwn(full.cc ?? []); cc = []; }
|
||||
/*
|
||||
* Nobody but me on the message, and a Reply-To pointing somewhere that
|
||||
* is not mine: that address is who this is really from.
|
||||
*
|
||||
* A contact form is the shape of it -- From and To are both the site's
|
||||
* own mailbox, and the person who filled the form in is in Reply-To.
|
||||
* The address test above calls that mine, correctly as far as it goes,
|
||||
* and the fallback then addressed the reply to my own desk (#415).
|
||||
*
|
||||
* After the Cc, not before it: a message I really did send carries my
|
||||
* own Reply-To, and somebody I actually wrote to beats it.
|
||||
*/
|
||||
if (!to.length) to = withoutOwn(full.replyTo ?? []);
|
||||
if (!to.length) to = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]);
|
||||
} else {
|
||||
to = uniqueAddresses(full.replyTo?.length ? full.replyTo : (full.from ?? []));
|
||||
@@ -379,6 +499,13 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
const textPart = full.textBody?.[0];
|
||||
const origHtml = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
|
||||
const origText = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : "";
|
||||
/*
|
||||
* What the message being answered was really written in. `htmlBody` is
|
||||
* derived, so its presence proves nothing -- hasHtmlAlternative() reads the
|
||||
* part's own type. Getting this wrong would offer every plain-text message
|
||||
* a switch to rich text it does not need.
|
||||
*/
|
||||
const origFormat = hasHtmlAlternative(htmlPart, origHtml) ? "html" : "text";
|
||||
const accountId = mail.accountId!;
|
||||
const attachments: ComposeAttachment[] = [];
|
||||
const cidMap: Record<string, string> = {};
|
||||
@@ -389,9 +516,21 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
attachments.push({ id: uid("a"), name: a.name ?? "attachment", type: a.type, size: a.size, blobId: a.blobId, progress: 100, error: null, cid: a.cid ?? undefined, inline });
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Quoting renders the message a second time, so the reader's decision
|
||||
* about its remote images applies here too: a quote that fetched what
|
||||
* they declined would report the message read to whoever was counting
|
||||
* (#410). Blocked images keep their address and get it back on the way
|
||||
* out, so the recipient's copy is the quote as its sender wrote it.
|
||||
*/
|
||||
const allowRemote = remoteImagesForMessage(full);
|
||||
// Fetched through this server while the reply is written, as reading the
|
||||
// message does, and pointed back at their own addresses on the way out
|
||||
// (#412).
|
||||
const proxyRemote = imageProxyOn();
|
||||
// Inline images are shown via their blob URLs in the editor and converted back to cid: at send time.
|
||||
const quotedHtmlBody = origHtml
|
||||
? sanitizeEmailHtml(origHtml, { cidMap, allowRemote: true, proxyRemote: false, dropStyleBlocks: true }).html
|
||||
? sanitizeEmailHtml(origHtml, { cidMap, allowRemote, proxyRemote, dropStyleBlocks: true }).html
|
||||
: textToHtml(origText).replace(/\n/g, "<br>");
|
||||
const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", "));
|
||||
const date = formatFullDate(full.receivedAt);
|
||||
@@ -434,12 +573,16 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
relatedKeyword: mode === "forward" ? "$forwarded" : "$answered",
|
||||
signatureHtml: sigHtml,
|
||||
replyMode: mode,
|
||||
quoteHtml,
|
||||
quoteText: quoteTxt,
|
||||
formatOffer: origFormat === s.composeFormat ? null : origFormat,
|
||||
});
|
||||
set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key }));
|
||||
return d.key;
|
||||
},
|
||||
|
||||
forwardAsAttachment(email) {
|
||||
if (refuseCompose()) return "";
|
||||
const accountId = useMail.getState().accountId;
|
||||
const key = get().open({
|
||||
subject: replySubject(email.subject, "Fwd"),
|
||||
@@ -601,8 +744,12 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
},
|
||||
|
||||
async send(key) {
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
if (!d) return;
|
||||
const found = get().drafts.find((x) => x.key === key);
|
||||
if (!found) return;
|
||||
// A draft that already has a Message-ID has been sent before, and failed.
|
||||
const retry = Boolean(found.sendMessageId);
|
||||
const fromEmail = (useMail.getState().identities.find((i) => i.id === found.identityId) ?? useMail.getState().identities[0])?.email ?? "";
|
||||
const d: Draft = { ...found, sendMessageId: found.sendMessageId ?? newMessageId(fromEmail) };
|
||||
const delay = settings().undoSendSeconds;
|
||||
// A schedule the user left sitting until it passed is just a send now.
|
||||
const scheduling = d.sendAt !== null && d.sendAt > Date.now();
|
||||
@@ -617,9 +764,39 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
return { pendingSends: rest };
|
||||
});
|
||||
try {
|
||||
await sendInternal(d, get);
|
||||
toast.success(scheduling ? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) }) : translate("Message sent"));
|
||||
const sent = await sendInternal(d, get, { retry });
|
||||
// Everyone written to who is not a contact yet, so they are suggested on every
|
||||
// device. Also when a resend found the first attempt had gone out: its reply
|
||||
// was what got lost, so nothing was collected then.
|
||||
if (settings().collectRecipients) {
|
||||
const own = useMail.getState().identities.map((i) => i.email);
|
||||
void useContacts.getState().collectRecipients([...d.to, ...d.cc, ...d.bcc], own).catch(() => undefined);
|
||||
}
|
||||
if (sent.alreadySent) {
|
||||
toast.success(translate("This message had already been sent, so it wasn't sent again."));
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
sent.held
|
||||
? translate("Held for review: it's sent once a reviewer releases it")
|
||||
: scheduling
|
||||
? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) })
|
||||
: translate("Message sent"),
|
||||
);
|
||||
} catch (err) {
|
||||
// inbuxa: DLP refused it: the draft comes back with the rules' notices
|
||||
if (err instanceof DlpRefusal) {
|
||||
set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: null, dlp: err.info, dlpOverride: null }], activeKey: d.key }));
|
||||
toast.error(err.info.kind === "warning" ? translate("Not sent yet: check the warning") : translate("Not sent: blocked by the server's rules"));
|
||||
return;
|
||||
}
|
||||
if (err instanceof SendOutcomeUnknown) {
|
||||
toast.error(err.message, {
|
||||
action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: err.message }], activeKey: d.key })) },
|
||||
duration: 30000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.error(translate("Send failed: {error}", { error: (err as Error).message }), {
|
||||
action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
|
||||
duration: 15000,
|
||||
@@ -754,7 +931,9 @@ export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailb
|
||||
if (!ident) throw new Error(translate("No sending identity available"));
|
||||
const from: EmailAddress = { name: ident.name || null, email: ident.email };
|
||||
|
||||
let html = d.format === "html" ? d.html : "";
|
||||
// Images blocked when the message was quoted keep their address; the copy
|
||||
// that leaves carries it, and the recipient's client decides for itself.
|
||||
let html = d.format === "html" ? unproxyImages(restoreBlockedImages(d.html)) : "";
|
||||
const text = d.format === "html" ? htmlToText(d.html) : d.text;
|
||||
|
||||
// Inline attachments shown via blob URLs in the editor → back to cid: references.
|
||||
@@ -912,6 +1091,8 @@ export function buildSubmission(opts: {
|
||||
draftsId: Id | null;
|
||||
scheduledId: Id | null;
|
||||
sendAt: number | null;
|
||||
/** inbuxa: the sender's reason to send despite a DLP warning. */
|
||||
dlpOverride?: string | null;
|
||||
}): { create: Record<string, unknown>; onSuccessUpdateEmail: Record<string, unknown> } {
|
||||
const scheduled = opts.sendAt !== null;
|
||||
const mailFrom: Record<string, unknown> = { email: opts.fromEmail };
|
||||
@@ -921,13 +1102,62 @@ export function buildSubmission(opts: {
|
||||
if (filedIn) onSuccess[`mailboxIds/${filedIn}`] = true;
|
||||
if (opts.draftsId && opts.draftsId !== filedIn) onSuccess[`mailboxIds/${opts.draftsId}`] = null;
|
||||
if (scheduled && opts.sentId && opts.sentId !== filedIn) onSuccess[`mailboxIds/${opts.sentId}`] = null;
|
||||
return {
|
||||
create: { identityId: opts.identityId, emailId: opts.emailRef, envelope: { mailFrom, rcptTo: opts.rcpts } },
|
||||
onSuccessUpdateEmail: onSuccess,
|
||||
};
|
||||
const create: Record<string, unknown> = { identityId: opts.identityId, emailId: opts.emailRef, envelope: { mailFrom, rcptTo: opts.rcpts } };
|
||||
if (opts.dlpOverride?.trim()) create["inbuxa:dlpOverride"] = { reason: opts.dlpOverride.trim() };
|
||||
return { create, onSuccessUpdateEmail: onSuccess };
|
||||
}
|
||||
|
||||
async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
|
||||
/** What the server said of a send: whether DLP held it for review (inbuxa). */
|
||||
interface Sent {
|
||||
held: boolean;
|
||||
/** A resend found the first attempt had already gone out; nothing was sent again. */
|
||||
alreadySent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A send whose outcome nobody can state: the request went out, no answer came
|
||||
* back, and the server could not be asked afterwards either. Offering a plain
|
||||
* "send again" here is how one message is delivered twice.
|
||||
*/
|
||||
export class SendOutcomeUnknown extends Error {}
|
||||
|
||||
/** A Message-ID for one send, on the sending identity's domain (RFC 5322 §3.6.4). */
|
||||
export function newMessageId(fromEmail: string): string {
|
||||
const domain = fromEmail.split("@")[1] || "localhost";
|
||||
return `${crypto.randomUUID()}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failed request may still have been carried out. A refusal (4xx)
|
||||
* means the server did not run it; no answer, or a 5xx from the proxy
|
||||
* that lost the upstream reply, means it may have.
|
||||
*/
|
||||
export function sendOutcomeUnknown(err: unknown): boolean {
|
||||
if (err instanceof ApiError) return err.status >= 500 || err.status === 408;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What became of a send with this Message-ID.
|
||||
*
|
||||
* `sent`: a message carrying it exists and was submitted. `orphan`: it exists
|
||||
* but no submission does, so it was created and never sent. `none`: nothing.
|
||||
*
|
||||
* Submissions are expunged after the server's hold period, so "no submission"
|
||||
* means unsent only right after the attempt (`justNow`). Later, a message
|
||||
* still carrying the id is taken as sent: every path that fails a send
|
||||
* destroys the copy it created.
|
||||
*/
|
||||
export async function traceSend(accountId: Id, messageId: string, justNow: boolean): Promise<{ trace: "sent" | "orphan" | "none"; emailIds: Id[] }> {
|
||||
const q = await client.call<{ ids: Id[] }>("Email/query", { accountId, filter: { header: ["Message-ID", messageId] } });
|
||||
const emailIds = q.ids ?? [];
|
||||
if (!emailIds.length) return { trace: "none", emailIds };
|
||||
if (!justNow) return { trace: "sent", emailIds };
|
||||
const subs = await client.call<{ ids: Id[] }>("EmailSubmission/query", { accountId, filter: { emailIds } });
|
||||
return { trace: subs.ids?.length ? "sent" : "orphan", emailIds };
|
||||
}
|
||||
|
||||
async function sendInternal(d: Draft, _get: () => ComposeState, opts: { retry?: boolean } = {}): Promise<Sent> {
|
||||
const mail = useMail.getState();
|
||||
const accountId = mail.accountId!;
|
||||
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
|
||||
@@ -936,6 +1166,27 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
|
||||
const scheduled = d.sendAt !== null && d.sendAt > Date.now();
|
||||
const scheduledId = scheduled ? await ensureScheduledMailbox() : null;
|
||||
const email = await buildEmailObject(d, { forSend: true, mailboxId: scheduledId });
|
||||
const messageId = d.sendMessageId ?? newMessageId(ident.email);
|
||||
email.messageId = [messageId];
|
||||
/*
|
||||
* Sending again a draft that came back from a failed send: ask first. The
|
||||
* failure may have been only the reply going missing, and a second
|
||||
* submission would deliver the message twice.
|
||||
*/
|
||||
if (opts.retry) {
|
||||
let found: Awaited<ReturnType<typeof traceSend>>;
|
||||
try {
|
||||
found = await traceSend(accountId, messageId, false);
|
||||
} catch {
|
||||
throw new SendOutcomeUnknown(translate("Couldn't check whether this message was already sent. Check Sent before sending it again."));
|
||||
}
|
||||
if (found.trace === "sent") {
|
||||
if (d.draftId) void client.call("Email/set", { accountId, destroy: [d.draftId] });
|
||||
void mail.loadMailboxes();
|
||||
void mail.refreshList();
|
||||
return { held: false, alreadySent: true };
|
||||
}
|
||||
}
|
||||
const sentId = mail.roleId("sent");
|
||||
const draftsId = mail.roleId("drafts");
|
||||
const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email }));
|
||||
@@ -949,6 +1200,7 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
|
||||
draftsId,
|
||||
scheduledId,
|
||||
sendAt: scheduled ? d.sendAt : null,
|
||||
dlpOverride: d.dlpOverride ?? null,
|
||||
});
|
||||
const calls: Array<[string, Record<string, unknown>, string]> = [
|
||||
["Email/set", { accountId, create: { m: email }, ...(d.draftId ? { destroy: [d.draftId] } : {}) }, "e"],
|
||||
@@ -961,7 +1213,27 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
|
||||
if (d.relatedEmailId && d.relatedKeyword) {
|
||||
calls.push(["Email/set", { accountId, update: { [d.relatedEmailId]: { [`keywords/${d.relatedKeyword}`]: true } } }, "k"]);
|
||||
}
|
||||
const res = await client.chain(calls, { allowErrors: true });
|
||||
let res: Awaited<ReturnType<typeof client.chain>>;
|
||||
try {
|
||||
res = await client.chain(calls, { allowErrors: true });
|
||||
} catch (err) {
|
||||
if (!sendOutcomeUnknown(err)) throw err;
|
||||
// No answer is not a no: find out what the server did with it.
|
||||
let found: Awaited<ReturnType<typeof traceSend>>;
|
||||
try {
|
||||
found = await traceSend(accountId, messageId, true);
|
||||
} catch {
|
||||
throw new SendOutcomeUnknown(translate("Couldn't confirm whether this message was sent. Check Sent before sending it again."));
|
||||
}
|
||||
if (found.trace === "sent") {
|
||||
void mail.loadMailboxes();
|
||||
void mail.refreshList();
|
||||
return { held: false };
|
||||
}
|
||||
// Created but never submitted: take it out of Sent, then report the failure.
|
||||
if (found.trace === "orphan") void client.call("Email/set", { accountId, destroy: found.emailIds });
|
||||
throw err;
|
||||
}
|
||||
const e = res.get("e")?.[0] as unknown as SetResponse<Email> & { __error?: { type: string; description?: string } };
|
||||
if (e.__error) throw new Error(setErrorMessage(e.__error));
|
||||
if (e.notCreated?.m) throw new Error(setErrorMessage(e.notCreated.m));
|
||||
@@ -972,6 +1244,8 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
|
||||
// Clean up the created (unsent) email so it doesn't linger in Sent.
|
||||
const created = e.created?.m?.id;
|
||||
if (created) void client.call("Email/set", { accountId, destroy: [created] });
|
||||
const dlp = dlpRefusalOf(err as { type?: string; rules?: { name?: string; notice?: string }[] });
|
||||
if (dlp) throw new DlpRefusal(dlp);
|
||||
throw new Error(setErrorMessage(err));
|
||||
}
|
||||
if (d.relatedEmailId && d.relatedKeyword) {
|
||||
@@ -993,6 +1267,7 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
|
||||
}
|
||||
void mail.loadMailboxes();
|
||||
void mail.refreshList();
|
||||
return { held: (s.created?.s as { "inbuxa:held"?: boolean } | undefined)?.["inbuxa:held"] === true };
|
||||
}
|
||||
|
||||
export { FULL_PROPS, BODY_PROPS };
|
||||
|
||||
@@ -2,10 +2,12 @@ import { create } from "zustand";
|
||||
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
|
||||
import { CAP, chunk, client, JmapMethodError, setErrorMessage } from "@/jmap/client";
|
||||
import type { AddressBook, ChangesResponse, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
|
||||
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
||||
import { contactDisplayName, contactEmails, contactFromAddress, sortKey } from "@/lib/contacts";
|
||||
import { uniqueAddresses } from "@/lib/address";
|
||||
import { parseLdif, uidFromDn } from "@/lib/ldif";
|
||||
import { cardFromLdif } from "@/lib/mozillaAb";
|
||||
import { useSettings } from "./settings";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { useSession } from "./session";
|
||||
import { useMail } from "./mail";
|
||||
|
||||
@@ -225,6 +227,8 @@ interface ContactsState {
|
||||
*/
|
||||
emptyBook(bookId: Id): Promise<{ destroyed: number; unfiled: number; refused?: SetError }>;
|
||||
createBook(name: string): Promise<Id>;
|
||||
/** Save the addresses that are not on any contact yet, in the Collected address book. */
|
||||
collectRecipients(addrs: EmailAddress[], own: string[]): Promise<number>;
|
||||
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
||||
destroyBook(id: Id): Promise<void>;
|
||||
/** Import vCards, updating any whose UID this book already holds rather than duplicating it. */
|
||||
@@ -270,7 +274,8 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
// The reader's own, not whichever account is selected: a shared address
|
||||
// book is shown beside theirs rather than instead of it, so nothing here
|
||||
// should move when the switcher does.
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.contacts);
|
||||
// inbuxa AL-7: or the locked account in view, the whole of it
|
||||
const accountId = useSession.getState().viewAccountFor(CAP.contacts);
|
||||
const available = Boolean(accountId && client.hasCapability(CAP.contacts));
|
||||
if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, cardState: null, loaded: false, selection: { accountId: null, bookId: "all" } });
|
||||
set({ available });
|
||||
@@ -300,7 +305,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
*/
|
||||
async loadShared() {
|
||||
const session = useSession.getState();
|
||||
const own = session.ownAccountFor(CAP.contacts);
|
||||
const own = session.viewAccountFor(CAP.contacts);
|
||||
const s = session.session;
|
||||
const accounts = Object.entries(s?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
|
||||
if (!accounts.length) {
|
||||
@@ -615,6 +620,28 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
return { destroyed: gone.length, unfiled, refused };
|
||||
},
|
||||
|
||||
async collectRecipients(addrs, own) {
|
||||
if (!get().available || !get().accountId) return 0;
|
||||
// Every card has to be known, or someone already a contact gets a second card.
|
||||
if (!get().loaded) await get().loadAll();
|
||||
const ownSet = new Set(own.map((e) => e.toLowerCase()));
|
||||
const fresh = uniqueAddresses(addrs).filter((a) => a.email && !ownSet.has(a.email.toLowerCase()) && !get().lookupByEmail(a.email));
|
||||
if (!fresh.length) return 0;
|
||||
const accountId = get().accountId!;
|
||||
const { settings, update } = useSettings.getState();
|
||||
let bookId = settings.collectedBookId && get().books[settings.collectedBookId] ? settings.collectedBookId : null;
|
||||
if (!bookId) {
|
||||
bookId = await get().createBook(translate("Collected"));
|
||||
update({ collectedBookId: bookId });
|
||||
}
|
||||
const create = Object.fromEntries(
|
||||
fresh.map((a, i) => [`c${i}`, { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...contactFromAddress(a), addressBookIds: { [bookId!]: true } }]),
|
||||
);
|
||||
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create });
|
||||
await get().syncCards();
|
||||
return Object.keys(res.created ?? {}).length;
|
||||
},
|
||||
|
||||
async createBook(name) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
|
||||
@@ -792,14 +819,29 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
seen.add(k);
|
||||
out.push(s);
|
||||
};
|
||||
const score = (name: string | null, email: string): number => {
|
||||
/*
|
||||
* Words in any order: "jane smi" finds "Smith, Jane", and a nickname or the
|
||||
* organization counts as much as the name. Each word typed has to start a
|
||||
* word of the person's name, nickname, organization or address.
|
||||
*/
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
const wordsOf = (parts: Array<string | null | undefined>) =>
|
||||
parts.flatMap((p) => (p ?? "").toLowerCase().split(/[\s,.;:@_+()<>"'-]+/)).filter(Boolean);
|
||||
const score = (name: string | null, email: string, extra: Array<string | null | undefined> = []): number => {
|
||||
const n = (name ?? "").toLowerCase();
|
||||
const e = email.toLowerCase();
|
||||
if (e.startsWith(q) || n.startsWith(q)) return 0;
|
||||
if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1;
|
||||
const words = wordsOf([name, email, ...extra]);
|
||||
if (tokens.every((t) => words.some((w) => w.startsWith(t)))) return 1;
|
||||
if (e.includes(q) || n.includes(q)) return 2;
|
||||
return 99;
|
||||
};
|
||||
// Someone written to lately ranks a little above someone not, within the same kind of match.
|
||||
const recentRank = new Map(st.recent.map((r, i) => [r.email.toLowerCase(), i] as const));
|
||||
const recency = (email: string) => {
|
||||
const i = recentRank.get(email.toLowerCase());
|
||||
return i === undefined ? 0 : -0.3 * (1 - i / Math.max(1, st.recent.length));
|
||||
};
|
||||
const candidates: Array<Suggestion & { score: number }> = [];
|
||||
// A shared address book is only useful if it answers when you are writing
|
||||
// to someone in it, so its cards are offered alongside the reader's own.
|
||||
@@ -807,19 +849,20 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 }));
|
||||
const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 }));
|
||||
for (const { c, penalty } of [...own, ...shared]) {
|
||||
const extra = [...Object.values(c.nicknames ?? {}).map((x) => x.name), ...Object.values(c.organizations ?? {}).map((x) => x.name)];
|
||||
for (const a of contactEmails(c)) {
|
||||
const sc = score(a.name, a.email);
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty });
|
||||
const sc = score(a.name, a.email, extra);
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty + recency(a.email) });
|
||||
}
|
||||
}
|
||||
for (const p of st.principals) {
|
||||
if (!p.email) continue;
|
||||
const sc = score(p.name, p.email);
|
||||
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 });
|
||||
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 + recency(p.email) });
|
||||
}
|
||||
for (const r of st.recent) {
|
||||
const sc = score(r.name, r.email);
|
||||
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 });
|
||||
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 + recency(r.email) });
|
||||
}
|
||||
candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email));
|
||||
for (const c of candidates) {
|
||||
|
||||
@@ -139,13 +139,16 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
|
||||
async init() {
|
||||
const session = useSession.getState();
|
||||
const ownAccountId = session.ownAccountFor(CAP.filenode);
|
||||
// inbuxa AL-7: home is the locked account in view, the whole of it
|
||||
const ownAccountId = session.viewAccountFor(CAP.filenode);
|
||||
const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode));
|
||||
// Stay where the reader is if the session still offers that account;
|
||||
// whether it still holds files is `discoverShared`'s to say.
|
||||
// whether it still holds files is `discoverShared`'s to say. A new home
|
||||
// (a locked account opened or left) starts there.
|
||||
const browsing = get().accountId;
|
||||
const offered = Object.entries(session.session?.accounts ?? {}).some(([id, a]) => id === browsing && a.isPersonal === false);
|
||||
if (!(browsing && (browsing === ownAccountId || offered))) set(emptyForAccount(ownAccountId));
|
||||
const moved = ownAccountId !== get().ownAccountId;
|
||||
if (moved || !(browsing && (browsing === ownAccountId || offered))) set(emptyForAccount(ownAccountId));
|
||||
set({ available, ownAccountId });
|
||||
},
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ function offerArchiveFolder(retry: () => Promise<void>): void {
|
||||
|
||||
export const useMail = create<MailState>((set, get) => ({
|
||||
accountId: null,
|
||||
imagesShown: {},
|
||||
mailboxes: {},
|
||||
mailboxState: null,
|
||||
mailboxesLoaded: false,
|
||||
@@ -104,6 +105,15 @@ export const useMail = create<MailState>((set, get) => ({
|
||||
|
||||
setAccount(accountId) {
|
||||
if (accountId === get().accountId) return;
|
||||
// inbuxa AL-7: leaving the reader's own mail for a delegated account,
|
||||
// remember where theirs was, so new mail there is still announced
|
||||
const own = useSession.getState().accountId;
|
||||
const leaving = get().accountId;
|
||||
if (leaving && leaving === own && accountId && accountId !== own) {
|
||||
ownWhileAway = { accountId: own, inbox: get().roleId("inbox"), state: get().emailState };
|
||||
} else if (accountId === own) {
|
||||
ownWhileAway = null;
|
||||
}
|
||||
resetBodyOrder();
|
||||
snapshots.clear();
|
||||
set({
|
||||
@@ -866,6 +876,10 @@ export const useMail = create<MailState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
showImages(id) {
|
||||
set((s) => ({ imagesShown: { ...s.imagesShown, [id]: true } }));
|
||||
},
|
||||
|
||||
select(ids, on) {
|
||||
set((s) => {
|
||||
const next = { ...s.selected };
|
||||
@@ -1485,10 +1499,56 @@ function removeFromList(ids: Id[], set: (fn: (s: MailState) => Partial<MailState
|
||||
}
|
||||
|
||||
async function notifyNewMail(created: Id[], get: () => MailState) {
|
||||
const s = settings();
|
||||
const inbox = get().roleId("inbox");
|
||||
if (!inbox) return;
|
||||
const emails = await get().getEmails(created);
|
||||
announceNewMail(emails, inbox);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reader's own account while a delegated one is in view (inbuxa AL-7):
|
||||
* its inbox and how far its mail was seen, so what arrives there meanwhile
|
||||
* is still announced.
|
||||
*/
|
||||
let ownWhileAway: { accountId: Id; inbox: Id | null; state: string | null } | null = null;
|
||||
|
||||
/** New mail in the reader's own account, while a delegated one is in view. */
|
||||
export async function notifyOwnWhileAway(): Promise<void> {
|
||||
const away = ownWhileAway;
|
||||
if (!away?.inbox || !away.state) return;
|
||||
try {
|
||||
const changes = await client.call<ChangesResponse>("Email/changes", {
|
||||
accountId: away.accountId,
|
||||
sinceState: away.state,
|
||||
maxChanges: 50,
|
||||
});
|
||||
if (ownWhileAway !== away) return;
|
||||
away.state = changes.newState;
|
||||
if (!changes.created.length) return;
|
||||
const got = await client.call<GetResponse<Email>>("Email/get", {
|
||||
accountId: away.accountId,
|
||||
ids: changes.created,
|
||||
properties: LIST_PROPS,
|
||||
});
|
||||
announceNewMail(got.list, away.inbox);
|
||||
} catch {
|
||||
/* the next change tries again */
|
||||
}
|
||||
}
|
||||
|
||||
export function ownAccountAway(): Id | null {
|
||||
return ownWhileAway?.accountId ?? null;
|
||||
}
|
||||
|
||||
/** The reader's own inbox, whatever account is in view (inbuxa AL-7). */
|
||||
export function ownInboxId(): Id | null {
|
||||
const mail = useMail.getState();
|
||||
if (mail.accountId === useSession.getState().accountId) return mail.roleId("inbox");
|
||||
return ownWhileAway?.inbox ?? null;
|
||||
}
|
||||
|
||||
function announceNewMail(emails: Email[], inbox: Id) {
|
||||
const s = settings();
|
||||
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
|
||||
if (!fresh.length) return;
|
||||
if (s.notificationSound) playNewMailSound();
|
||||
@@ -1514,9 +1574,12 @@ async function notifyNewMail(created: Id[], get: () => MailState) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the store bound to the selected account. */
|
||||
/**
|
||||
* Keep the store bound to the account in view: a delegated account while one
|
||||
* is open (inbuxa AL-7), the reader's own otherwise.
|
||||
*/
|
||||
useSession.subscribe((s) => {
|
||||
useMail.getState().setAccount(s.status === "authenticated" ? s.accountId : null);
|
||||
useMail.getState().setAccount(s.status === "authenticated" ? (s.viewing ?? s.accountId) : null);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ export interface ListState extends ListQuery {
|
||||
|
||||
export interface MailState {
|
||||
accountId: Id | null;
|
||||
/**
|
||||
* Messages the reader pressed "Show images" on, this session. Kept here
|
||||
* rather than in the message view because replying quotes the message into
|
||||
* a second window, which has to honour the same decision.
|
||||
*/
|
||||
imagesShown: Record<Id, boolean>;
|
||||
mailboxes: Record<Id, Mailbox>;
|
||||
mailboxState: string | null;
|
||||
mailboxesLoaded: boolean;
|
||||
@@ -113,6 +119,8 @@ export interface MailState {
|
||||
saveVacation(patch: Partial<VacationResponse>): Promise<void>;
|
||||
loadQuota(): Promise<void>;
|
||||
|
||||
/** Remember that this message's remote images were allowed by hand. */
|
||||
showImages(id: Id): void;
|
||||
select(ids: Id[], on: boolean): void;
|
||||
clearSelection(): void;
|
||||
/** Refresh the per-label unread counts, in one request. */
|
||||
|
||||
@@ -6,9 +6,12 @@ import { accountForCapability, ownAccountForCapability } from "@/lib/accountRout
|
||||
import { setServerLocale } from "@/lib/datetime";
|
||||
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
||||
import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild";
|
||||
import { unsubscribeThisDevice } from "@/lib/notify/webpush";
|
||||
import { unsubscribeAccount, unsubscribeThisDevice } from "@/lib/notify/webpush";
|
||||
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
|
||||
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
|
||||
import { delegationOf, type Delegation } from "@/lib/delegation";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail";
|
||||
|
||||
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
||||
|
||||
@@ -17,27 +20,101 @@ interface SessionState {
|
||||
session: JmapSession | null;
|
||||
/** Selected mail account (defaults to primary). */
|
||||
accountId: Id | null;
|
||||
/**
|
||||
* Another account whose mail the app shows instead of the reader's own:
|
||||
* a locked account handed to them (inbuxa AL-7), whose calendar, contacts
|
||||
* and files follow, or a shared or group mailbox (MA-A), which is mail
|
||||
* only. The reader's settings, filters, signatures and push stay their own.
|
||||
*/
|
||||
viewing: Id | null;
|
||||
/** Shared and group mailboxes the reader can open (MA-A); see lib/sharedMail. */
|
||||
sharedMail: SharedMailAccount[];
|
||||
/** inbuxa MA-B: the accounts signed in in this browser, the one in front first. */
|
||||
signedIn: SignedInAccount[];
|
||||
/** Whether one more may be added (the cap, and both organizations' addAccounts). */
|
||||
canAddAccount: boolean;
|
||||
/** The name of an account the reader lost while it was in view. */
|
||||
delegationEnded: string | null;
|
||||
error: string | null;
|
||||
pushConnected: boolean;
|
||||
/** Finer than pushConnected: tells "reconnecting" from "not connected". */
|
||||
pushState: PushState;
|
||||
bootstrap(): Promise<void>;
|
||||
login(username: string, password: string, totp: string, remember: boolean): Promise<void>;
|
||||
/** Signs out of the account in front; another signed-in one comes forward. */
|
||||
logout(): Promise<void>;
|
||||
/** inbuxa MA-B: ends every account signed in in this browser. */
|
||||
logoutAll(): Promise<void>;
|
||||
/** inbuxa MA-B: finds the other accounts signed in here. */
|
||||
loadSignedIn(): Promise<void>;
|
||||
/** inbuxa MA-B: brings another signed-in account to the front, and reloads. */
|
||||
switchTo(sessionId: string): Promise<void>;
|
||||
refresh(): Promise<void>;
|
||||
setAccount(id: Id): void;
|
||||
/** Show a delegated account's or shared mailbox's mail, or the reader's own with null. */
|
||||
view(id: Id | null): void;
|
||||
/** Finds the shared and group mailboxes the reader can open. */
|
||||
loadSharedMail(): Promise<void>;
|
||||
clearDelegationEnded(): void;
|
||||
/** The account to read and write for a capability, honoring the account switcher. */
|
||||
accountFor(cap: string): Id | null;
|
||||
/** The user's own account for a capability, whatever they are looking at. */
|
||||
ownAccountFor(cap: string): Id | null;
|
||||
/**
|
||||
* inbuxa AL-7: the account calendar, contacts and files show: the locked
|
||||
* account in view, if it offers `cap`, else the reader's own. Never for
|
||||
* anything the reader keeps (settings, signatures, push): those stay
|
||||
* `ownAccountFor`.
|
||||
*/
|
||||
viewAccountFor(cap: string): Id | null;
|
||||
}
|
||||
|
||||
let refreshing: Promise<void> | null = null;
|
||||
|
||||
/** What a signed-in account looks like in the switcher (MA-B). */
|
||||
export interface SignedInAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
front: boolean;
|
||||
/** Its mail account, for its push subscription (MA-8); null when not known yet. */
|
||||
mailAccountId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: a notification for an account not in front opens
|
||||
* `?account=<session>&next=<where>`: bring that account forward, then go
|
||||
* there. Only a path inside the app is followed.
|
||||
*/
|
||||
// Read as the app starts: the router sends `/` on to `/mail` without its query.
|
||||
let launchParams: URLSearchParams | null = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
|
||||
|
||||
async function openFromNotification(accounts: SignedInAccount[]): Promise<void> {
|
||||
const params = launchParams;
|
||||
launchParams = null;
|
||||
const wanted = params?.get("account");
|
||||
if (!params || !wanted) return;
|
||||
const raw = params.get("next") ?? "";
|
||||
const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : withBase("/mail");
|
||||
const account = accounts.find((a) => a.id === wanted);
|
||||
if (account && !account.front) {
|
||||
await apiFetch(`/api/auth/accounts/${encodeURIComponent(wanted)}/front`, { method: "POST" });
|
||||
clearSignedInData();
|
||||
}
|
||||
window.location.replace(next);
|
||||
}
|
||||
|
||||
/** Which sign-out: the account in front, or every one (MA-B). */
|
||||
let signOutPath = "/api/auth/logout";
|
||||
|
||||
export const useSession = create<SessionState>((set, get) => ({
|
||||
status: "loading",
|
||||
session: null,
|
||||
accountId: null,
|
||||
viewing: null,
|
||||
sharedMail: [],
|
||||
signedIn: [],
|
||||
canAddAccount: false,
|
||||
delegationEnded: null,
|
||||
error: null,
|
||||
pushConnected: false,
|
||||
pushState: "disconnected",
|
||||
@@ -75,7 +152,19 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
// without removing it leaves this browser notifying for a mailbox nobody is
|
||||
// signed into. On a shared machine that is somebody else's mail.
|
||||
try {
|
||||
await unsubscribeThisDevice();
|
||||
const everyone = signOutPath.endsWith("logout-all");
|
||||
const othersRemain = !everyone && get().signedIn.some((a) => !a.front);
|
||||
if (everyone) {
|
||||
// inbuxa MA-8: every account's subscription goes, the others' first
|
||||
const { unsubscribeOtherAccounts } = await import("@/lib/notify/webpushEnable");
|
||||
await unsubscribeOtherAccounts();
|
||||
}
|
||||
if (othersRemain) {
|
||||
// inbuxa MA-8: only this account's; the browser keeps notifying for the rest
|
||||
await unsubscribeAccount(undefined, get().ownAccountFor(CAP.mail));
|
||||
} else {
|
||||
await unsubscribeThisDevice();
|
||||
}
|
||||
} catch {
|
||||
/* never block signing out over this */
|
||||
}
|
||||
@@ -88,8 +177,11 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
} catch {
|
||||
/* never block signing out over this */
|
||||
}
|
||||
const path = signOutPath;
|
||||
signOutPath = "/api/auth/logout";
|
||||
let next = false;
|
||||
try {
|
||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||
next = Boolean((await apiFetch<{ next?: boolean }>(path, { method: "POST" }))?.next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -99,7 +191,35 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
// problem next -- and the address book cached here is the same argument.
|
||||
clearSignedInData();
|
||||
client.session = null;
|
||||
set({ status: "anonymous", session: null, accountId: null });
|
||||
// inbuxa MA-B: another signed-in account is in front now
|
||||
if (next) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [], signedIn: [], canAddAccount: false });
|
||||
},
|
||||
|
||||
async logoutAll() {
|
||||
// The same care as signing out of one, then every account ends
|
||||
signOutPath = "/api/auth/logout-all";
|
||||
await get().logout();
|
||||
},
|
||||
|
||||
async loadSignedIn() {
|
||||
try {
|
||||
const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts");
|
||||
set({ signedIn: answer.accounts, canAddAccount: answer.canAdd });
|
||||
await openFromNotification(answer.accounts);
|
||||
} catch {
|
||||
set({ signedIn: [], canAddAccount: false });
|
||||
}
|
||||
},
|
||||
|
||||
async switchTo(sessionId) {
|
||||
await apiFetch(`/api/auth/accounts/${encodeURIComponent(sessionId)}/front`, { method: "POST" });
|
||||
// What was cached belongs to the account that was in front
|
||||
clearSignedInData();
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
refresh() {
|
||||
@@ -109,7 +229,16 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
|
||||
client.session = s;
|
||||
setServerLocale(s.ihasmail?.userLocale);
|
||||
set({ session: s });
|
||||
// A delegation that ended, or a shared mailbox taken away, takes the
|
||||
// reader back to their own mail
|
||||
const viewing = get().viewing;
|
||||
if (viewing && !delegationOf(s, viewing) && !sharedMailCandidates(s).some((a) => a.id === viewing)) {
|
||||
const name = get().session?.accounts[viewing]?.name ?? null;
|
||||
set({ session: s, viewing: null, delegationEnded: name });
|
||||
} else {
|
||||
set({ session: s });
|
||||
}
|
||||
void get().loadSharedMail();
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
@@ -123,6 +252,23 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
set({ accountId: id });
|
||||
},
|
||||
|
||||
view(id) {
|
||||
if (id && !delegationOf(get().session, id) && !get().sharedMail.some((a) => a.id === id)) return;
|
||||
if (id === get().viewing) return;
|
||||
set({ viewing: id });
|
||||
},
|
||||
|
||||
async loadSharedMail() {
|
||||
const session = get().session;
|
||||
const found = await findSharedMail(session);
|
||||
// A sign-out or another account's session arrived while it was asking
|
||||
if (get().session === session) set({ sharedMail: found });
|
||||
},
|
||||
|
||||
clearDelegationEnded() {
|
||||
set({ delegationEnded: null });
|
||||
},
|
||||
|
||||
accountFor(cap) {
|
||||
return accountForCapability(get().session, get().accountId, cap);
|
||||
},
|
||||
@@ -130,6 +276,14 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
ownAccountFor(cap) {
|
||||
return ownAccountForCapability(get().session, cap);
|
||||
},
|
||||
|
||||
viewAccountFor(cap) {
|
||||
const { session, viewing } = get();
|
||||
const viewed = viewing ? session?.accounts[viewing] : undefined;
|
||||
// A shared or group mailbox in view is mail only (MA-A, MA-S)
|
||||
if (viewing && viewed && delegationOf(session, viewing)?.kind === "lock" && cap in (viewed.accountCapabilities ?? {})) return viewing;
|
||||
return ownAccountForCapability(session, cap);
|
||||
},
|
||||
}));
|
||||
|
||||
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
|
||||
@@ -149,7 +303,9 @@ function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
|
||||
startIdleLogout(() => void useSession.getState().logout());
|
||||
}
|
||||
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
|
||||
set({ status: "authenticated", session: s, accountId, error: null });
|
||||
set({ status: "authenticated", session: s, accountId, viewing: null, sharedMail: [], error: null });
|
||||
void useSession.getState().loadSharedMail();
|
||||
void useSession.getState().loadSignedIn();
|
||||
}
|
||||
|
||||
client.onUnauthenticated(() => {
|
||||
@@ -162,12 +318,31 @@ client.onUnauthenticated(() => {
|
||||
// usual reason to be signed out here, and reloading a form someone has
|
||||
// already started typing into would throw the password away.
|
||||
void reloadIfServerRebuilt().then((reloading) => {
|
||||
if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null });
|
||||
if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] });
|
||||
});
|
||||
});
|
||||
|
||||
push.onConnection((state) => useSession.setState({ pushConnected: state === "connected", pushState: state }));
|
||||
|
||||
/** The delegation of the locked account in view, if one is (inbuxa AL-6). */
|
||||
export function useViewingDelegation(): Delegation | null {
|
||||
const session = useSession((s) => s.session);
|
||||
const viewing = useSession((s) => s.viewing);
|
||||
return delegationOf(session, viewing);
|
||||
}
|
||||
|
||||
/** The shared or group mailbox in view, if one is (MA-A). */
|
||||
export function useViewingShared(): SharedMailAccount | null {
|
||||
const viewing = useSession((s) => s.viewing);
|
||||
const sharedMail = useSession((s) => s.sharedMail);
|
||||
return (viewing && sharedMail.find((a) => a.id === viewing)) || null;
|
||||
}
|
||||
|
||||
export function viewingDelegation(): Delegation | null {
|
||||
const s = useSession.getState();
|
||||
return delegationOf(s.session, s.viewing);
|
||||
}
|
||||
|
||||
export function hasCap(cap: string): boolean {
|
||||
return client.hasCapability(cap);
|
||||
}
|
||||
@@ -206,6 +206,15 @@ export interface Settings {
|
||||
* dates nobody put there is a surprise rather than a feature.
|
||||
*/
|
||||
birthdayCalendar: boolean;
|
||||
/**
|
||||
* Save everyone written to who is not a contact yet, in an address book of
|
||||
* its own, so they are suggested on every device rather than only in the
|
||||
* browser that sent the message. On by default, as mail clients do; the
|
||||
* address book can be emptied or deleted like any other.
|
||||
*/
|
||||
collectRecipients: boolean;
|
||||
/** The address book collected recipients go to, once there is one. */
|
||||
collectedBookId: string | null;
|
||||
/**
|
||||
* Calendars subscribed to by URL. The subscription is the setting; the
|
||||
* events themselves are fetched on demand and never stored, so this follows
|
||||
@@ -361,6 +370,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
timeZone: null,
|
||||
labelsSidebar: true,
|
||||
birthdayCalendar: false,
|
||||
collectRecipients: true,
|
||||
collectedBookId: null,
|
||||
icalSubscriptions: [],
|
||||
listSortPreset: "newest",
|
||||
listSortLevels: [],
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Read off disk: `?raw` comes back empty for a stylesheet under the test runner.
|
||||
const css = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../app.css"), "utf8");
|
||||
|
||||
/*
|
||||
* The app rail, the collapsed sidebar and the raised surfaces are all CSS, and
|
||||
* jsdom does no layout, so these pin the rules themselves. Contrast for the
|
||||
* pill accent was measured in a browser across every palette, accent and
|
||||
* mode; the shape of that rule is pinned here.
|
||||
*/
|
||||
|
||||
const rule = (selector: string) => {
|
||||
const i = css.indexOf(selector + " {");
|
||||
expect(i, `no rule for ${selector}`).toBeGreaterThan(-1);
|
||||
return css.slice(i, css.indexOf("}", i));
|
||||
};
|
||||
|
||||
describe("app layout CSS", () => {
|
||||
it("gives the rail its own column ahead of the sidebar, collapsed or not", () => {
|
||||
expect(rule(".app-body.has-rail")).toMatch(/grid-template-columns:\s*56px var\(--sidebar-w\)/);
|
||||
expect(rule(".app-body.has-rail.collapsed")).toMatch(/grid-template-columns:\s*56px var\(--sidebar-w-collapsed\)/);
|
||||
});
|
||||
|
||||
it("hides a sidebar with no icon-only form instead of crushing it", () => {
|
||||
expect(rule(".app-body.has-rail.sidebar-hidden")).toMatch(/grid-template-columns:\s*56px 0 /);
|
||||
expect(rule(".app-body.sidebar-hidden > .sidebar")).toMatch(/visibility:\s*hidden/);
|
||||
});
|
||||
|
||||
it("hides section headings when collapsed, over the sidebar's own display rule", () => {
|
||||
// `.sidebar .nav-section { display: flex }` comes later at the same weight;
|
||||
// the collapsed rule has to outweigh it or "FOLDERS" is drawn cut off.
|
||||
expect(css).toMatch(/\.app-body\.collapsed \.sidebar \.nav-section[^{]*\{\s*display:\s*none/);
|
||||
});
|
||||
|
||||
it("darkens the pill accent in light themes and lightens it in dark ones", () => {
|
||||
expect(css).toMatch(/:root \{ --pill-accent: color-mix\(in srgb, var\(--accent\) \d+%, black\); \}/);
|
||||
expect(css).toMatch(/:root\[data-theme="dark"\] \{ --pill-accent: color-mix\(in srgb, var\(--accent\) \d+%, white\); \}/);
|
||||
});
|
||||
|
||||
it("lets a color the reader picked win over the role tint", () => {
|
||||
for (const role of ["inbox", "drafts", "sent", "archive", "junk", "trash", "scheduled"]) {
|
||||
expect(css).toContain(`.folder-icon[data-role="${role}"]:not([style*="--folder-color"]) svg`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1248,6 +1248,24 @@ a.menu-item:hover { color: var(--fg); }
|
||||
.topbar .brand img { width: 34px; height: 34px; object-fit: contain; }
|
||||
.topbar .brand .brand-name { color: #14b8a6; }
|
||||
.topbar .brand .brand-name span { color: var(--fg-muted); font-weight: 500; }
|
||||
/*
|
||||
* inbuxa AL-7: a locked account's mail in view. The bar is its own grid row
|
||||
* above the top bar; its red is fixed, never the palette's, so it reads the
|
||||
* same in every palette and mode and can't pass for part of a theme (white on
|
||||
* red-700 is 6.5:1). The wordmark turns red too, with a padlock beside it.
|
||||
*/
|
||||
.app.delegated { grid-template-rows: auto var(--topbar-h) 1fr; }
|
||||
.delegated-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #b91c1c; color: #fff; font-size: .9em; min-width: 0; }
|
||||
.delegated-bar strong { font-weight: 700; }
|
||||
/* MA-A: a shared or group mailbox is the reader's to be in, so the palette's own color, not the locked account's red. */
|
||||
.delegated-bar.shared { background: var(--accent); color: var(--accent-fg); }
|
||||
/* MA-8: new mail in another signed-in account, on the avatar */
|
||||
.acct-dot { position: absolute; top: 0; right: 0; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); }
|
||||
.delegated-bar button { flex: none; border: 1px solid rgba(255, 255, 255, .7); background: transparent; color: #fff; border-radius: 999px; padding: 3px 12px; font: inherit; font-weight: 600; cursor: pointer; }
|
||||
.delegated-bar button:hover, .delegated-bar button:focus-visible { background: rgba(255, 255, 255, .15); }
|
||||
.topbar .brand.locked .brand-name, .topbar .brand.locked .brand-lock { color: #dc2626; }
|
||||
[data-theme="dark"] .topbar .brand.locked .brand-name, [data-theme="dark"] .topbar .brand.locked .brand-lock { color: #f87171; }
|
||||
.topbar .brand .brand-lock { flex: none; }
|
||||
/* `min-width: 0`, or the flex default of `auto` holds the search field at its
|
||||
own min-content and the account buttons beside it are pushed off a phone. */
|
||||
.searchbar { flex: 1 1 auto; min-width: 0; max-width: 720px; margin: 0 auto; position: relative; }
|
||||
@@ -1302,7 +1320,7 @@ a.menu-item:hover { color: var(--fg); }
|
||||
.collapsed .compose-btn span { display: none; }
|
||||
.nav-section { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 4px; font-size: .75em; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--fg-faint); }
|
||||
.nav-section .icon-btn { width: 24px; height: 24px; }
|
||||
.collapsed .nav-section { display: none; }
|
||||
.collapsed .nav-section, .app-body.collapsed .sidebar .nav-section { display: none; }
|
||||
.nav-item { position: relative; display: flex; align-items: center; gap: 12px; height: 36px; padding: 0 12px; border-radius: 0 999px 999px 0; margin-right: 8px; color: var(--fg); text-decoration: none; white-space: nowrap; transition: background .12s; user-select: none; font-weight: 450; }
|
||||
.nav-item:hover { background: var(--bg-hover); }
|
||||
.nav-item.unread .nav-label, .nav-item.unread .nav-count { font-weight: 700; color: var(--fg); }
|
||||
@@ -1369,6 +1387,82 @@ a.menu-item:hover { color: var(--fg); }
|
||||
.collapsed .nav-item .nav-dot { position: absolute; top: 6px; right: 6px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||
.nav-label-color { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
|
||||
/* Outlook-style module bar at the bottom of the sidebar */
|
||||
/* App rail. A fixed column left of the sidebar on wide screens; phones keep the tab bar. */
|
||||
.app-body.has-rail { grid-template-columns: 56px var(--sidebar-w) minmax(0, 1fr); }
|
||||
.app-body.has-rail.collapsed { grid-template-columns: 56px var(--sidebar-w-collapsed) minmax(0, 1fr); }
|
||||
.app-body.has-rail > .sidebar-splitter { left: calc(56px + var(--sidebar-w) - 3px); }
|
||||
/* Only Mail's folder list has an icon-only form. Calendar, Contacts, Files, Settings and Admin
|
||||
hide their sidebar outright when collapsed; the rail still navigates and its toggle brings it back. */
|
||||
.app-body.has-rail.sidebar-hidden { grid-template-columns: 56px 0 minmax(0, 1fr); }
|
||||
.app-body.sidebar-hidden > .sidebar { visibility: hidden; padding: 0; min-width: 0; }
|
||||
.app-rail { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 0 12px; border-right: 1px solid var(--border); min-height: 0; }
|
||||
.app-rail-spacer { flex: 1; }
|
||||
.rail-toggle { background: none; border: 0; cursor: pointer; margin-top: 4px; }
|
||||
.rail-link { position: relative; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 12px; color: var(--fg-muted); transition: background .12s, color .12s; }
|
||||
.rail-link:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
.rail-link.active { background: var(--accent-soft); color: var(--accent-soft-fg); }
|
||||
.rail-badge { position: absolute; top: -3px; right: -5px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--pill-accent); color: var(--accent-fg); font-size: 10.5px; font-weight: 700; line-height: 18px; text-align: center; box-shadow: 0 0 0 2px var(--bg); }
|
||||
|
||||
/* Pill counts. Accent-filled when the folder has unread mail, neutral for totals (Drafts, Scheduled). */
|
||||
.nav-item .nav-count { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; height: 20px; padding: 0 7px; border-radius: 999px; background: var(--bg-active); color: var(--fg-muted); font-size: .74em; font-weight: 650; font-variant-numeric: tabular-nums; }
|
||||
/* The accent as a fill behind small text (pills, the rail badge, Compose). The plain accent is
|
||||
a mid tone that white text does not read on in light themes, so it is darkened there and
|
||||
lightened a touch in dark ones: at least 4.5:1 in all 12 palettes x 6 accents x 2 modes,
|
||||
measured 2026-10-05 (lowest 4.83). */
|
||||
:root { --pill-accent: color-mix(in srgb, var(--accent) 70%, black); }
|
||||
:root[data-theme="dark"] { --pill-accent: color-mix(in srgb, var(--accent) 88%, white); }
|
||||
.nav-item.unread .nav-count { background: var(--pill-accent); color: var(--accent-fg); font-weight: 700; }
|
||||
.nav-item.active .nav-count, .nav-item.active.unread .nav-count { background: var(--pill-accent); color: var(--accent-fg); box-shadow: 0 1px 2px rgb(0 0 0 / .25); }
|
||||
.collapsed .nav-item .nav-count { display: none; }
|
||||
|
||||
/* Raised surfaces. A top-lit sheen, a 1px inner highlight on top and shade below, and a small drop,
|
||||
so buttons and the current selection read as pieces standing off the page. Tuned per theme lightness. */
|
||||
:root {
|
||||
--raise-sheen: linear-gradient(to bottom, rgb(255 255 255 / .30), rgb(255 255 255 / .04) 50%, rgb(0 0 0 / .06));
|
||||
--raise-sheen-strong: linear-gradient(to bottom, rgb(255 255 255 / .32), rgb(255 255 255 / .06) 50%, rgb(0 0 0 / .14));
|
||||
--raise-hi: rgb(255 255 255 / .75);
|
||||
--raise-lo: rgb(0 0 0 / .12);
|
||||
--raise-drop: 0 1px 2px rgb(0 0 0 / .14), 0 3px 8px rgb(0 0 0 / .09);
|
||||
--raise-press: inset 0 1px 2px rgb(0 0 0 / .14);
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--raise-sheen: linear-gradient(to bottom, rgb(255 255 255 / .14), rgb(255 255 255 / .02) 50%, rgb(0 0 0 / .16));
|
||||
--raise-sheen-strong: linear-gradient(to bottom, rgb(255 255 255 / .30), rgb(255 255 255 / .06) 50%, rgb(0 0 0 / .22));
|
||||
--raise-hi: rgb(255 255 255 / .16);
|
||||
--raise-lo: rgb(0 0 0 / .45);
|
||||
--raise-drop: 0 1px 2px rgb(0 0 0 / .55), 0 3px 10px rgb(0 0 0 / .32);
|
||||
--raise-press: inset 0 1px 3px rgb(0 0 0 / .45);
|
||||
}
|
||||
.btn, .compose-btn, .rail-link.active, .nav-item.active, .module-link.active, .segmented button.active {
|
||||
background-image: var(--raise-sheen);
|
||||
box-shadow: inset 0 1px 0 var(--raise-hi), inset 0 -1px 0 var(--raise-lo), var(--raise-drop);
|
||||
}
|
||||
.btn-primary, .rail-badge { background-image: var(--raise-sheen-strong); }
|
||||
.compose-btn, .compose-btn:hover { background-color: var(--pill-accent); color: var(--accent-fg); background-image: var(--raise-sheen-strong); }
|
||||
.compose-btn svg { color: inherit; }
|
||||
.compose-btn, .rail-link.active { box-shadow: inset 0 1px 0 rgb(255 255 255 / .28), inset 0 -1px 0 rgb(0 0 0 / .22), var(--raise-drop); }
|
||||
.compose-btn:hover { box-shadow: inset 0 1px 0 rgb(255 255 255 / .28), inset 0 -1px 0 rgb(0 0 0 / .22), var(--shadow-2); filter: brightness(1.05); }
|
||||
.rail-link.active, .rail-link.active:hover { background-color: var(--accent); color: var(--accent-fg); background-image: var(--raise-sheen-strong); }
|
||||
.btn-primary { box-shadow: inset 0 1px 0 rgb(255 255 255 / .22), inset 0 -1px 0 rgb(0 0 0 / .18), var(--raise-drop); }
|
||||
.btn:active, .compose-btn:active, .rail-link:active, .nav-item:active, .module-link:active, .segmented button:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: var(--raise-press);
|
||||
background-image: none;
|
||||
}
|
||||
/* The segmented control clips its children; the raise sits inside its border. */
|
||||
.segmented button.active { box-shadow: inset 0 1px 0 var(--raise-hi), inset 0 -1px 0 var(--raise-lo); }
|
||||
|
||||
/* Tinted role-folder icons. A color the reader picked for a folder still wins (style attribute, fill rule above). */
|
||||
:root { --role-inbox: #2f6fe4; --role-drafts: #8a5cd6; --role-sent: #138a6c; --role-archive: #976214; --role-junk: #b8501c; --role-trash: #c93c4b; --role-scheduled: #0f8aa6; }
|
||||
:root[data-theme="dark"] { --role-inbox: #6ea2ff; --role-drafts: #b796f5; --role-sent: #4fcfa6; --role-archive: #e5b45a; --role-junk: #f2925f; --role-trash: #f07a87; --role-scheduled: #4fc3dc; }
|
||||
.folder-row .folder-icon[data-role="inbox"]:not([style*="--folder-color"]) svg { color: var(--role-inbox); }
|
||||
.folder-row .folder-icon[data-role="drafts"]:not([style*="--folder-color"]) svg { color: var(--role-drafts); }
|
||||
.folder-row .folder-icon[data-role="sent"]:not([style*="--folder-color"]) svg { color: var(--role-sent); }
|
||||
.folder-row .folder-icon[data-role="archive"]:not([style*="--folder-color"]) svg { color: var(--role-archive); }
|
||||
.folder-row .folder-icon[data-role="junk"]:not([style*="--folder-color"]) svg { color: var(--role-junk); }
|
||||
.folder-row .folder-icon[data-role="trash"]:not([style*="--folder-color"]) svg { color: var(--role-trash); }
|
||||
.folder-row .folder-icon[data-role="scheduled"]:not([style*="--folder-color"]) svg { color: var(--role-scheduled); }
|
||||
|
||||
.module-bar { display: flex; align-items: stretch; justify-content: space-around; gap: 2px; padding: 6px 4px 4px; border-top: 1px solid var(--border); flex: 0 0 auto; }
|
||||
.module-link { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; flex: 1; min-width: 0; padding: 6px 2px; border-radius: var(--radius-sm); color: var(--fg-muted); text-decoration: none; font-size: 11px; font-weight: 500; transition: background .12s, color .12s; }
|
||||
.module-link:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
@@ -1461,6 +1555,11 @@ a.menu-item:hover { color: var(--fg); }
|
||||
.mail-list.two-line .msg-row .msg-from { width: auto; flex: 1; }
|
||||
.mail-list.two-line .msg-row .msg-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.mail-list.two-line .msg-row .msg-line1 { display: flex; align-items: center; gap: 8px; }
|
||||
/* Labels share the sender's line (#35). The sender gives way first, then the
|
||||
labels, each of which truncates rather than pushing the date off the row. */
|
||||
.mail-list.two-line .msg-row .msg-line1 .msg-from { flex: 0 1 auto; min-width: 3em; }
|
||||
.mail-list.two-line .msg-row .msg-line1 .msg-labels { flex: 0 1 auto; min-width: 0; max-width: 55%; overflow: hidden; }
|
||||
.mail-list.two-line .msg-row .msg-line1 .msg-labels .tag { flex: 0 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; display: block; line-height: 18px; }
|
||||
.mail-list.two-line .msg-row .msg-main { display: flex; gap: 6px; }
|
||||
.mail-list.two-line .msg-row .msg-subject { flex: 0 1 auto; }
|
||||
.mail-list.two-line .msg-row .msg-preview { flex: 1; }
|
||||
@@ -1570,6 +1669,12 @@ a.menu-item:hover { color: var(--fg); }
|
||||
.composer-head .title { flex: 1; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.composer-head .status { color: var(--fg-faint); font-size: .8em; margin-right: 6px; white-space: nowrap; }
|
||||
.composer-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
/* An offer the draft makes about itself, above the editor: quiet, one line, and dismissible. */
|
||||
.composer-notice { display: flex; align-items: center; gap: 8px; padding: 6px 10px 6px 14px; background: var(--accent-soft); color: var(--accent-soft-fg); border-bottom: 1px solid var(--border); font-size: .85em; flex: 0 0 auto; }
|
||||
.composer-notice span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* inbuxa: a DLP warning or block on the last send */
|
||||
.composer-notice-dlp { background: var(--warn-soft); color: var(--fg); }
|
||||
.composer-notice-dlp span { white-space: normal; }
|
||||
.composer-fields { flex: 0 0 auto; padding: 0 12px; }
|
||||
.composer-field { display: flex; align-items: center; gap: 8px; min-height: 40px; border-bottom: 1px solid var(--border); padding: 4px 0; }
|
||||
.composer-field > label { color: var(--fg-muted); width: 42px; flex: 0 0 auto; font-size: .92em; }
|
||||
@@ -1602,6 +1707,14 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
||||
.editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; }
|
||||
.editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); }
|
||||
.editor-area img { max-width: 100%; height: auto; }
|
||||
/* Image resizing (Gitea issue #26): drawn over the editor, never inside it, so none of it is sent. */
|
||||
.img-resize-layer { position: absolute; pointer-events: none; overflow: hidden; z-index: 5; }
|
||||
.img-resize-frame { position: absolute; outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
|
||||
.img-resize-handle { position: absolute; right: -7px; bottom: -7px; width: 14px; height: 14px; background: var(--accent); border: 2px solid var(--bg-elev); border-radius: 3px; cursor: nwse-resize; pointer-events: auto; touch-action: none; }
|
||||
.img-resize-bar { position: absolute; display: flex; gap: 4px; padding: 3px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-sm); box-shadow: var(--shadow-2); pointer-events: auto; }
|
||||
.img-resize-bar .btn-sm { height: 26px; padding: 0 8px; }
|
||||
.img-resize-bar .btn[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
@media (pointer: coarse) { .img-resize-handle { width: 24px; height: 24px; right: -12px; bottom: -12px; border-radius: 50%; } }
|
||||
.editor-area a { color: var(--link); }
|
||||
.editor-area .ihm-signature { color: var(--fg-muted); }
|
||||
.editor-area pre { font-family: var(--font-mono); background: var(--bg-sunken); padding: 8px; border-radius: 6px; overflow: auto; }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The INBUXA wordmark, "inbuxa" in Space Grotesk Bold, as vector paths in the
|
||||
* current text color, so it reads on every palette, light or dark. The mark
|
||||
* beside it is `/img/inbuxa-mark.png`: ihasmail's own cat and envelope, which
|
||||
* INBUXA's logo reuses unchanged. The paths are the same ones INBUXA Admin
|
||||
* draws, from the INBUXA logo bundle.
|
||||
* beside it is `/img/inbuxa-mark.png`: inbuxa's own kitten, the family's cat
|
||||
* over a server with a bay for each piece of the suite. The paths are the
|
||||
* same ones inbuxa Admin draws, from the inbuxa logo bundle.
|
||||
*
|
||||
* ihasmail-inbuxa only. Public ihasmail keeps its own name.
|
||||
*/
|
||||
|
||||