Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2f13d6a1a | ||
|
|
afb39fac20 | ||
|
|
b514dab62a | ||
|
|
2d7d8952ab | ||
|
|
e752d08ebc | ||
|
|
dd955a939e | ||
|
|
26cf45f502 | ||
|
|
7469598178 | ||
|
|
f0a92deb08 | ||
|
|
8abf3a96aa | ||
|
|
acb93a90a6 | ||
|
|
4cac9088dd | ||
|
|
1cde6f3032 | ||
|
|
e5f590978e | ||
|
|
cf93a1697f | ||
|
|
17f0552453 | ||
|
|
461129c5d6 | ||
|
|
95f7f8008e | ||
|
|
097d08900c | ||
|
|
de120ba7ca | ||
|
|
25763832f3 | ||
|
|
a2ba7f5acd | ||
|
|
fdcf27f3ea | ||
|
|
9e3844e94a | ||
|
|
f8119fafbf | ||
|
|
8a7ff5d42f | ||
|
|
c201d34377 | ||
|
|
05d1645ab7 | ||
|
|
cfa661de20 | ||
|
|
cee4f74257 | ||
|
|
bb25355c23 | ||
|
|
9d2de725c9 | ||
|
|
091782ae3a | ||
|
|
2e668edb51 | ||
|
|
cdd8fabff9 | ||
|
|
8857bdac30 |
+14
-13
@@ -1,8 +1,8 @@
|
|||||||
# ---- ihasmail server configuration ----
|
# ---- ihasmail server configuration ----
|
||||||
|
|
||||||
# Base URL of your Stalwart server (scheme + host, no path). ihasmail discovers
|
# Base URL of your mail server (scheme + host, no path). ihasmail discovers
|
||||||
# the JMAP session at <STALWART_URL>/.well-known/jmap.
|
# the JMAP session at <MAIL_SERVER_URL>/.well-known/jmap.
|
||||||
STALWART_URL=https://mail.example.com
|
MAIL_SERVER_URL=https://mail.example.com
|
||||||
|
|
||||||
# Random secret used to derive encryption keys for persisted sessions.
|
# Random secret used to derive encryption keys for persisted sessions.
|
||||||
# Generate with: openssl rand -base64 48
|
# Generate with: openssl rand -base64 48
|
||||||
@@ -61,10 +61,10 @@ MAX_UPLOAD_BYTES=52428800
|
|||||||
# Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly.
|
# Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly.
|
||||||
IMAGE_PROXY=1
|
IMAGE_PROXY=1
|
||||||
|
|
||||||
# In-app administration, for accounts whose Stalwart role manages accounts and
|
# In-app administration, for accounts whose role on the mail server manages accounts and
|
||||||
# domains. 0 turns it off for everyone: no menu, and the JMAP proxy refuses
|
# domains. 0 turns it off for everyone: no menu, and the JMAP proxy refuses
|
||||||
# Stalwart's registry methods beyond an account's own password, app passwords
|
# the mail server's registry methods beyond an account's own password, app passwords
|
||||||
# and settings. Stalwart's own admin interface is not affected.
|
# and settings. INBUXA Admin is not affected.
|
||||||
ADMINISTRATION=1
|
ADMINISTRATION=1
|
||||||
|
|
||||||
# Branding
|
# Branding
|
||||||
@@ -73,8 +73,9 @@ APP_NAME=ihasmail
|
|||||||
# Where this instance's source can be had. ihasmail is AGPL-3.0-or-later, which
|
# Where this instance's source can be had. ihasmail is AGPL-3.0-or-later, which
|
||||||
# asks whoever runs a modified version to offer *that* version's source -- so if
|
# asks whoever runs a modified version to offer *that* version's source -- so if
|
||||||
# you have patched it, point this at your own tree. Shown on the sign-in page
|
# you have patched it, point this at your own tree. Shown on the sign-in page
|
||||||
# and in Settings > About.
|
# and in Settings > About. INBUXA's webmail is itself a modified ihasmail, so
|
||||||
SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
|
# the default is this fork.
|
||||||
|
SOURCE_URL=https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa
|
||||||
|
|
||||||
# ---- Settings this installation decides (all optional) ----
|
# ---- Settings this installation decides (all optional) ----
|
||||||
#
|
#
|
||||||
@@ -100,16 +101,16 @@ SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
|
|||||||
# Read once at startup: editing a policy means restarting the container.
|
# Read once at startup: editing a policy means restarting the container.
|
||||||
# Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides
|
# Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides
|
||||||
|
|
||||||
# ---- Several Stalwart servers (optional) ----
|
# ---- Several mail servers (optional) ----
|
||||||
#
|
#
|
||||||
# Choose the upstream by the domain someone signs in with. STALWART_URL above
|
# Choose the upstream by the domain someone signs in with. MAIL_SERVER_URL above
|
||||||
# stays required and stays the default; this only adds domains that go
|
# stays required and stays the default; this only adds domains that go
|
||||||
# elsewhere. See the shipped stalwart-servers.example.json, and mount it
|
# elsewhere. See the shipped mail-servers.example.json, and mount it
|
||||||
# read-only:
|
# read-only:
|
||||||
#
|
#
|
||||||
# -v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro
|
# -v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro
|
||||||
#
|
#
|
||||||
# STALWART_SERVERS_FILE=/etc/ihasmail/servers.json
|
# MAIL_SERVERS_FILE=/etc/ihasmail/servers.json
|
||||||
#
|
#
|
||||||
# An unlisted domain, or a username with no domain, goes to STALWART_URL. A
|
# An unlisted domain, or a username with no domain, goes to MAIL_SERVER_URL. A
|
||||||
# listed domain never falls back. Read once at startup: editing means a restart.
|
# listed domain never falls back. Read once at startup: editing means a restart.
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# Releases are cut by pushing a tag named `inbuxa-v<version>`, where
|
||||||
|
# <version> is what scripts/version.mjs says for the tagged commit with the
|
||||||
|
# `+` turned into `-` (e.g. inbuxa-v2026.9.22-g1a2b3c4). The prefix matters:
|
||||||
|
# this repository carries upstream ihasmail's own `v...` tags, on commits it
|
||||||
|
# shares with upstream, and a publish keyed on `v*` would ship plain ihasmail
|
||||||
|
# under the INBUXA name the moment one arrived. Only `inbuxa-v` tags publish.
|
||||||
|
# A tag publishes only if it names its own commit's version and that commit is
|
||||||
|
# on main. There is no release schedule yet; tags are cut by hand.
|
||||||
|
|
||||||
|
# Every job runs in an image pinned by digest (tag in the trailing comment),
|
||||||
|
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
|
||||||
|
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||||
|
# unreviewed can be pulled in. Read the comment for the version; the digest is
|
||||||
|
# what runs. Do not "simplify" one back to a bare tag.
|
||||||
|
#
|
||||||
|
# Jobs run on the runner's `ci-net` network and clone from Gitea's internal
|
||||||
|
# address, never through the Cloudflare-proxied public name, which caps
|
||||||
|
# request bodies at 100 MB.
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['**']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# -------------------------------------------------------------- test ------
|
||||||
|
node:
|
||||||
|
runs-on: light
|
||||||
|
container:
|
||||||
|
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_CACHE: ${{ github.workspace }}/.npm
|
||||||
|
steps:
|
||||||
|
# version.test.ts shells out to git to resolve a build version, and the
|
||||||
|
# slim image ships without it; the checkout action installs it when it
|
||||||
|
# is missing, so it is there for the tests too. Full history, because
|
||||||
|
# the version is computed from it.
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
# config.test.ts chmods a directory to 0555 and expects the write to be
|
||||||
|
# refused. Root ignores the permission bits, so as root that assertion
|
||||||
|
# can never hold. The tests run as the image's unprivileged `node` user
|
||||||
|
# for that reason; -p keeps the environment.
|
||||||
|
#
|
||||||
|
# imageproxy.test.ts needs IPv6 as well, which is not set here but on the
|
||||||
|
# runner: jobs run on the `ci-net` docker network, created with --ipv6.
|
||||||
|
# Without a non-loopback IPv6 address on the container, getaddrinfo's
|
||||||
|
# AI_ADDRCONFIG drops ::1 from the results entirely, localhost resolves
|
||||||
|
# to IPv4 only, and the test's control case connects to a port nothing
|
||||||
|
# is listening on. That is a runner property, so it cannot be fixed from
|
||||||
|
# this file -- if these tests ever fail again with ECONNREFUSED on
|
||||||
|
# 127.0.0.1, check that the runner still puts jobs on an IPv6-enabled
|
||||||
|
# network.
|
||||||
|
- run: chown -R node:node "$GITHUB_WORKSPACE"
|
||||||
|
- run: su node -p -c "npm ci --ignore-scripts"
|
||||||
|
- run: su node -p -c "npm run typecheck"
|
||||||
|
- run: su node -p -c "npm test"
|
||||||
|
- run: su node -p -c "npm run build"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- build ------
|
||||||
|
# Proves the Dockerfile still builds on every change, without pushing. The
|
||||||
|
# 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/') }}
|
||||||
|
needs: [node]
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
steps:
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
- run: |
|
||||||
|
tag="ihasmail:ci-$(echo "$GITHUB_SHA" | cut -c1-8)"
|
||||||
|
docker build -t "$tag" .
|
||||||
|
docker image rm "$tag"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- release ------
|
||||||
|
# Only for `inbuxa-v` tags (see the top of this file). The tag has to name
|
||||||
|
# its own commit's version, so the image, the release and the About screen
|
||||||
|
# all agree, and the commit has to be on main, so a release never describes
|
||||||
|
# code that was not reviewed onto the default branch.
|
||||||
|
version:
|
||||||
|
if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
||||||
|
runs-on: light
|
||||||
|
container:
|
||||||
|
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.v.outputs.VERSION }}
|
||||||
|
docker_tag: ${{ steps.v.outputs.DOCKER_TAG }}
|
||||||
|
steps:
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- id: v
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
V="$(node scripts/version.mjs)"
|
||||||
|
want="inbuxa-v${V/+/-}"
|
||||||
|
[ "$TAG" = "$want" ] || { echo "!! $TAG does not name this commit's version; expected $want"; exit 1; }
|
||||||
|
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|
||||||
|
|| { echo "!! $TAG is not on main"; exit 1; }
|
||||||
|
echo "VERSION=$V" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "DOCKER_TAG=${V/+/-}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "VERSION=$V DOCKER_TAG=${V/+/-}"
|
||||||
|
|
||||||
|
# Multi-arch image at <REGISTRY>/inbuxa/ihasmail-inbuxa, then the release.
|
||||||
|
# arm64 is built under QEMU on this amd64 host, which is slow but fine for
|
||||||
|
# a hand-cut release. PACKAGE_TOKEN (jcoffey-dev, write:package) logs in:
|
||||||
|
# the job's own token is refused by the container registry. The release is
|
||||||
|
# created last, so a release on the page always has its image behind it.
|
||||||
|
publish:
|
||||||
|
if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }}
|
||||||
|
needs: [node, version]
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }}
|
||||||
|
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
- run: |
|
||||||
|
test -n "$REGISTRY" && test -n "$VERSION" && test -n "$DOCKER_TAG"
|
||||||
|
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
|
||||||
|
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||||
|
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
|
||||||
|
- run: |
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--build-arg IHASMAIL_VERSION="$VERSION" \
|
||||||
|
--provenance=false --sbom=false \
|
||||||
|
--tag "$IMAGE:$DOCKER_TAG" \
|
||||||
|
--tag "$IMAGE:latest" \
|
||||||
|
--push .
|
||||||
|
docker buildx imagetools inspect "$IMAGE:$DOCKER_TAG"
|
||||||
|
# Show the package on the repository's Packages tab. Idempotent.
|
||||||
|
- run: |
|
||||||
|
apk add --no-cache -q curl
|
||||||
|
curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \
|
||||||
|
"$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \
|
||||||
|
|| echo "package already linked (or link refused); not fatal"
|
||||||
|
# The release, on the internal address. The job's own token may create
|
||||||
|
# releases; a tag it creates would not start a workflow, but this one
|
||||||
|
# already exists.
|
||||||
|
- env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
body="INBUXA webmail $VERSION.\n\nImage: \`$IMAGE:$DOCKER_TAG\` (linux/amd64, linux/arm64), also tagged \`latest\`."
|
||||||
|
curl -fsS -o /dev/null -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
--data "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"$body\"}" \
|
||||||
|
"$CI_SERVER_INTERNAL/api/v1/repos/$GITHUB_REPOSITORY/releases"
|
||||||
|
echo "release $TAG created"
|
||||||
|
- if: always()
|
||||||
|
run: docker logout "$REGISTRY" || true
|
||||||
@@ -63,7 +63,11 @@ env:
|
|||||||
# Hardcoded rather than derived from github.repository: a registry path must
|
# Hardcoded rather than derived from github.repository: a registry path must
|
||||||
# be lowercase and the owner is spelled `Coffey-Labs`, so deriving it means
|
# 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.
|
# remembering to lowercase it. This is the string the docs already name.
|
||||||
IMAGE: ghcr.io/coffey-labs/ihasmail
|
# 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:
|
jobs:
|
||||||
# The version is worked out once and handed to both builds, so the two
|
# The version is worked out once and handed to both builds, so the two
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# CI for the self-hosted GitLab that replaced GitHub Actions when the account
|
||||||
|
# was suspended on 2026-09-20. This is a port of .github/workflows/ci.yml,
|
||||||
|
# kept in the tree for reference and for the day the appeal succeeds.
|
||||||
|
#
|
||||||
|
# There is deliberately no publish job, although publish.yml is in the tree.
|
||||||
|
# Every tag in this repository is one of ihasmail's own upstream tags, the same
|
||||||
|
# commits, and at those tags publish.yml pushed to ihasmail's image, not an
|
||||||
|
# INBUXA one. A tag-driven publish here would ship plain ihasmail under the
|
||||||
|
# INBUXA name the moment upstream tags reached this project -- which happened
|
||||||
|
# once, by hand, and was deleted. Add one back only with a release scheme that
|
||||||
|
# produces tags this repository alone has.
|
||||||
|
#
|
||||||
|
# Every `image:` here is pinned to a digest, with the tag it belonged to in the
|
||||||
|
# trailing comment. That is the direct replacement for the SHA-pinned `uses:`
|
||||||
|
# in the Actions workflows: GitLab has no equivalent of an action allowlist, so
|
||||||
|
# the only thing standing between this pipeline and whatever the publisher
|
||||||
|
# pushes to a tag next is the digest. Read the comment for the version; the
|
||||||
|
# digest is what runs. Do not "simplify" one back to a bare tag.
|
||||||
|
#
|
||||||
|
# The runner is a group runner on Web_Host with the host docker socket bound
|
||||||
|
# in, reached over the internal container network rather than
|
||||||
|
# https://git.coffeylabs.org -- that name is Cloudflare-proxied on the Free
|
||||||
|
# plan, which caps request bodies at 100 MB and would break artifact uploads.
|
||||||
|
|
||||||
|
stages: [test, build]
|
||||||
|
|
||||||
|
variables:
|
||||||
|
GIT_DEPTH: "0"
|
||||||
|
|
||||||
|
default:
|
||||||
|
interruptible: true
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- test ------
|
||||||
|
node:
|
||||||
|
stage: test
|
||||||
|
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||||
|
variables:
|
||||||
|
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
|
||||||
|
cache:
|
||||||
|
key:
|
||||||
|
files: [package-lock.json]
|
||||||
|
paths: [.npm/]
|
||||||
|
before_script:
|
||||||
|
# version.test.ts shells out to git to resolve a build version, and the
|
||||||
|
# slim image ships without it. The clone is done by the runner's helper
|
||||||
|
# image, so nothing else here needs git and its absence is easy to miss.
|
||||||
|
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git
|
||||||
|
# config.test.ts chmods a directory to 0555 and expects the write to be
|
||||||
|
# refused. Root ignores the permission bits, so as root that assertion can
|
||||||
|
# never hold. The tests run as the image's unprivileged `node` user for
|
||||||
|
# that reason; -p keeps the environment.
|
||||||
|
#
|
||||||
|
# imageproxy.test.ts needs IPv6 as well, which is not set here but on the
|
||||||
|
# runner: jobs run on the `ci-net` docker network, created with --ipv6.
|
||||||
|
# Without a non-loopback IPv6 address on the container, getaddrinfo's
|
||||||
|
# AI_ADDRCONFIG drops ::1 from the results entirely, localhost resolves to
|
||||||
|
# IPv4 only, and the test's control case connects to a port nothing is
|
||||||
|
# listening on. That is a runner property, so it cannot be fixed from this
|
||||||
|
# file -- if these tests ever fail again with ECONNREFUSED on 127.0.0.1,
|
||||||
|
# check that the runner still puts jobs on an IPv6-enabled network.
|
||||||
|
- chown -R node:node "$CI_PROJECT_DIR"
|
||||||
|
script:
|
||||||
|
- su node -p -c "npm ci --ignore-scripts"
|
||||||
|
- su node -p -c "npm run typecheck"
|
||||||
|
- su node -p -c "npm test"
|
||||||
|
- su node -p -c "npm run build"
|
||||||
|
artifacts:
|
||||||
|
paths: [dist/]
|
||||||
|
expire_in: 1 week
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- build ------
|
||||||
|
# Proves the Dockerfile still builds on every change, without pushing. The
|
||||||
|
# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step.
|
||||||
|
#
|
||||||
|
# Not called `image`: that is a reserved keyword, and a job by that name is
|
||||||
|
# silently read as the global image: setting instead ("image name should be a
|
||||||
|
# string"). Same trap for `stages`, `cache`, `services` and `variables`.
|
||||||
|
docker-build:
|
||||||
|
stage: build
|
||||||
|
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||||
|
needs: [node]
|
||||||
|
script:
|
||||||
|
- docker build -t ihasmail:ci-$CI_COMMIT_SHORT_SHA .
|
||||||
|
- docker image rm ihasmail:ci-$CI_COMMIT_SHORT_SHA
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
+23
-24
@@ -1,6 +1,6 @@
|
|||||||
# Contributing to ihasmail
|
# Contributing to ihasmail
|
||||||
|
|
||||||
Thanks for your interest in contributing to **ihasmail** — an immutable, JMAP-only webmail client for [Stalwart Mail Server](https://stalw.art/). Contributions of all kinds are welcome: bug reports, feature requests, code, documentation, and testing.
|
Thanks for your interest in contributing to the **INBUXA webmail**, an immutable, JMAP-only webmail client for the INBUXA mail server, built on ihasmail. Contributions of all kinds are welcome: bug reports, feature requests, code, documentation, and testing.
|
||||||
|
|
||||||
## Code of Conduct
|
## Code of Conduct
|
||||||
|
|
||||||
@@ -9,21 +9,21 @@ By participating in this project, you agree to treat other contributors with res
|
|||||||
## Before You Start
|
## Before You Start
|
||||||
|
|
||||||
- ihasmail speaks **JMAP only** — it does not support IMAP/POP3/SMTP fallback paths. Keep this in mind when proposing features.
|
- ihasmail speaks **JMAP only** — it does not support IMAP/POP3/SMTP fallback paths. Keep this in mind when proposing features.
|
||||||
- ihasmail has **no database of its own** — all state lives in Stalwart via JMAP. Contributions should not introduce a separate persistence layer without discussion first.
|
- ihasmail has **no database of its own** — all state lives on the mail server, over JMAP. Contributions should not introduce a separate persistence layer without discussion first.
|
||||||
- This project is licensed under **AGPL-3.0**. Any code you contribute will be distributed under this license, including for hosted/SaaS deployments.
|
- This project is licensed under **AGPL-3.0**. Any code you contribute will be distributed under this license, including for hosted/SaaS deployments.
|
||||||
|
|
||||||
## How to Contribute
|
## How to Contribute
|
||||||
|
|
||||||
### Reporting Bugs
|
### Reporting Bugs
|
||||||
|
|
||||||
Before opening a new issue, please search [existing issues](https://github.com/Coffey-Labs/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
|
Before opening a new issue, please search [existing issues](https://git.coffeylabs.org/coffey-labs/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
|
||||||
|
|
||||||
- A clear, descriptive title
|
- A clear, descriptive title
|
||||||
- Steps to reproduce the issue
|
- Steps to reproduce the issue
|
||||||
- Expected behavior vs. actual behavior
|
- Expected behavior vs. actual behavior
|
||||||
- Your environment: browser/OS, Stalwart version, and how ihasmail is deployed (Docker, bare metal, etc.)
|
- Your environment: browser/OS, mail server version, and how ihasmail is deployed (Docker, bare metal, etc.)
|
||||||
- Relevant logs, console errors, or screenshots
|
- Relevant logs, console errors, or screenshots
|
||||||
- Whether the issue is reproducible against a fresh Stalwart instance
|
- Whether the issue is reproducible against a fresh mail server
|
||||||
|
|
||||||
### Suggesting Features
|
### Suggesting Features
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ For larger changes, please open an issue to discuss the approach **before** subm
|
|||||||
2. **Name your branch** descriptively, e.g. `fix/thread-view-scroll` or `feat/search-filters`.
|
2. **Name your branch** descriptively, e.g. `fix/thread-view-scroll` or `feat/search-filters`.
|
||||||
3. **Keep PRs focused** — one logical change per PR. Large, unrelated changes bundled together are harder to review and more likely to be rejected.
|
3. **Keep PRs focused** — one logical change per PR. Large, unrelated changes bundled together are harder to review and more likely to be rejected.
|
||||||
4. **Write clear commit messages** describing what changed and why.
|
4. **Write clear commit messages** describing what changed and why.
|
||||||
5. **Test your changes** against a real (or local) Stalwart instance where possible, since JMAP behavior can be subtle.
|
5. **Test your changes** against a real (or local) mail server where possible, since JMAP behavior can be subtle.
|
||||||
6. **Update documentation** if your change affects setup, configuration, or user-facing behavior.
|
6. **Update documentation** if your change affects setup, configuration, or user-facing behavior.
|
||||||
7. **Open the pull request** against `main`, filling out the PR template with:
|
7. **Open the pull request** against `main`, filling out the PR template with:
|
||||||
- A summary of the change
|
- A summary of the change
|
||||||
@@ -117,7 +117,7 @@ Store tests do not exercise the component. At least one bug in this repo's
|
|||||||
history — a shift-click range measured inside a `setState` updater, which React
|
history — a shift-click range measured inside a `setState` updater, which React
|
||||||
runs after the anchor ref has already moved — passed every store assertion and
|
runs after the anchor ref has already moved — passed every store assertion and
|
||||||
failed the moment the built app was driven. If a change is visible on screen,
|
failed the moment the built app was driven. If a change is visible on screen,
|
||||||
run it: `npm run dev:mock` (mock Stalwart, credentials printed on start), then
|
run it: `npm run dev:mock` (the mock mail server, credentials printed on start), then
|
||||||
drive the real thing. Add a component test for what you find; there are
|
drive the real thing. Add a component test for what you find; there are
|
||||||
examples in `web/src/views/*/__tests__/`.
|
examples in `web/src/views/*/__tests__/`.
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ examples in `web/src/views/*/__tests__/`.
|
|||||||
git clone https://github.com/YOUR-USERNAME/ihasmail.git
|
git clone https://github.com/YOUR-USERNAME/ihasmail.git
|
||||||
cd ihasmail
|
cd ihasmail
|
||||||
```
|
```
|
||||||
2. Point your local instance at a running Stalwart Mail Server (a test/dev instance is strongly recommended — do not develop against a production mailbox), or use the built-in mock below.
|
2. Point your local instance at a running INBUXA mail server (a test/dev instance is strongly recommended — do not develop against a production mailbox), or use the built-in mock below.
|
||||||
3. Install and run, as below.
|
3. Install and run, as below.
|
||||||
4. Verify your changes don't break existing JMAP calls by exercising core flows: login, list/read mail, send, search, and folder/label operations.
|
4. Verify your changes don't break existing JMAP calls by exercising core flows: login, list/read mail, send, search, and folder/label operations.
|
||||||
|
|
||||||
@@ -137,8 +137,8 @@ Requirements: Node ≥ 20.19 (26 recommended), npm ≥ 10.
|
|||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
npm run dev # real Stalwart (STALWART_URL in .env) — server :8080, Vite :5173
|
npm run dev # a real mail server (MAIL_SERVER_URL in .env) — server :8080, Vite :5173
|
||||||
npm run dev:mock # built-in mock Stalwart ([email protected] / demo), mock on :8788
|
npm run dev:mock # built-in mock mail server ([email protected] / demo), mock on :8788
|
||||||
npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold
|
npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold
|
||||||
|
|
||||||
npm run typecheck # tsc for both packages
|
npm run typecheck # tsc for both packages
|
||||||
@@ -153,38 +153,38 @@ build.
|
|||||||
#### Architecture
|
#### Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
|
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► mail server
|
||||||
React SPA • session cookie ⇄ Basic auth
|
React SPA • session cookie ⇄ Basic auth
|
||||||
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
|
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
|
||||||
```
|
```
|
||||||
|
|
||||||
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitizer, search parser, Sieve codec, locale-aware dates, vCard, …).
|
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitizer, search parser, Sieve codec, locale-aware dates, vCard, …).
|
||||||
- `server/` — Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake Stalwart for development and demos.
|
- `server/` — Node/Hono backend: authenticates against the mail server's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake mail server for development and demos.
|
||||||
|
|
||||||
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
|
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
|
||||||
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
|
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
|
||||||
`quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own
|
`quota`, `blob`, `filenode`, EventSource push, plus the mail server's own
|
||||||
`urn:stalwart:jmap`. Features degrade gracefully when one is missing.
|
registry capability. Features degrade gracefully when one is missing.
|
||||||
|
|
||||||
#### The mock
|
#### The mock
|
||||||
|
|
||||||
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against
|
An in-memory fake mail server — enough JMAP to develop and demo against
|
||||||
without a real mailbox. It reproduces the things a naive fake would get wrong,
|
without a real mailbox. It reproduces the things a naive fake would get wrong,
|
||||||
because each cost a live debugging session: `urn:stalwart:jmap` advertised
|
because each cost a live debugging session: the registry capability advertised
|
||||||
**per-account** rather than session-level, identity signatures capped at 2047
|
**per-account** rather than session-level, identity signatures capped at 2047
|
||||||
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
|
**bytes**, and `CalendarEvent/set` speaking the server's vocabulary rather than
|
||||||
RFC 8984's.
|
RFC 8984's.
|
||||||
|
|
||||||
| Switch | What it does |
|
| Switch | What it does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `MOCK_NO_FUTURE_RELEASE=1` | Advertises FUTURERELEASE, then drops every hold |
|
| `MOCK_NO_FUTURE_RELEASE=1` | Advertises FUTURERELEASE, then drops every hold |
|
||||||
| `MOCK_NO_REGISTRY=1` | Omits the Stalwart capability, so the sign-in refusal can be tested |
|
| `MOCK_NO_REGISTRY=1` | Omits the registry capability, so the sign-in refusal can be tested |
|
||||||
| `MOCK_NO_SCHEDULING_SEND=1` | Refuses a calendar write that asks for scheduling messages, as for an account without that permission |
|
| `MOCK_NO_SCHEDULING_SEND=1` | Refuses a calendar write that asks for scheduling messages, as for an account without that permission |
|
||||||
| `MOCK_ROLE` | Who the demo user is for Administration: `admin` (the default), `tenant-admin`, `helpdesk` or `user` |
|
| `MOCK_ROLE` | Who the demo user is for Administration: `admin` (the default), `tenant-admin`, `helpdesk` or `user` |
|
||||||
| `MOCK_METRICS=off` | Refuses the dashboard's metric history, as Community does |
|
| `MOCK_METRICS=off` | Refuses the dashboard's metric history, as a server without metrics history does |
|
||||||
| `MOCK_EDITION=enterprise` | Reports Enterprise, which Tenants needs |
|
| `MOCK_EDITION=enterprise` | Reports the `enterprise` edition, for code that still reads it |
|
||||||
|
|
||||||
It tracks the current Stalwart release rather than 0.16 in general, and each
|
It tracks the current mail server release, and each
|
||||||
behavior is confirmed against a real server before it is copied here — the
|
behavior is confirmed against a real server before it is copied here — the
|
||||||
comments say which version and on what date. Where a release changes something
|
comments say which version and on what date. Where a release changes something
|
||||||
a client can see, the mock changes with it, and the test that pinned the old
|
a client can see, the mock changes with it, and the test that pinned the old
|
||||||
@@ -199,9 +199,8 @@ at build time — nothing writes a version into the tree, and `package.json` sta
|
|||||||
at `0.0.0`. `node scripts/version.mjs` prints it for the current checkout.
|
at `0.0.0`. `node scripts/version.mjs` prints it for the current checkout.
|
||||||
|
|
||||||
The PR number sits after the `+` as build metadata because it records where a
|
The PR number sits after the `+` as build metadata because it records where a
|
||||||
build came from, not how new it is. The version says nothing about Stalwart on
|
build came from, not how new it is. The version says nothing about the mail server on
|
||||||
purpose: what a build needs from the server is stated in the README badge and
|
purpose: the server's own version is its own business. Building an image with the version on it,
|
||||||
in [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Building an image with the version on it,
|
|
||||||
and the single-host `deploy.example.sh`, are covered in
|
and the single-host `deploy.example.sh`, are covered in
|
||||||
[Installing](https://docs.ihasmail.org/install/).
|
[Installing](https://docs.ihasmail.org/install/).
|
||||||
|
|
||||||
|
|||||||
-1857
File diff suppressed because it is too large
Load Diff
-158
@@ -1,158 +0,0 @@
|
|||||||
# Known issues and pending QA
|
|
||||||
|
|
||||||
What was checked, against which server, and when. For a failure you are hitting
|
|
||||||
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
|
|
||||||
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
|
|
||||||
|
|
||||||
The live instance runs **0.16.22**, and as of **2026-08-26 there is nothing
|
|
||||||
left pending**. Most entries below were exercised against 0.16.19 on the date
|
|
||||||
they name, and the dates still say so: each upgrade since was read against the
|
|
||||||
diff rather than re-run, and nothing in those diffs touches the session
|
|
||||||
capabilities, blob, quota, submission or registry paths these entries describe.
|
|
||||||
The calendar entries carrying a 2026-08-31 date were exercised against a live
|
|
||||||
0.16.20 directly, as were the public-key entries dated 2026-09-05.
|
|
||||||
|
|
||||||
**0.16.21 was different and was re-run rather than read.** It changed four
|
|
||||||
things a client can see, one of which resolved an entry below outright: an
|
|
||||||
occurrence of a recurring event is identified by its recurrence id rather than
|
|
||||||
its position in the series, so an id held across a write no longer names a
|
|
||||||
different date; `Calendar/get` and `AddressBook/get` return every property when
|
|
||||||
none are named; EventSource advertises its ping interval in seconds rather than
|
|
||||||
milliseconds; and a calendar write that asks for scheduling messages is refused
|
|
||||||
when the account may not send them. The mock reproduces all four. The app
|
|
||||||
was run against a real 0.16.21 with mail, calendar and contacts exercised by
|
|
||||||
hand, including editing one occurrence of a recurring series through the
|
|
||||||
interface and confirming the rest of the series stayed where it was.
|
|
||||||
|
|
||||||
**0.16.22 (2026-09-13) was tested too.** The app has been tested against it on
|
|
||||||
the live instance. Its changes a client can see are all in `CalendarEvent/get`
|
|
||||||
and `ContactCard/get`, and were read from its source before the mock was made
|
|
||||||
to follow them: `baseEventId` is `null` for an event read by its stored id,
|
|
||||||
`recurrenceRule` and `recurrenceOverrides` asked for on a synthetic id come back
|
|
||||||
`null`, `useDefaultAlerts` belongs to the reader and reads `false` until set,
|
|
||||||
and an empty `properties` list returns `id` alone. None of them contradicts an
|
|
||||||
entry below.
|
|
||||||
|
|
||||||
What remains here is not a list of unknowns but of things worth knowing — where
|
|
||||||
Stalwart departs from a spec, where a setting has to be turned on for a feature
|
|
||||||
to work, and what ihasmail deliberately does not do.
|
|
||||||
|
|
||||||
Entries keep saying what was checked and when, because this section has been
|
|
||||||
wrong before: the 0.16 registry path was once recorded as verified live when a
|
|
||||||
capability looked for in the wrong place meant it had never run at all.
|
|
||||||
|
|
||||||
Some entries record what a live **0.15.5** proved before that server was
|
|
||||||
upgraded on 2026-08-25. They are kept where the finding is about ihasmail
|
|
||||||
rather than about 0.15 — a byte cap that still applies, a flow that still
|
|
||||||
works the same way — and dropped where 0.15 was the whole subject. Support for
|
|
||||||
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
|
||||||
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
|
||||||
|
|
||||||
- **`ContactCard/changes` works, and a download honors one byte range but does not say so.** Both **confirmed live (0.16.22, 2026-09-16)**, with objects on a throwaway account that were removed afterwards. `ContactCard/changes` reports a create, an update and a destroy exactly, nets a card created and destroyed since the given state out to nothing, and answers a state it does not recognize with `invalidArguments` rather than `cannotCalculateChanges`; the contacts store syncs from it and falls back to a full reload on any error. The download endpoint answers a single range (`bytes=0-9`, `bytes=-5`, `bytes=995-`) with `206` and a correct `Content-Range`, and anything else (several ranges, or a range past the end) with the whole file and `200`, never `416`. It sends no `Accept-Ranges`, so ihasmail's proxy advertises it: Chrome's PDF viewer reads a file in pieces only when told it can. The mock answers the same way.
|
|
||||||
|
|
||||||
- **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://github.com/Coffey-Labs/ihasmail/issues/375)).
|
|
||||||
|
|
||||||
- **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://github.com/Coffey-Labs/ihasmail/issues/376)).
|
|
||||||
|
|
||||||
- **Administration was built from Stalwart's source, and the first live run found the one thing the source reading got wrong.** Accounts and Domains were written on 2026-09-13 against the 0.16.22 source and a mock reproducing it, deployed the same day, and exercised against the live server from an administrator's session. On that server the Accounts list did not load: `x:Account/query` answered **`unsupportedFilter - type`**. A registry filter is keyed by the property's name *as it appears on the object*, and the discriminator is `@type`, so `{"type": "User"}` names nothing the server knows and fails the whole query; `{"@type": "User"}` is accepted. The research that fed the build had listed the field as `type`, and the mock took it without complaint — which is how it shipped. Fixed in [#336](https://github.com/Coffey-Labs/ihasmail/pull/336), and the mock now refuses any filter name the real server does not index, answering the way Stalwart does. Everything else was **confirmed live (2026-09-13)**, mostly read-only, with the domain writes made on a throwaway domain created for the purpose and removed afterwards:
|
|
||||||
|
|
||||||
- **Permissions** come from `GET /api/account` in camelCase (`sysAccountGet`); an administrator's list held 641 of them and none were kebab-case, whatever the documentation shows. The menu gates on these.
|
|
||||||
- **The Basic credential ihasmail proxies with reaches the admin `x:` methods**, as it already reached the self-service ones. No separate token is involved.
|
|
||||||
- **An account reads back in the shapes the code expects**: `credentials` as `{"0": {"@type": "Password", …}}`, aliases and group memberships as objects, the disk limit under `quotas.maxDiskQuota`.
|
|
||||||
- **A new domain gets automatic DKIM straight away** — an Ed25519 and an RSA key, both `active`, with their records already in the zone file — and manual DNS and certificates.
|
|
||||||
- **`dnsZoneFile` is BIND text**, one record per line as `name IN TYPE value`, with a long TXT record split into a parenthesized run of quoted chunks. A throwaway domain's file held 18 records and 3 continuation lines; every record parsed and the panel showed 18 rows. The production domains also carry TLSA records, which show as rows like any other.
|
|
||||||
- **`x:DkimSignature/query` accepts a `domainId` filter.**
|
|
||||||
- **`catchAllAddress` wants a whole address.** A bare local part is refused with `invalidPatch`, *"Invalid email address"*.
|
|
||||||
- **A domain its keys still name cannot be destroyed**: `objectIsLinked`, with `linkedObjects` listing each as `{"object": "DkimSignature", "id": …}` and no description. Removing through the panel destroys the keys first and then the domain; both were gone afterwards.
|
|
||||||
- **A reserved TLD is refused**: `example` as a domain's top level comes back `invalidPatch`, *"Invalid domain name"*, naming `name`.
|
|
||||||
|
|
||||||
The last two were then tried by hand on the live server the same day and behaved as described. **A password set by an administrator** — written to the account's existing credential, `credentials/<index>/secret` — signs in. **The outranking guard** held: an account with more rights than the viewer's role opens read-only. The guard exists because the source shows Stalwart skipping its grant check when only a password changes and on a delete, and it stays for that reason.
|
|
||||||
|
|
||||||
- **The dashboard's feeds were settled on the live server before the code was written (2026-09-15, 0.16.22 Enterprise, read-only calls from an administrator's session).** The first probes guessed two of these wrong — filtering on `timestamp`, and counting received mail from `message-ingest.*` — and the server and the 0.16.22 source agreed on the answers below:
|
|
||||||
|
|
||||||
- **The metric history filters on comparison names.** `x:Metric/query` accepts `{"timestampIsGreaterThanOrEqual": …, "metric": [names]}`; a bare `timestamp`, `after` or `metric` as a string is `unsupportedFilter`. Sorting on `timestamp` works. At the default interval a day is about 80 records for the six metrics the dashboard reads, and a get takes at most 500.
|
|
||||||
- **Received and sent are `queue.*` counters**, not `message-ingest.*`: `queue.message-queued` for received, and `queue.authenticated-message-queued` + `queue.dsn-queued` + `queue.report-queued` for sent, which is what Stalwart's own dashboard adds up. A Counter holds its interval's count and a zero one is not written; the `*-time` histograms are cumulative, which is why nothing reads them.
|
|
||||||
- **Memory is the `server.memory` Gauge**, in bytes, one per interval. **Counts** come from `/query` with `calculateTotal: true` and `limit: 0`, which returned the whole total for `x:Account` (users only, via `@type`), `x:Domain` and `x:QueuedMessage`.
|
|
||||||
- **`x:Metrics/get` is not the history.** It is the singleton holding the collection settings (Prometheus and OpenTelemetry export, the metrics policy); the history is `x:Metric`.
|
|
||||||
|
|
||||||
**Not confirmed live:** that a tenant administrator's counts are scoped to the tenancy, and that a Community server refuses `x:Metric` as `forbidden`. Both are read from the 0.16.22 source (`query.rs`, `queued_message.rs`, `registry/mod.rs`); the production server has no tenants and is Enterprise, so neither could be tried there without writing. The dashboard's handling of both is covered by tests against the refusal Stalwart's source gives.
|
|
||||||
|
|
||||||
- **Groups were built from the 0.16.22 source and a mock, then confirmed on the live server (2026-09-15)** with a throwaway group on one of the server's domains, created and removed, its only member the administrator's own account:
|
|
||||||
|
|
||||||
- **A group is created** as `x:Account` with `@type: "Group"`, no credentials and no encryption setting, and reads back with roles `{"@type": "Default"}`, `permissions` `Inherit`, a `locale` of `en-US` and `usedDiskQuota` 0.
|
|
||||||
- **Membership is the member's.** `"memberGroupIds/<group>": true` on the user was accepted; `{"@type": "User", "memberGroupIds": <group>}` then found them with a total of 1, and the user's own `memberGroupIds` read `{"<group>": true}`. The same pointer with `null` took them out again and left the set as it was before.
|
|
||||||
- **A group with members cannot be deleted**: `objectIsLinked`, with `objectId` as `{"object": "Account", "id": <group>}` and `linkedObjects` listing each member as `{"object": "Account", "id": …}`. With the member out, the delete went through and the group read back as `notFound`.
|
|
||||||
|
|
||||||
The same run tried a throwaway mailing list before the Mailing lists section was written. It was created with `recipients` as a set, `{"[email protected]": true}`, and read back as `name`, `domainId`, `description`, `aliases`, `memberTenantId`, `recipients` and a computed `emailAddress`. `"recipients/<address>": true` added one and left the other; a `text` filter found it; it was destroyed with nothing linked. Not tried: removing a recipient with `null` (the same set patch as a group membership, which was), and how the server words a recipient that is not an address.
|
|
||||||
|
|
||||||
Still from source only: that membership gives a member no permissions (`access_token.rs` builds a user's permissions from their own roles), and that groups cannot nest.
|
|
||||||
|
|
||||||
- **Roles were built from the 0.16.22 source, its schema and the mock, then confirmed on the live server (2026-09-15)** with throwaway `ihasmail-role-test` roles, created and removed:
|
|
||||||
|
|
||||||
- **A role is created** with `description`, `roleIds`, `enabledPermissions` and `disabledPermissions` as sets, and reads back with them and `memberTenantId`.
|
|
||||||
- **Pointers change one entry each**: `enabledPermissions/<p>` and `disabledPermissions/<p>` with `true` or `null`, `roleIds/<id>` likewise, and `description` in the same update, all applied together.
|
|
||||||
- **A name that is not a permission fails the whole update** as `invalidPatch`, *"Invalid value for object property"*, naming the pointer — which is how a probe using the mock's made-up `jmapEmailSet` found that the mock had carried a permission Stalwart does not have since Accounts was built; it is `jmapEmailUpdate` now, and the mock refuses unknown names.
|
|
||||||
- **A grant the caller does not hold is refused**: `forbidden`, *"You are not authorized to grant permissions: scimAccess"*.
|
|
||||||
- **A role another role builds on cannot be deleted**: `objectIsLinked`, `objectId` `{"object": "Role", …}`, `linkedObjects` naming the child.
|
|
||||||
- **The defaults** read from `x:Authentication`: users get User; groups get Group; tenant administrators get Tenant Administrator and User; administrators get System Administrator and User.
|
|
||||||
|
|
||||||
**The picker is stricter than the server for a few permissions.** `GET /api/account` never lists some permissions an administrator holds — `sysLogCreate` among them, which was granted without complaint — so their *Allow* is locked for everyone. That errs toward refusing and can be revisited if it gets in anyone's way. Still from source only: that a denial anywhere in a role's tree wins (`permissions.rs` unions enabled and disabled across the tree, then subtracts). **`GET /api/schema` through ihasmail's server was confirmed on production after the deploy (2026-09-15, v2026.9.15+pr364)**: `/api/admin/permissions` answered 200 with all 661 permissions, the same list as the 0.16.22 snapshot, and the Roles picker drew them under 60 headings. The four bootstrap roles grant 244 (User), 229 (Group), 50 (Tenant Administrator) and 452 (System Administrator) once their trees are followed.
|
|
||||||
|
|
||||||
- **Tenants were built from the 0.16.22 source, its schema and the mock, then tried on the live server (2026-09-15)** with throwaway `ihasmail-tenant-test` tenants, a throwaway role, two throwaway lists and a throwaway domain, all removed. The live run changed the design twice:
|
|
||||||
|
|
||||||
- **A tenant is created and edited as built**: `name`, `logo`, `roles`, `permissions`, `quotas`; `quotas/<name>` pointers, a logo and a rename in one update; an unknown quota name is `invalidPatch`.
|
|
||||||
- **Something in a tenant has to be on a domain in that tenant.** A list in the tenant on a domain in none was refused, `invalidForeignKey` with `objectId` `{"object": "Domain", …}`; the same list on a domain created in the tenant was accepted — and so was a list in *no* tenant on that domain. **So an account's tenant choice offers only its domain's tenant**, and a new account starts in the tenant of the domain it is made on.
|
|
||||||
- **A domain created in a tenant puts its DKIM keys in the tenant too**, and they stay there. They count against `maxDkimKeys` and keep the tenant from being deleted, so they are counted with everything else.
|
|
||||||
- **Stalwart lets a domain leave a tenant while the tenant still has things on it**, leaving them in a tenant on a domain outside it. **The panel refuses to take a domain out while any of the tenant's accounts are on it.** Mailing lists cannot be filtered by domain, so a list is not checked.
|
|
||||||
- **A tenant still holding anything is kept**: `objectIsLinked`, `objectId` `{"object": "Tenant", …}`, `linkedObjects` naming a role, a list and DKIM keys. A role set to `memberTenantId: null` left it, after which the tenant was deleted.
|
|
||||||
|
|
||||||
Still from source only: that only a caller outside every tenant may set `memberTenantId` (`set.rs` passes `can_set_tenant` only when the token has no tenant), and that a tenant administrator's queries are scoped to the tenant. On a server that does not report Enterprise the Tenants page is only its notice.
|
|
||||||
|
|
||||||
- **The permission labels in eight languages are machine translations awaiting native review.** 661 labels and 59 headings per language, written against each catalog's existing terms. The translators flagged the terms they were least sure of, which are the place to start: *principal* (JMAP/DAV), *throttles*, *listeners*, *lookups*, *milters*, *masked emails*, *samples* (spam training), *schedules* (MTA delivery), *email submission*, and the MTA stage settings. Several of Stalwart's own English labels are identical for different permissions (ARF, DMARC and TLS reports are all "Get reports"), and the translations inherit that; the heading above tells them apart.
|
|
||||||
|
|
||||||
- **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognized by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why.
|
|
||||||
|
|
||||||
- **Administration is off for a device not marked as your own, and for an installation that says so.** Both are enforced by the server rather than hidden by the menu: such a session is sent no permissions, and the JMAP proxy refuses registry methods beyond the account's own. That is worth stating because the proxy otherwise forwards whatever the browser sends, and before these gates an administrator's console could make any registry call their role allowed. For a session that may not administer, the proxy reads a request body only when it could name a registry method — a `"x:` in the text, or a `\u` escape that could spell one — so ordinary mail traffic is forwarded untouched.
|
|
||||||
|
|
||||||
- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localize and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
|
|
||||||
|
|
||||||
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogs and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalog key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalog can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. **The check had the same blind spot one level down (2026-09-14).** It looked at `title=`, `aria-label=`, `placeholder=` and `alt=` on elements, but not at props passed to components, so `<MenuItem label={x ? "Collapse all" : "Expand all"}>` passed. It also accepted a JSX literal that was a catalog key, although no component here runs its props through `t()`, so 19 strings with translations in every catalog (Report spam, Mark as read, Add star, Save…) still rendered in English. And the script only exited non-zero with `--check`, which `npm run i18n:check` never passed, so it could print a finding without failing. Component props are checked now, a key no longer excuses a literal in an attribute, and both halves run with `--check`. That turned up 28 strings, all fixed: 19 wrapped, and 9 that needed new keys in all nine catalogs. English built with a template literal inside an attribute, such as ``aria-label={`Remove ${email}`}``, was the last gap. It can't be a catalog key as written. Since 2026-09-14 the check flags any template literal in one of these positions that has words between its values, and the twelve that existed are now keys with placeholders. They were the quota bar, the address menu, a folder's unread count, the recipient chips, the contact editor's title, shared calendars and address books, the date and time fields, the attachment fallback name, and the free/busy bar. That bar showed the raw JMAP value (`confirmed`) in every language.
|
|
||||||
|
|
||||||
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
|
|
||||||
|
|
||||||
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
|
|
||||||
- **Sharing a mail folder is accepted and does nothing.** `Mailbox/set` with a `shareWith` map is applied, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with — **confirmed live on 0.16.19 (2026-08-27)** with a folder shared read-only to another account on the same server, which never saw it. Stalwart's own sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing reports a failure at any point, which is the whole problem: the share is stored, so a client that trusts what it reads back shows it as live for ever. The entry point is withdrawn. A folder that is *already* shared still offers **Stop sharing**, because a share nobody can see is exactly the one you want to be able to clear, and there is no other way to. File sharing is unaffected and works end to end.
|
|
||||||
- **Address book sharing works, and was briefly withdrawn by mistake.** It was taken out alongside mail folders on 2026-08-27 on a report that it behaved the same way; the report was mistaken and the feature was put back the same day. Nothing was ever shown to be wrong with it, and Stalwart documents address books as shareable. Recorded because the withdrawal is in the history and would otherwise read as a finding. Shared books now appear in the Contacts pane under "Shared with me" rather than behind an account switch, and their contacts are offered when addressing a message.
|
|
||||||
- **Stalwart lets a sharee subscribe to a shared calendar but not a shared address book.** Subscribing is a write to the *owner's* account -- `isSubscribed` lives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only: `AddressBook/set` answers successfully with the id in `notUpdated`, `forbidden`, *"You are not allowed to modify this address book."* The identical `Calendar/set` on a shared calendar is accepted. **Confirmed live on 0.16.19 (2026-08-27)** from a second account holding both shares, which is the only place it shows: from the owner's own account the write succeeds and everything looks fine. So ihasmail asks the server first, because a preference the server holds is one every client agrees about, and keeps the answer in its own synced settings (`addedShares`) when the server will not. Two things this cost, both worth remembering: the refusal arrives as a *successful* response, so the code that ignored `notUpdated` saw nothing wrong and the button simply did nothing; and it is invisible from the owner's account, so it took two browsers signed in as two accounts to find at all. The mock now refuses the same write for the same reason, since one that accepted it agreed with the belief that shipped.
|
|
||||||
- **`shareWith` is not returned unless a client asks for it by name.** A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all — not null, not empty, absent — **confirmed live on 0.16.19 (2026-08-27)** against a calendar and an address book that were genuinely shared with another account: omit the list and there is no `shareWith`; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on *"not shared with anyone yet"* over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, because `fileNodeProps` had always named the property; calendars, address books and mail folders fetched everything and got less. Mail folders mattered in a way of their own: sharing one is withdrawn, and the only way to clear a share already made is a **Stop sharing** entry that appears when a folder looks shared — so without the property the escape hatch for the exact situation it was built for was invisible. The mock omitted it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server. **0.16.21 fixed this for calendars and address books**: with `properties` omitted, `Calendar/get` and `AddressBook/get` now return every property, `shareWith` included — **confirmed live on 0.16.21 (2026-09-06)**. `Mailbox/get` on the same server still leaves it out, so the mock now hides it for mail folders alone, and ihasmail keeps naming the property everywhere.
|
|
||||||
- **Stalwart's `x:PublicKey` registry works, and ihasmail deliberately does not expose it.** A Settings section for it has been built twice — [PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67), closed 2026-08-26, and [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285) — and withdrawn both times, for a reason that has nothing to do with the server: **nothing in ihasmail signs, encrypts, decrypts or verifies with a key**, so a page for managing them is furniture rather than a feature. It ends up telling the reader, in its own footnote, that adding a key does nothing. The registry is written up here rather than in [ROADMAP.md](ROADMAP.md) because what follows is established fact about Stalwart that cost a live probe, and losing it twice to a closed pull request was how the second attempt came to exist at all. Everything below was **confirmed live on 0.16.20 (2026-09-05)** from a normal account with no administrative rights, and the full round trip — create, read back, rename, patch, destroy — succeeded for both formats.
|
|
||||||
|
|
||||||
- **An ordinary user may read *and* write their own keys**, whatever the permissions table says: Stalwart documents every `sysPublicKey*` permission as administrative, and the server granted them anyway. A create carrying a malformed key was refused with `invalidProperties` naming `key` rather than `forbidden` — a rejection of the key, not of the person. Had the documentation been right, any such feature would have been useless to everybody but an administrator, which is why this was probed first.
|
|
||||||
- **It takes S/MIME certificates as well as OpenPGP keys, and parses both.** A self-signed X.509 certificate carrying `emailProtection` and an `email:` SAN registered, read back and destroyed cleanly, and a malformed one is refused by a decoder of its own: *"Failed to decode X509 certificate: BER decoding error: Expected Tag { class: Universal, value: 16 } tag…"*. Worth checking rather than assuming, because every *other* message the registry returns names OpenPGP — including for input that is not OpenPGP at all — so the server reads as though OpenPGP were the only format it knows. It is not.
|
|
||||||
- **A key can parse perfectly and still be refused, and says something different when it is.** A sign-and-certify OpenPGP key with no encryption subkey — which is what `gpg --quick-generate-key` produces — comes back *"Could not find any suitable keys in OpenPGP public key"*, distinct from the parser's *"Failed to decode OpenPGP public key: Malformed packet: Malformed CTB…"*. Any client showing these must keep them apart: one says paste it again, the other says the key needs an encryption subkey and no amount of care with the clipboard will help. Certificates have no equivalent trap, since one issued for email use has key encipherment by construction.
|
|
||||||
- **`emailAddresses` comes back as `{}` when empty** — an object, where a JMAP list property should be an array. Nothing fails loudly: it is a plain `Get` response that type-checks against a hand-written interface and then throws in `join()` while a list renders. A client must check the shape rather than trust the type.
|
|
||||||
- **A create answers with the id alone**, no `createdAt`, so anything that reads the date back out of the create response gets `undefined`. **Patching `key` on an existing entry is allowed**, which is worth knowing and probably worth not doing: replacing a key by adding one and removing the old keeps `createdAt` meaning what it says.
|
|
||||||
- **`expiresAt` is the registry's own field and is not derived from the key.** A certificate valid for a year registers with `expiresAt: null`. Reading the real date means parsing the certificate, and a date a client extracted would disagree with the server's field the moment the two ever differed.
|
|
||||||
|
|
||||||
- **Signature checking is done here, and its trust model is deliberately small.** Stalwart does not verify S/MIME or OpenPGP signatures and exposes no result for one, so ihasmail does it in the browser: raw message, MIME split, PKCS#7 parse, WebCrypto. What is worth knowing is what it does *not* do, because the gap is a design choice rather than an omission. **No chain of trust is validated** — a browser has no system trust store, no CA bundle is shipped, and revocation is not checked — so a verified signature on its own shows only that the sender held the key inside their own message, which anyone can self-sign. What carries the weight instead is trust on first use: the first signed message from an address pins its fingerprint in the account's settings, and a later message signed by a different certificate is reported loudly. That is why the interface never says the bare word "verified", why a first sighting is gray rather than green, and why a changed signer never overwrites the pin. Verified against real `openssl smime -sign` output rather than hand-built fixtures — RSA and ECDSA, plus a tampered copy — because a signed message written by hand only ever agrees with whatever the author believed the format to be.
|
|
||||||
- **OpenPGP signatures cannot be checked at all, for a reason that is not effort.** A PGP signature carries no key, so verifying one needs the sender's public key in advance, and there is nowhere to get it: `x:PublicKey` holds the *account's own* keys, not correspondents'. Fetching from a keyserver or via WKD would tell a third party who you correspond with each time you opened a message — the same leak the image proxy exists to close — so it is not done. Such a message says so by name rather than failing as an unknown format, and it says *could not check* rather than *did not check out*, which is a distinction worth keeping: one is ignorance and the other is an accusation.
|
|
||||||
- **Two signature shapes are declined rather than attempted.** SHA-1 signatures are refused outright — one nobody can forge in practice today is still not one to put a tick beside. RSA-PSS is declined because the salt length lives in parameters ihasmail does not read, and guessing wrong would report a perfectly good signature as *bad*, which is a far worse thing to say than "cannot check". Both are shown as uncheckable, not as broken.
|
|
||||||
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
|
|
||||||
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
|
|
||||||
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
|
|
||||||
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognize ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
|
||||||
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
|
|
||||||
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
|
|
||||||
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honors a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, canceled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a canceled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
|
|
||||||
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Canceling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence. Since 0.16.22 the same event read by its *stored* id answers `baseEventId: null` rather than its own id, which changes nothing here: a one-off read through the synthetic id an expanded query gave it still carries a base.
|
|
||||||
- **Free/busy between accounts needs no sharing, and calendar contents cannot be reached at all.** These are the two halves of the same finding, and the second is what makes the first safe. **Confirmed live on 0.16.20 (2026-09-01)** against the deployed instance: `Principal/getAvailability` was called for all seven principals the directory returns, none of whose calendars are shared with the calling account, and every one was answered — no `forbidden`, no error of any kind, from a server that refuses a malformed call instantly. It returns real data rather than a polite empty list: the caller's own principal reported one busy period against the one event in the next sixty days. And a `Principal` carries only `id`, `type`, `name`, `description` and `email` — **no `accountId`** — so there is no handle with which to ask for anybody's calendars. Free/busy is therefore not the weaker of two permissions, it is the only channel between two accounts, and it is open by default. That is the right posture and worth recording, because a client that assumed sharing was a precondition would hide a working feature behind a setting nobody needs to touch. **One thing this did not settle**: the other six principals reported nothing over a nine-month window, which is equally consistent with "those accounts have empty calendars" — likely, since the session reaches one account — and with "an unreadable principal answers with an empty list rather than an error". Distinguishing them needs a second account with an event in it, and until somebody has one, ihasmail assumes the pessimistic reading everywhere it matters: a participant it cannot read is drawn as unknown rather than as free.
|
|
||||||
|
|
||||||
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behavior and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
|
|
||||||
|
|
||||||
- **A synthetic id was only true until the next write, through 0.16.20. Fixed in 0.16.21.** Stalwart's expanded-occurrence ids used to encode a position in the series, so writing a `recurrenceOverrides` entry renumbered them. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier, so an id cached across a write silently pointed at another date and a delete meant for one occurrence removed a different one. The failure was never a `notFound` a client would notice; it was a confident answer about the wrong day. **0.16.21 identifies an occurrence by its recurrence id, and confirming that was the point of re-running rather than reading the diff. Confirmed live on 0.16.21 (2026-09-06)**: the same shape of test — five weekly occurrences expanded, the third retitled through its own synthetic id, all five original ids re-read — left every id on its own date, with none renumbered and none `notFound`. A second override written through the interface behaved the same way. The defense stays regardless: ihasmail still never mutates an occurrence by an id it is holding, and `updateEvent` and `destroyEvent` still re-resolve by `recurrenceId` immediately before acting, because a date can still leave a series and because the client supports 0.16 as a whole rather than only its newest release. The mock follows the new behavior, and the test that pinned the old renumbering now pins the stability instead — rewritten rather than deleted, so the reversal stays on the record.
|
|
||||||
|
|
||||||
- **A per-occurrence patch made only of inherited properties creates an override that loses the title.** The twelve properties 0.16.20 drops from a per-occurrence patch are dropped *after* it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the `start` and `duration` the server fills in and nothing else. **Confirmed live on 0.16.20 (2026-08-31)**: `{"privacy": "private"}` aimed at one occurrence answered `updated`, left `privacy` untouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle.
|
|
||||||
|
|
||||||
- **Recurring events can be edited and deleted one date at a time, since 0.16.20.** A write aimed at a synthetic id was refused outright through 0.16.19; 0.16.20 turns it into a `recurrenceOverrides` entry instead, so "this occurrence" and "the whole series" are now two different things ihasmail asks about before acting. **Confirmed live on 0.16.20 (2026-08-31)** end to end against a five-week series: a legal patch landed on the override with `start` and `duration` filled in by the server; `useDefaultAlerts` was refused with *"This property cannot be modified on a single occurrence."*; a destroy removed one date and left the series; and a base event and one of its instances in the same request were refused together, both ids, with *"A base event and its instances cannot be modified in the same request."* The scope is chosen before the editor opens rather than on save, because it decides which event the form is about — one populated from the master shows the *series'* start date, so editing Wednesday would have offered to move Monday. Two entries below are the sharp edges this turned up.
|
|
||||||
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
|
|
||||||
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
|
|
||||||
@@ -1,114 +1,104 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="web/public/img/logo.png" alt="ihasmail" width="150">
|
<img src="web/public/img/inbuxa-mark.png" alt="" width="110">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<h1 align="center">INBUXA webmail</h1>
|
||||||
<strong><a href="https://demo.ihasmail.com">Try the demo</a></strong><br>
|
|
||||||
<sub>A working copy with an invented mailbox behind it — no sign-up, nothing real, nothing kept.</sub>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<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>
|
<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>
|
||||||
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.22" src="https://img.shields.io/badge/Stalwart-0.16.22-6366f1?style=flat-square"></a>
|
|
||||||
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
|
|
||||||
<a href="https://coffeylabs.org" target="_blank" rel="noreferrer"><img alt="by Coffey Labs" src="https://img.shields.io/badge/by-Coffey%20Labs-0f766e?style=flat-square"></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# ihasmail
|
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
|
||||||
**Immutable webmail for [Stalwart Mail Server](https://stalw.art).** Mail,
|
the INBUXA mail server, and keeps nothing of its own: everything durable,
|
||||||
calendars, contacts, files and filters in one app that works as well on a phone
|
settings included, lives on the server, so the container is disposable.
|
||||||
as on a desktop — and a container with nothing to persist.
|
|
||||||
|
|
||||||
ihasmail talks only JMAP to Stalwart. There is no database, no IMAP or SMTP,
|
|
||||||
and with `IMMUTABLE=1` no writable filesystem either: everything durable,
|
|
||||||
settings included, belongs to Stalwart, so the container is disposable.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
| --- | --- |
|
|
||||||
| 🌐 **[ihasmail.org](https://ihasmail.org)** | What it is, what it looks like, the full feature list |
|
|
||||||
| 📘 **[docs.ihasmail.org](https://docs.ihasmail.org)** | [Installing](https://docs.ihasmail.org/install/) · [Configuring](https://docs.ihasmail.org/configure/) · [Using it](https://docs.ihasmail.org/using/) · [Shortcuts](https://docs.ihasmail.org/shortcuts/) · [Rebranding](https://docs.ihasmail.org/rebranding/) · [Troubleshooting](https://docs.ihasmail.org/troubleshooting/) |
|
|
||||||
| 📋 **[FEATURES.md](FEATURES.md)** | Everything it does, feature by feature, with the capability each one needs |
|
|
||||||
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
|
|
||||||
| 🛣 **[ROADMAP.md](ROADMAP.md)** | What ihasmail does not do, and why |
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
| | |
|
|
||||||
| --- | --- |
|
|
||||||
| **Inbox & conversation (dark)**  | **Inbox & conversation (light)**  |
|
|
||||||
| **Composer**  | **Calendar**  |
|
|
||||||
| **Contacts**  | **Sieve filter builder**  |
|
|
||||||
|
|
||||||
Taken against the built-in mock with sample data. More, including the phone
|
|
||||||
layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
|
|
||||||
|
|
||||||
## What's in it
|
## What's in it
|
||||||
|
|
||||||
- **Mail** — conversations, labels, search operators, keyboard shortcuts, scheduled and undo send, invitations and RSVP, filters made from a message
|
- **Mail:** conversations, labels, search operators, keyboard shortcuts,
|
||||||
- **Calendar** — month, week, day and agenda views, recurrence, attendees and free-busy
|
scheduled and undo send, invitations and RSVP, filters made from a message.
|
||||||
- **Contacts** — address books, groups, vCard import and export
|
- **Calendar:** month, week, day and agenda views, recurrence, attendees and
|
||||||
- **Files** — browse, upload, move, share
|
free-busy.
|
||||||
- **Signature checking** — S/MIME signed mail verified as you read it
|
- **Contacts:** address books, groups, vCard import and export.
|
||||||
- **Settings that follow the account**, kept in the account's own storage on Stalwart
|
- **Files:** browse, upload, move, share.
|
||||||
- **On a phone** — swipe to archive or delete, pull to refresh, hold to select
|
- **Signature checking:** S/MIME signed mail verified as you read it.
|
||||||
- **Administration** — a dashboard, accounts, groups, mailing lists, roles, tenants and domains, each shown only when the Stalwart role allows it
|
- **Settings that follow the account**, stored on the mail server.
|
||||||
- **Ten interface languages and twelve themes** — the nine translations are marked Beta until a native speaker has read them
|
- **On a phone:** swipe to archive or delete, pull to refresh, hold to select.
|
||||||
- **Platform** — installable PWA, Web Push, `mailto:` handler, no credentials in the browser, strict CSP
|
- **Administration:** a dashboard, accounts, groups, mailing lists, roles,
|
||||||
|
tenants and domains, each shown only to an account whose role allows it.
|
||||||
|
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.**
|
||||||
|
|
||||||
The long version is [FEATURES.md](FEATURES.md) and
|
## Configuration
|
||||||
[ihasmail.org](https://ihasmail.org/#features).
|
|
||||||
|
|
||||||
## Requirements
|
| Variable | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `MAIL_SERVER_URL` | How this webmail reaches the mail server. |
|
||||||
|
| `APP_SECRET` | A long random secret for sealing sessions. Required in production. |
|
||||||
|
| `OAUTH_CLIENT_SECRET` | Turns on sign-in through the server's page. The secret of the confidential client the server registers for this webmail: on the server, the same value as `INBUXA_WEBMAIL_CLIENT_SECRET`. |
|
||||||
|
| `OAUTH_CLIENT_ID` | The client's id. Default `ihasmail-inbuxa`, which is what the server registers. |
|
||||||
|
| `PUBLIC_URL` | Where browsers reach the webmail, without `BASE_PATH`. Required with `OAUTH_CLIENT_SECRET`. The redirect URI, `PUBLIC_URL` + `BASE_PATH` + `/api/auth/callback`, must match the server's `INBUXA_WEBMAIL_URL` + `/api/auth/callback` exactly. |
|
||||||
|
| `MAIL_SERVERS_FILE` | Optional: several mail servers, picked by the account's domain. See `mail-servers.example.json`. |
|
||||||
|
| `ADMIN_URL` | Optional: where INBUXA Admin is, for the dashboard's link. |
|
||||||
|
| `APP_NAME` | What the webmail calls itself. Default `INBUXA`, shown as the INBUXA wordmark; any other name shows as text. |
|
||||||
|
|
||||||
**Stalwart 0.16 or newer** — sign-in refuses anything older, by name. Tested
|
`.env.example` lists the rest.
|
||||||
against 0.16.22; what changed in each release is in
|
|
||||||
[KNOWN-ISSUES.md](KNOWN-ISSUES.md).
|
|
||||||
|
|
||||||
- **No Stalwart yet?** [ihasmail-oneshot](https://github.com/Coffey-Labs/ihasmail-oneshot) deploys a new Stalwart and ihasmail together on one host, in one command.
|
On the mail server, set `INBUXA_WEBMAIL_URL` to the webmail's address (with
|
||||||
- **On Stalwart 0.15?** [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) upgrades it in place, or stay on the [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support) release.
|
`BASE_PATH`, if any) and `INBUXA_WEBMAIL_CLIENT_SECRET` to the shared secret.
|
||||||
|
The server registers the client on start and allows the webmail's origin for
|
||||||
|
cross-origin requests.
|
||||||
|
|
||||||
|
With one mail server, the sign-in page asks for no address, only whether this
|
||||||
|
is the person's own device. The server's page asks for the rest. With several,
|
||||||
|
the address comes first, since its domain picks the server.
|
||||||
|
|
||||||
|
A password change revokes the server's tokens, so it signs the person out
|
||||||
|
everywhere, this session included.
|
||||||
|
|
||||||
## Quick start (Docker)
|
## Quick start (Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48)
|
# edit: MAIL_SERVER_URL, APP_SECRET, and for server sign-in OAUTH_CLIENT_SECRET and PUBLIC_URL
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
# → http://localhost:8080 — put a reverse proxy in front for TLS
|
# → http://localhost:8080. Put a reverse proxy in front for TLS.
|
||||||
```
|
```
|
||||||
|
|
||||||
Or pull the published image, `ghcr.io/coffey-labs/ihasmail`. Releases are
|
## Source code
|
||||||
weekly, so it is usually a few days behind `main`.
|
|
||||||
|
|
||||||
People sign in with their Stalwart mailbox credentials. **An account with
|
INBUXA webmail is a modified ihasmail, so the AGPL's offer is this fork:
|
||||||
two-factor authentication needs an app password**, created in Stalwart's own
|
<https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa>. The sign-in page and Settings ›
|
||||||
settings.
|
About link there, beside the version, which names the commit the running build
|
||||||
|
came from.
|
||||||
|
|
||||||
Everything else — TLS, running immutably, several Stalwart servers, settings
|
Run your own patched build and that offer becomes yours, not ours: point
|
||||||
the installation decides, every environment variable — is in
|
`SOURCE_URL` at your tree and both links follow it.
|
||||||
[Installing](https://docs.ihasmail.org/install/) and
|
|
||||||
[Configuring](https://docs.ihasmail.org/configure/).
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev:mock # built-in mock Stalwart ([email protected] / demo)
|
npm run dev:mock # the built-in mock mail server ([email protected] / demo)
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The mock also answers OAuth. Start it and the webmail with
|
||||||
|
`OAUTH_CLIENT_SECRET=mock-oauth-secret` and a `PUBLIC_URL`, and its sign-in
|
||||||
|
page approves the demo user at once.
|
||||||
|
|
||||||
Architecture, the mock's switches and how versions are numbered are in
|
Architecture, the mock's switches and how versions are numbered are in
|
||||||
[CONTRIBUTING.md](CONTRIBUTING.md#development-setup).
|
[CONTRIBUTING.md](CONTRIBUTING.md#development-setup).
|
||||||
|
|
||||||
## Contributing
|
## Built on ihasmail
|
||||||
|
|
||||||
[CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
|
The INBUXA webmail is built on [ihasmail](https://git.coffeylabs.org/coffey-labs/ihasmail),
|
||||||
[SECURITY.md](SECURITY.md) — please report vulnerabilities privately.
|
Coffey Labs' own webmail, which stays an independent product. The public
|
||||||
|
repository is the remote `ihasmail`, fetch-only, and its `main` is merged in to
|
||||||
|
keep up. Nothing here is pushed there.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright (C) 2026 Coffey Labs — AGPL-3.0-or-later. See [LICENSE](LICENSE).
|
Copyright (C) 2026 Coffey Labs. AGPL-3.0-or-later; see [LICENSE](LICENSE).
|
||||||
|
|
||||||
If you run a modified ihasmail, set `SOURCE_URL` to your own repository: the
|
|
||||||
sign-in page and Settings › About both show it. See
|
|
||||||
[Rebranding](https://docs.ihasmail.org/rebranding/).
|
|
||||||
|
|||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
# Roadmap / not yet
|
|
||||||
|
|
||||||
Things ihasmail does not do, and why. An issue number here says where the entry
|
|
||||||
came from, not that it is tracked elsewhere — a report can be closed because the
|
|
||||||
bug in it was fixed while the larger thing it asked for stays on this page. What
|
|
||||||
is genuinely open lives in [the issue tracker](https://github.com/Coffey-Labs/ihasmail/issues);
|
|
||||||
the rest is here because the answer is "no", not "not yet".
|
|
||||||
|
|
||||||
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
|
||||||
|
|
||||||
- **More of Stalwart's directory in Administration.** The Administration menu opens on a dashboard and manages accounts, groups, mailing lists, tenants, roles and domains today — see [FEATURES.md](FEATURES.md#administration). DNS and ACME providers are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it.
|
|
||||||
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
|
|
||||||
- **A scheduling view of its own**, for asking "when is everyone free next week?" without an event in hand. The grid itself is built and lives in the event editor — a row per participant, steppable, and clickable to place the event — which is where the question gets asked while you are arranging something. What is not built is the same thing as a destination you can visit with nothing in progress. Came out of [#172](https://github.com/Coffey-Labs/ihasmail/issues/172), which asked for a separate view and is closed by the panel: the reasoning for putting it in the editor is that a separate surface can only ever tell you a time you then retype, whereas one beside the event can set it. It stays here rather than in the tracker because nobody has yet said they want to ask the question on its own.
|
|
||||||
- **Per-message actions from the message list on a touchscreen.** Reply, Forward and compose-as-new are on the list row's context menu, which is a right-click — and holding a row on a phone starts selection instead, so none of them are reachable there. They are all available inside a thread, which is where the actions on a single message belong; what is missing is the shortcut from the list. Fixing it means deciding what a long press should do when it already means something, which is a bigger question than the actions themselves.
|
|
||||||
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
|
|
||||||
- **A translation anybody has checked.** The translations themselves shipped on 2026-08-31 and are no longer on this page: nine of them, alongside English, and the extraction that had always been the hard half is done — see [FEATURES.md](FEATURES.md#interface-language). What is *not* done is the other half, and it is the half that cannot be bought or automated. All nine were produced by AI against standard dictionaries and **not one has been read by anybody who speaks the language**, which is exactly where a bad translation does harm rather than merely looking untidy. They ship marked Beta, with that said in Settings and a link for reporting anything wrong, because shipping them quietly would ask people to trust text nobody has checked. A language loses the Beta mark when a speaker reads it and says so — a deliberate act by a person, not something a coverage percentage earns. If you speak one of them and are willing to read a few hundred strings, that is the single most useful thing anyone could contribute right now.
|
|
||||||
- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalog without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
|
|
||||||
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings › Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
|
|
||||||
- **Signing and encrypting mail.** *Reading* a signature is built: S/MIME signed mail is checked as it is read, and the signer is remembered so a change is called out — see [Checking a signature](FEATURES.md#checking-a-signature). What is not built is anything that produces a signature or touches ciphertext, and the reason is not Stalwart. This is client work over the message body: JMAP hands over the MIME blob and the rest is ours.
|
|
||||||
|
|
||||||
The blocker is a security model, not code, and it is the same one it has always been. Signing and decrypting need a **private** key in a page served by the same host that would handle it, which runs straight into two things ihasmail says about itself: that it never stores a credential, and that it runs immutably with nowhere to keep one. Verifying needed none of that — the certificate travels inside the message — which is exactly why it could be built first and why it went first.
|
|
||||||
|
|
||||||
**OpenPGP signatures are not checked, and this is a harder problem than it looks.** A PGP signature does not carry the key, so verifying one means having the sender's public key already. ihasmail has no source for it: `x:PublicKey` is the account's *own* registry, and fetching from a keyserver or WKD would tell a third party who you correspond with, which is precisely the leak the image proxy exists to close. A local store of correspondents' keys is possible and is not a small feature; nobody has asked for it yet.
|
|
||||||
|
|
||||||
*Managing* keys — publishing your own to `x:PublicKey` — has been built twice ([PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67), [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285)) and withdrawn twice, because a Settings page for keys nothing uses is furniture. That reasoning is now partly spent: something does use a key. But what signature checking uses is the certificate inside the message, not anything in the registry, so publishing your own key remains a feature waiting for a consumer.
|
|
||||||
|
|
||||||
**Encryption at rest is refused rather than deferred.** Stalwart offers it as `encryptionAtRest`, a field on `x:AccountSettings` beside `description`, `locale` and `timeZone` — there is no `x:EncryptionAtRest` object whatever the docs suggest, and its value is a typed object (`{"@type": "Disabled"}`) rather than a bare string. It is self-service, needs no administrator, and would be easy to offer. It will not be: turning it *off does not decrypt what is already there*. Every message delivered while it was on stays encrypted on disk, readable only by a client holding the private key, so switching it on is a one-way door — and a toggle that reads as "make my mail safer" while quietly being irreversible is the wrong thing to hand an ordinary user.
|
|
||||||
|
|
||||||
**Why S/MIME rather than OpenPGP, and why neither is urgent.** End-to-end encrypted mail never reached the mainstream and is not on its way there: as a share of the world's email, PGP-encrypted messages are a rounding error, and the most successful use of OpenPGP is signing packages rather than sending mail. The reasons are structural rather than a matter of better tooling. Everyone in a thread has to take part, so the network effect works against it from the first reply. Key discovery was never solved — keyservers were unauthenticated and got weaponized in the 2019 certificate-flooding attacks, which made specific people's keys unusable by any client that fetched them, and WKD is better without being universal. There is no forward secrecy, so one compromised key retroactively opens everything ever received. The metadata stays in the clear: subject lines are cleartext in classic PGP/MIME, and who corresponded with whom is often the sensitive part. Losing a key loses the mail permanently. And it breaks the client — no server-side search, degraded spam filtering, awkward on a phone — while EFAIL showed in 2018 that the clients themselves were exploitable through MIME and HTML handling. Meanwhile the actual privacy win arrived invisibly and without anyone participating, in STARTTLS, MTA-STS and DANE.
|
|
||||||
|
|
||||||
So if one of the two gets built here it is S/MIME, because it is the one that is *more* deployed in the places that pay for software: native in Outlook and Apple Mail, and routine in defense, healthcare, finance and government, where a CA issues and revokes certificates that an IT department can actually administer. The web of trust never became something anybody could run at scale.
|
|
||||||
|
|
||||||
Expect the asking to be far out of proportion to the using. A self-hosted webmail for Stalwart draws self-hosters, privacy-minded users and European SMEs, which is about the densest concentration of PGP users left alive — so this will be requested much more often than it would be used, and that is an argument for keeping it here, described honestly, rather than either building it on the strength of the requests or refusing it outright.
|
|
||||||
+5
-5
@@ -22,13 +22,13 @@ Please include as much of the following as you can:
|
|||||||
- A description of the vulnerability and its potential impact
|
- A description of the vulnerability and its potential impact
|
||||||
- Steps to reproduce, or a proof-of-concept
|
- Steps to reproduce, or a proof-of-concept
|
||||||
- The version/commit of ihasmail affected
|
- The version/commit of ihasmail affected
|
||||||
- The version of Stalwart Mail Server you were testing against, if relevant
|
- The version of the mail server you were testing against, if relevant
|
||||||
- Whether the issue is in ihasmail itself, in how it talks to Stalwart over JMAP, or in a dependency
|
- Whether the issue is in the webmail itself, in how it talks to the mail server over JMAP, or in a dependency
|
||||||
|
|
||||||
### What to Expect
|
### What to Expect
|
||||||
|
|
||||||
- **Acknowledgment:** You should receive a response within a few days confirming the report was received.
|
- **Acknowledgment:** You should receive a response within a few days confirming the report was received.
|
||||||
- **Assessment:** The issue will be triaged and its severity assessed. Because ihasmail holds no data of its own and relies entirely on Stalwart's store over JMAP, some reports may need to be routed to or coordinated with the [Stalwart Mail Server](https://github.com/stalwartlabs/mail-server) project if the root cause lives there rather than in ihasmail's client code.
|
- **Assessment:** The issue will be triaged and its severity assessed. Because ihasmail holds no data of its own and relies entirely on the mail server's store over JMAP, some reports may need to be routed to or coordinated with the mail server's own project if the root cause lives there rather than in ihasmail's client code.
|
||||||
- **Fix & disclosure:** Once a fix is ready, a new release will be published. We'll coordinate with you on public disclosure timing and credit, if you'd like to be credited.
|
- **Fix & disclosure:** Once a fix is ready, a new release will be published. We'll coordinate with you on public disclosure timing and credit, if you'd like to be credited.
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
@@ -42,9 +42,9 @@ In scope:
|
|||||||
|
|
||||||
Out of scope (please report upstream instead):
|
Out of scope (please report upstream instead):
|
||||||
|
|
||||||
- Vulnerabilities in Stalwart Mail Server itself — report those to the [Stalwart project](https://github.com/stalwartlabs/mail-server)
|
- Vulnerabilities in the INBUXA mail server itself: report those to the mail server's own project
|
||||||
- Vulnerabilities in third-party libraries with no demonstrated impact on ihasmail
|
- Vulnerabilities in third-party libraries with no demonstrated impact on ihasmail
|
||||||
- Issues requiring physical access to a user's device or an already-compromised Stalwart instance
|
- Issues requiring physical access to a user's device or an already-compromised mail server
|
||||||
|
|
||||||
## Disclosure Policy
|
## Disclosure Policy
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -25,11 +25,11 @@ services:
|
|||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
environment:
|
environment:
|
||||||
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
|
MAIL_SERVER_URL: ${MAIL_SERVER_URL:?set MAIL_SERVER_URL in .env}
|
||||||
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
||||||
APP_NAME: ${APP_NAME:-ihasmail}
|
APP_NAME: ${APP_NAME:-ihasmail}
|
||||||
BASE_PATH: ${BASE_PATH:-}
|
BASE_PATH: ${BASE_PATH:-}
|
||||||
SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail}
|
SOURCE_URL: ${SOURCE_URL:-https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa}
|
||||||
TRUST_PROXY: "1"
|
TRUST_PROXY: "1"
|
||||||
IMAGE_PROXY: "1"
|
IMAGE_PROXY: "1"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"_comment": [
|
"_comment": [
|
||||||
"Optional: which Stalwart a domain signs in to.",
|
"Optional: which mail server a domain signs in to.",
|
||||||
"",
|
"",
|
||||||
"STALWART_URL stays required and stays the default. This file only adds",
|
"MAIL_SERVER_URL stays required and stays the default. This file only adds",
|
||||||
"domains that go somewhere else -- delete it and nothing changes.",
|
"domains that go somewhere else -- delete it and nothing changes.",
|
||||||
"",
|
"",
|
||||||
"Point at it with STALWART_SERVERS_FILE=/etc/ihasmail/servers.json and mount",
|
"Point at it with MAIL_SERVERS_FILE=/etc/ihasmail/servers.json and mount",
|
||||||
"it read-only. Read once at startup, so editing it means restarting.",
|
"it read-only. Read once at startup, so editing it means restarting.",
|
||||||
"",
|
"",
|
||||||
"A domain that is not listed here, and a bare username with no domain at",
|
"A domain that is not listed here, and a bare username with no domain at",
|
||||||
"all, go to STALWART_URL. A domain that IS listed never falls back: if its",
|
"all, go to MAIL_SERVER_URL. A domain that IS listed never falls back: if its",
|
||||||
"server is unreachable that sign-in fails, because falling back would",
|
"server is unreachable that sign-in fails, because falling back would",
|
||||||
"authenticate somebody against a server their domain was routed away from.",
|
"authenticate somebody against a server their domain was routed away from.",
|
||||||
"",
|
"",
|
||||||
@@ -20,10 +20,10 @@
|
|||||||
"ihasmail's Administration dashboard links to each server's own",
|
"ihasmail's Administration dashboard links to each server's own",
|
||||||
"administration, found from the server. A value may instead be an object",
|
"administration, found from the server. A value may instead be an object",
|
||||||
"that overrides where it is: {\"url\": ..., \"adminUrl\": ...}.",
|
"that overrides where it is: {\"url\": ..., \"adminUrl\": ...}.",
|
||||||
"STALWART_ADMIN_URL is the same for the default server. A listed domain is",
|
"ADMIN_URL is the same for the default server. A listed domain is",
|
||||||
"never pointed at the default server's administration.",
|
"never pointed at the default server's administration.",
|
||||||
"",
|
"",
|
||||||
"Docs: https://docs.ihasmail.org/configure/#several-stalwart-servers"
|
"Each listed domain signs in to its own server; everything else goes to MAIL_SERVER_URL."
|
||||||
],
|
],
|
||||||
|
|
||||||
"example.com": "https://mail.example.com",
|
"example.com": "https://mail.example.com",
|
||||||
+3
-3
@@ -20,11 +20,11 @@
|
|||||||
"test": "npm run test -w web && npm run test -w server",
|
"test": "npm run test -w web && npm run test -w server",
|
||||||
"lint": "npm run typecheck",
|
"lint": "npm run typecheck",
|
||||||
"mock": "npm run mock -w server",
|
"mock": "npm run mock -w server",
|
||||||
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"MAIL_SERVER_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
||||||
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"MAIL_SERVER_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
||||||
"i18n:coverage": "node scripts/i18n-coverage.mjs",
|
"i18n:coverage": "node scripts/i18n-coverage.mjs",
|
||||||
"i18n:check": "node scripts/i18n-catalog-check.mjs --check && node scripts/i18n-literals.mjs --check",
|
"i18n:check": "node scripts/i18n-catalog-check.mjs --check && node scripts/i18n-literals.mjs --check",
|
||||||
"dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
|
"dev:mock:no-keyword-sort": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-keyword-sort -w server\" \"MAIL_SERVER_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^10.0.5",
|
"concurrently": "^10.0.5",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const PORT = 18797;
|
|||||||
process.env.MOCK_PORT = String(PORT);
|
process.env.MOCK_PORT = String(PORT);
|
||||||
process.env.MOCK_USER = "[email protected]";
|
process.env.MOCK_USER = "[email protected]";
|
||||||
process.env.MOCK_PASS = "demo-password";
|
process.env.MOCK_PASS = "demo-password";
|
||||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||||
process.env.APP_SECRET = "test-secret-for-account-flows";
|
process.env.APP_SECRET = "test-secret-for-account-flows";
|
||||||
|
|
||||||
const mock = await import("./mock/index.js");
|
const mock = await import("./mock/index.js");
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodRespon
|
|||||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
});
|
});
|
||||||
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
|
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
|
||||||
if (!res.ok) throw new UpstreamError(`Stalwart rejected the request (${res.status})`, 502);
|
if (!res.ok) throw new UpstreamError(`The mail server rejected the request (${res.status})`, 502);
|
||||||
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
|
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ writeFileSync(
|
|||||||
"Linked.Test.": { url: "https://mail.linked.test", adminUrl: "https://admin.linked.test/" },
|
"Linked.Test.": { url: "https://mail.linked.test", adminUrl: "https://admin.linked.test/" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
process.env.STALWART_URL = "https://default.example";
|
process.env.MAIL_SERVER_URL = "https://default.example";
|
||||||
process.env.STALWART_ADMIN_URL = "https://admin.default.example/";
|
process.env.ADMIN_URL = "https://admin.default.example/";
|
||||||
process.env.STALWART_SERVERS_FILE = file;
|
process.env.MAIL_SERVERS_FILE = file;
|
||||||
|
|
||||||
const { adminPrefixFrom, adminUrlFor, advertisedOrigin, upstreamFor } = await import("./upstream.js");
|
const { adminPrefixFrom, adminUrlFor, advertisedOrigin, upstreamFor } = await import("./upstream.js");
|
||||||
const { config, parseStalwartServers } = await import("./config.js");
|
const { config, parseStalwartServers } = await import("./config.js");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the dashboard's "Open Stalwart admin" points. STALWART_URL is how this
|
* Where the dashboard's "Open Stalwart admin" points. MAIL_SERVER_URL is how this
|
||||||
* server reaches Stalwart; STALWART_ADMIN_URL is where a browser opens its
|
* server reaches Stalwart; ADMIN_URL is where a browser opens its
|
||||||
* administration, and follows the same domain routing.
|
* administration, and follows the same domain routing.
|
||||||
*/
|
*/
|
||||||
test("a servers file entry may name its administration as well as its server, and a note is not a domain", () => {
|
test("a servers file entry may name its administration as well as its server, and a note is not a domain", () => {
|
||||||
@@ -67,7 +67,7 @@ test("the origin is the one Stalwart advertises, even when it is reached on a pr
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("the shipped example loads through the parser that reads it", () => {
|
test("the shipped example loads through the parser that reads it", () => {
|
||||||
const example = new URL("../../stalwart-servers.example.json", import.meta.url);
|
const example = new URL("../../mail-servers.example.json", import.meta.url);
|
||||||
const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example");
|
const parsed = parseStalwartServers(JSON.parse(readFileSync(example, "utf8")), "example");
|
||||||
assert.ok(Object.keys(parsed.urls).length > 0);
|
assert.ok(Object.keys(parsed.urls).length > 0);
|
||||||
assert.ok(!("_comment" in parsed.urls));
|
assert.ok(!("_comment" in parsed.urls));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||||
const { createApp } = await import("./app.js");
|
const { createApp } = await import("./app.js");
|
||||||
|
|
||||||
test("CSRF guard rejects API POSTs without the custom header", async () => {
|
test("CSRF guard rejects API POSTs without the custom header", async () => {
|
||||||
@@ -111,7 +111,7 @@ test("only a PDF blob may be framed, and only by us", async () => {
|
|||||||
/*
|
/*
|
||||||
* #239: retrying through an outage must not lock somebody out of the recovery.
|
* #239: retrying through an outage must not lock somebody out of the recovery.
|
||||||
*
|
*
|
||||||
* STALWART_URL at the top of this file is 127.0.0.1:1 — nothing listens there,
|
* MAIL_SERVER_URL at the top of this file is 127.0.0.1:1 — nothing listens there,
|
||||||
* so every sign-in here is the outage case. Before the fix, the eleventh of
|
* so every sign-in here is the outage case. Before the fix, the eleventh of
|
||||||
* these came back 429 and stayed 429 for fifteen minutes, outliving whatever
|
* these came back 429 and stayed 429 for fifteen minutes, outliving whatever
|
||||||
* had actually been wrong.
|
* had actually been wrong.
|
||||||
|
|||||||
+198
-6
@@ -41,6 +41,7 @@ import {
|
|||||||
revokeAppPassword,
|
revokeAppPassword,
|
||||||
} from "./account.js";
|
} from "./account.js";
|
||||||
import { imageProxyHandler } from "./imageproxy.js";
|
import { imageProxyHandler } from "./imageproxy.js";
|
||||||
|
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js";
|
||||||
import { icsProxyHandler } from "./icsproxy.js";
|
import { icsProxyHandler } from "./icsproxy.js";
|
||||||
import { staticHandler } from "./static.js";
|
import { staticHandler } from "./static.js";
|
||||||
|
|
||||||
@@ -199,6 +200,9 @@ function compressResponses(basePath: string): MiddlewareHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const csrfGuard: MiddlewareHandler = async (c, next) => {
|
const csrfGuard: MiddlewareHandler = async (c, next) => {
|
||||||
|
// The mail server's sign-in page sends the browser back here, so this one
|
||||||
|
// arrives cross-site by design. Its state, bound to a cookie, stands in.
|
||||||
|
if (c.req.method === "GET" && c.req.path.endsWith("/api/auth/callback")) return next();
|
||||||
const site = c.req.header("sec-fetch-site");
|
const site = c.req.header("sec-fetch-site");
|
||||||
if (site && site !== "same-origin" && site !== "none") {
|
if (site && site !== "same-origin" && site !== "none") {
|
||||||
return c.json({ error: "cross_site_request" }, 403);
|
return c.json({ error: "cross_site_request" }, 403);
|
||||||
@@ -229,7 +233,20 @@ const smallBodies: MiddlewareHandler = (c, next) => (LARGE_BODY_ROUTE.test(c.req
|
|||||||
|
|
||||||
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
||||||
const cookie = getCookie(c, config.cookieName);
|
const cookie = getCookie(c, config.cookieName);
|
||||||
const session = sessions.resolve(cookie);
|
let session = sessions.resolve(cookie);
|
||||||
|
if (session?.tokens && needsRefresh(session.tokens)) {
|
||||||
|
try {
|
||||||
|
session = await refreshSession(cookie!, session);
|
||||||
|
} catch (err) {
|
||||||
|
// Couldn't ask the server. The token may still have a few minutes; if
|
||||||
|
// not, the call itself will say so.
|
||||||
|
console.warn("[ihasmail] token refresh failed:", (err as Error).message);
|
||||||
|
}
|
||||||
|
if (!session) {
|
||||||
|
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||||
|
return c.json({ error: "unauthenticated" }, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return c.json({ error: "unauthenticated" }, 401);
|
return c.json({ error: "unauthenticated" }, 401);
|
||||||
}
|
}
|
||||||
@@ -237,6 +254,55 @@ const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
|||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* One refresh per session at a time: a page opening does several requests at
|
||||||
|
* once, and each would otherwise renew the same token.
|
||||||
|
*/
|
||||||
|
const refreshing = new Map<string, Promise<LiveSession | null>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renew an OAuth session's access token and keep the new one. Null when the
|
||||||
|
* server refused the refresh token (a password change revokes it), which
|
||||||
|
* ends the session.
|
||||||
|
*/
|
||||||
|
function refreshSession(cookie: string, session: LiveSession): Promise<LiveSession | null> {
|
||||||
|
let inFlight = refreshing.get(session.id);
|
||||||
|
if (!inFlight) {
|
||||||
|
inFlight = (async () => {
|
||||||
|
const renewed = await refreshTokens(upstreamFor(session.username), session.tokens!);
|
||||||
|
if (!renewed) {
|
||||||
|
sessions.destroy(session.id);
|
||||||
|
forgetUpstreamSession(session.id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
sessions.updateTokens(cookie, renewed);
|
||||||
|
return sessions.resolve(cookie);
|
||||||
|
})().finally(() => refreshing.delete(session.id));
|
||||||
|
refreshing.set(session.id, inFlight);
|
||||||
|
}
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What push keeps to renew an account's subscription long after the session
|
||||||
|
* that started it. A password is good until it changes; OAuth tokens get a
|
||||||
|
* copy that renews itself, since push outlives any one access token.
|
||||||
|
*/
|
||||||
|
export function pushCredential(session: LiveSession): { get(): Promise<string> } {
|
||||||
|
if (!session.tokens) {
|
||||||
|
const authorization = session.authorization;
|
||||||
|
return { get: async () => authorization };
|
||||||
|
}
|
||||||
|
let tokens: TokenSet = session.tokens;
|
||||||
|
const base = upstreamFor(session.username);
|
||||||
|
return {
|
||||||
|
async get() {
|
||||||
|
if (needsRefresh(tokens)) tokens = (await refreshTokens(base, tokens)) ?? tokens;
|
||||||
|
return `Bearer ${tokens.access}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope the session cookie to the mount, not the whole host.
|
* Scope the session cookie to the mount, not the whole host.
|
||||||
*
|
*
|
||||||
@@ -316,11 +382,88 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
/* Sent before sign-in like the rest of this: it says what the
|
/* Sent before sign-in like the rest of this: it says what the
|
||||||
installation has decided, not anything about who is asking. */
|
installation has decided, not anything about who is asking. */
|
||||||
settingsPolicy: config.settingsPolicy,
|
settingsPolicy: config.settingsPolicy,
|
||||||
|
/* "oauth": sign in on the mail server's own page (see oauth.ts). */
|
||||||
|
signIn: oauthEnabled() ? "oauth" : "password",
|
||||||
|
/* With "oauth": true when the server's page can take it from here, so
|
||||||
|
the sign-in form doesn't ask for an address first. */
|
||||||
|
signInDirect: oauthEnabled() && singleServer(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------- Auth ----------
|
// ---------- Auth ----------
|
||||||
|
/*
|
||||||
|
* Sign-in through the mail server's page. `start` sends the browser there;
|
||||||
|
* `callback` is where the server sends it back. See oauth.ts.
|
||||||
|
*/
|
||||||
|
const OAUTH_STATE_COOKIE = `${config.cookieName}_signin`;
|
||||||
|
|
||||||
|
api.get("/auth/oauth/start", async (c) => {
|
||||||
|
if (!oauthEnabled()) return c.json({ error: "not_found" }, 404);
|
||||||
|
const rateIp = rateLimitKey(clientIp(c));
|
||||||
|
if (!loginFloodLimiter.check(rateIp)) {
|
||||||
|
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
|
||||||
|
return c.redirect(`${basePath}/?signin_error=rate_limited`, 302);
|
||||||
|
}
|
||||||
|
const username = (c.req.query("username") ?? "").trim().slice(0, 320);
|
||||||
|
try {
|
||||||
|
const { location, state } = await startSignIn({ username, base: upstreamFor(username), remember: c.req.query("remember") === "1" });
|
||||||
|
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) {
|
||||||
|
console.warn("[ihasmail] could not start sign-in:", (err as Error).message);
|
||||||
|
return c.redirect(`${basePath}/?signin_error=unavailable`, 302);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.get("/auth/callback", async (c) => {
|
||||||
|
if (!oauthEnabled()) return c.json({ error: "not_found" }, 404);
|
||||||
|
const boundState = getCookie(c, OAUTH_STATE_COOKIE);
|
||||||
|
deleteCookie(c, OAUTH_STATE_COOKIE, { path: `${basePath}/api/auth` });
|
||||||
|
const fail = (code: string) => c.redirect(`${basePath}/?signin_error=${code}`, 302);
|
||||||
|
const rateIp = rateLimitKey(clientIp(c));
|
||||||
|
if (!loginFloodLimiter.check(rateIp)) return fail("rate_limited");
|
||||||
|
const state = c.req.query("state") ?? "";
|
||||||
|
const code = c.req.query("code") ?? "";
|
||||||
|
// The server's page sends `error` when the person cancels or is refused.
|
||||||
|
if (!code || c.req.query("error")) return fail("cancelled");
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await finishSignIn({ state, boundState, code });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SignInError) return fail(err.code);
|
||||||
|
console.warn("[ihasmail] sign-in exchange failed:", (err as Error).message);
|
||||||
|
return fail("unavailable");
|
||||||
|
}
|
||||||
|
const authorization = `Bearer ${result.tokens.access}`;
|
||||||
|
try {
|
||||||
|
const upstream = await fetchUpstreamSession(authorization, result.base);
|
||||||
|
if (!hasStalwartRegistry(upstream)) return fail("unsupported_server");
|
||||||
|
const username = upstream.username || result.username;
|
||||||
|
// Every later call finds the account's server from its name. If the
|
||||||
|
// server signed in an account that routes elsewhere, calls would go to
|
||||||
|
// the wrong server, so refuse it.
|
||||||
|
if (upstreamFor(username) !== result.base) return fail("wrong_account");
|
||||||
|
const { cookie, session } = sessions.create({
|
||||||
|
username,
|
||||||
|
account: accountKey(result.base, username),
|
||||||
|
tokens: result.tokens,
|
||||||
|
remember: result.remember,
|
||||||
|
userAgent: c.req.header("user-agent") ?? "",
|
||||||
|
ip: clientIp(c),
|
||||||
|
});
|
||||||
|
setSessionCookie(c, cookie, session.remember);
|
||||||
|
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||||
|
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
|
||||||
|
return c.redirect(`${basePath}/`, 302);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[ihasmail] sign-in failed after the exchange:", (err as Error).message);
|
||||||
|
return fail("unavailable");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
api.post("/auth/login", async (c) => {
|
api.post("/auth/login", async (c) => {
|
||||||
|
// With sign-in on the mail server's page, this form never sees a password.
|
||||||
|
if (oauthEnabled()) return c.json({ error: "oauth_required", message: "Sign in on the mail server's page." }, 403);
|
||||||
const ip = clientIp(c);
|
const ip = clientIp(c);
|
||||||
// What the limits count under: the address, or its /64 for IPv6.
|
// What the limits count under: the address, or its /64 for IPv6.
|
||||||
const rateIp = rateLimitKey(ip);
|
const rateIp = rateLimitKey(ip);
|
||||||
@@ -378,7 +521,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
{
|
{
|
||||||
error: "unsupported_server",
|
error: "unsupported_server",
|
||||||
message:
|
message:
|
||||||
"Your credentials are fine, but this mail server is older than Stalwart 0.16, which ihasmail needs. Upgrade the server, or run the release tagged stalwart-0.15-support.",
|
"Your credentials are fine, but this mail server isn't one this webmail supports.",
|
||||||
},
|
},
|
||||||
501,
|
501,
|
||||||
);
|
);
|
||||||
@@ -396,7 +539,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
// Start the account's push subscription now, so it is usually verified
|
// Start the account's push subscription now, so it is usually verified
|
||||||
// by the time the browser opens its stream. See push.ts.
|
// by the time the browser opens its stream. See push.ts.
|
||||||
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||||
if (mailAccount) pushPrepare(session.username, mailAccount, session.authorization);
|
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
|
||||||
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -421,7 +564,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
{
|
{
|
||||||
error: "totp_unsupported",
|
error: "totp_unsupported",
|
||||||
message:
|
message:
|
||||||
"This mail server does not accept two-factor codes from webmail. Sign in with an app password instead — create one in Stalwart's own settings, under app passwords. Your password and code are probably fine.",
|
"This mail server does not accept two-factor codes from this form. Sign in with an app password instead. Your password and code are probably fine.",
|
||||||
},
|
},
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
@@ -486,7 +629,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
const accountCtx = async (c: Context<Env>) => {
|
const accountCtx = async (c: Context<Env>) => {
|
||||||
const session = c.get("session");
|
const session = c.get("session");
|
||||||
// The account's own server. Without it, the first fetch after the cached
|
// The account's own server. Without it, the first fetch after the cached
|
||||||
// session expires goes to STALWART_URL -- which, for a domain mapped
|
// session expires goes to MAIL_SERVER_URL -- which, for a domain mapped
|
||||||
// elsewhere, either refuses the password or knows a different account by
|
// elsewhere, either refuses the password or knows a different account by
|
||||||
// the same name (#238).
|
// the same name (#238).
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||||
@@ -535,6 +678,14 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return accountFailure(c, err);
|
return accountFailure(c, err);
|
||||||
}
|
}
|
||||||
|
if (session.tokens) {
|
||||||
|
// The server revokes every token when the password changes, this
|
||||||
|
// session's included, so there is nothing to keep: sign in again.
|
||||||
|
forgetUpstreamSession(session.id);
|
||||||
|
const revoked = sessions.destroyAllForUser(session.account);
|
||||||
|
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||||
|
return c.json({ ok: true, revokedSessions: revoked - 1, signedOut: true });
|
||||||
|
}
|
||||||
// The old password is now dead: re-seal this session with the new one and
|
// The old password is now dead: re-seal this session with the new one and
|
||||||
// drop the others, whose sealed copies would fail on their next call.
|
// drop the others, whose sealed copies would fail on their next call.
|
||||||
const otpCode = body.otpCode?.trim();
|
const otpCode = body.otpCode?.trim();
|
||||||
@@ -625,6 +776,16 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return accountFailure(c, err);
|
return accountFailure(c, err);
|
||||||
}
|
}
|
||||||
|
if (session.tokens) {
|
||||||
|
// Signed in on the server's page, where two-factor is asked for, so
|
||||||
|
// nothing here needs moving onto an app password.
|
||||||
|
try {
|
||||||
|
await enableOtp(ctx, { url: body.url, code, current: body.current });
|
||||||
|
} catch (err) {
|
||||||
|
return accountFailure(c, err);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, ...(await afterCredentialChange(c, session)) });
|
||||||
|
}
|
||||||
let app: { id: string; secret: string } | null = null;
|
let app: { id: string; secret: string } | null = null;
|
||||||
try {
|
try {
|
||||||
app = await createAppPassword(ctx, { description: appPasswordName(c) });
|
app = await createAppPassword(ctx, { description: appPasswordName(c) });
|
||||||
@@ -663,6 +824,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return accountFailure(c, err);
|
return accountFailure(c, err);
|
||||||
}
|
}
|
||||||
|
if (session.tokens) return c.json({ ok: true, ...(await afterCredentialChange(c, session)) });
|
||||||
// This session may be running on the app password minted when 2FA went on;
|
// This session may be running on the app password minted when 2FA went on;
|
||||||
// the plain password works again now, so put it back.
|
// the plain password works again now, so put it back.
|
||||||
sessions.reseal(getCookie(c, config.cookieName), body.current);
|
sessions.reseal(getCookie(c, config.cookieName), body.current);
|
||||||
@@ -880,7 +1042,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
// relay, and is moved to fan-out the moment the account verifies.
|
// relay, and is moved to fan-out the moment the account verifies.
|
||||||
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||||
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
|
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
|
||||||
if (accountId && pushAttach(session.username, accountId, session.authorization, out)) {
|
if (accountId && pushAttach(session.username, accountId, pushCredential(session), out)) {
|
||||||
out.writeHead(200, SSE_HEADERS);
|
out.writeHead(200, SSE_HEADERS);
|
||||||
out.flushHeaders();
|
out.flushHeaders();
|
||||||
out.write(": subscribed\n\n");
|
out.write(": subscribed\n\n");
|
||||||
@@ -957,6 +1119,16 @@ async function readJson<T>(c: Context): Promise<T | null> {
|
|||||||
* server.
|
* server.
|
||||||
*/
|
*/
|
||||||
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
|
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
|
||||||
|
if (session.tokens) {
|
||||||
|
// Holding no password, the only judge is the server.
|
||||||
|
try {
|
||||||
|
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
|
||||||
|
await fetchUpstreamSession(authorization, upstreamFor(session.username));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const decoded = Buffer.from(session.authorization.replace(/^Basic /, ""), "base64").toString("utf8");
|
const decoded = Buffer.from(session.authorization.replace(/^Basic /, ""), "base64").toString("utf8");
|
||||||
const held = decoded.slice(decoded.indexOf(":") + 1);
|
const held = decoded.slice(decoded.indexOf(":") + 1);
|
||||||
if (safeEqual(held, candidate)) return true;
|
if (safeEqual(held, candidate)) return true;
|
||||||
@@ -974,6 +1146,24 @@ async function confirmsPassword(session: LiveSession, candidate: string): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a two-factor change on a token session: whether the server still
|
||||||
|
* honors this session's token. If it revoked it, end the session here too,
|
||||||
|
* so the web app can send the person to sign in again.
|
||||||
|
*/
|
||||||
|
async function afterCredentialChange(c: Context<Env>, session: LiveSession): Promise<{ signedOut: boolean }> {
|
||||||
|
forgetUpstreamSession(session.id);
|
||||||
|
try {
|
||||||
|
await fetchUpstreamSession(session.authorization, upstreamFor(session.username));
|
||||||
|
return { signedOut: false };
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof UpstreamError && err.status === 401)) return { signedOut: false };
|
||||||
|
sessions.destroyAllForUser(session.account);
|
||||||
|
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||||
|
return { signedOut: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Direction overrides and isolates, which can make `Invoice_\u202Efdp.exe`
|
* Direction overrides and isolates, which can make `Invoice_\u202Efdp.exe`
|
||||||
* read as a PDF in the downloads list. A filename has no use for them.
|
* read as a PDF in the downloads list. A filename has no use for them.
|
||||||
@@ -999,6 +1189,8 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
|||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
loginName: session.username,
|
loginName: session.username,
|
||||||
remember: session.remember,
|
remember: session.remember,
|
||||||
|
/** "oauth": signed in on the mail server's page, holding tokens, not a password. */
|
||||||
|
signIn: session.tokens ? "oauth" : "password",
|
||||||
/** Locale configured for the account in Stalwart's directory, if readable. */
|
/** Locale configured for the account in Stalwart's directory, if readable. */
|
||||||
userLocale: info.locale,
|
userLocale: info.locale,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||||
const { createApp } = await import("./app.js");
|
const { createApp } = await import("./app.js");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ writeFileSync(join(root, "assets", "app.js"), script);
|
|||||||
writeFileSync(join(root, "index.html"), `<!doctype html><title>t</title>${"<p>hello</p>".repeat(400)}`);
|
writeFileSync(join(root, "index.html"), `<!doctype html><title>t</title>${"<p>hello</p>".repeat(400)}`);
|
||||||
|
|
||||||
process.env.STATIC_DIR = root;
|
process.env.STATIC_DIR = root;
|
||||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||||
const { createApp } = await import("./app.js");
|
const { createApp } = await import("./app.js");
|
||||||
|
|
||||||
test("an asset is gzipped when the client asks for it", async () => {
|
test("an asset is gzipped when the client asks for it", async () => {
|
||||||
|
|||||||
+37
-18
@@ -57,7 +57,7 @@ if (!appSecret || appSecret === "change-me") {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, "");
|
const stalwartUrl = env("MAIL_SERVER_URL", "https://mail.example.com").replace(/\/+$/, "");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Declares that this instance is running as an immutable container: read-only
|
* Declares that this instance is running as an immutable container: read-only
|
||||||
@@ -190,7 +190,7 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
|
|||||||
/**
|
/**
|
||||||
* Which Stalwart a domain signs in to.
|
* Which Stalwart a domain signs in to.
|
||||||
*
|
*
|
||||||
* `STALWART_URL` stays required and stays the default; this only adds domains
|
* `MAIL_SERVER_URL` stays required and stays the default; this only adds domains
|
||||||
* that go somewhere else (#238). An installation that sets nothing behaves
|
* that go somewhere else (#238). An installation that sets nothing behaves
|
||||||
* exactly as it always has.
|
* exactly as it always has.
|
||||||
*
|
*
|
||||||
@@ -203,15 +203,15 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
|
|||||||
* one is unreachable is a sign-in question, answered in #239.
|
* one is unreachable is a sign-in question, answered in #239.
|
||||||
*/
|
*/
|
||||||
function readStalwartServers(): { urls: Record<string, string>; adminUrls: Record<string, string> } {
|
function readStalwartServers(): { urls: Record<string, string>; adminUrls: Record<string, string> } {
|
||||||
const file = process.env.STALWART_SERVERS_FILE;
|
const file = process.env.MAIL_SERVERS_FILE;
|
||||||
if (!file) return { urls: {}, adminUrls: {} };
|
if (!file) return { urls: {}, adminUrls: {} };
|
||||||
if (!existsSync(file)) throw new Error(`STALWART_SERVERS_FILE does not exist: ${file}`);
|
if (!existsSync(file)) throw new Error(`MAIL_SERVERS_FILE does not exist: ${file}`);
|
||||||
|
|
||||||
let raw: unknown;
|
let raw: unknown;
|
||||||
try {
|
try {
|
||||||
raw = JSON.parse(readFileSync(file, "utf8"));
|
raw = JSON.parse(readFileSync(file, "utf8"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): ${(err as Error).message}`);
|
throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
return parseStalwartServers(raw, file);
|
return parseStalwartServers(raw, file);
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ function readStalwartServers(): { urls: Record<string, string>; adminUrls: Recor
|
|||||||
/** The servers file's contents, checked. Exported so the shipped example is tested by the parser that reads it. */
|
/** The servers file's contents, checked. Exported so the shipped example is tested by the parser that reads it. */
|
||||||
export function parseStalwartServers(raw: unknown, file: string): { urls: Record<string, string>; adminUrls: Record<string, string> } {
|
export function parseStalwartServers(raw: unknown, file: string): { urls: Record<string, string>; adminUrls: Record<string, string> } {
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): expected an object of domain to URL`);
|
throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): expected an object of domain to URL`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
@@ -233,16 +233,16 @@ export function parseStalwartServers(raw: unknown, file: string): { urls: Record
|
|||||||
taken off a username will arrive and comparing them any other way means
|
taken off a username will arrive and comparing them any other way means
|
||||||
a mapping that silently never matches. */
|
a mapping that silently never matches. */
|
||||||
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
|
const domain = rawDomain.trim().toLowerCase().replace(/\.$/, "");
|
||||||
if (!domain) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): a domain key is empty`);
|
if (!domain) throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): a domain key is empty`);
|
||||||
if (domain in out) throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" appears twice once normalized`);
|
if (domain in out) throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): "${domain}" appears twice once normalized`);
|
||||||
/* A domain's value is its server's URL, or an object that also names where
|
/* A domain's value is its server's URL, or an object that also names where
|
||||||
that server's own administration is: `{"url": …, "adminUrl": …}`. */
|
that server's own administration is: `{"url": …, "adminUrl": …}`. */
|
||||||
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, unknown>) : { url: rawValue };
|
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, unknown>) : { url: rawValue };
|
||||||
if (typeof value.url !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" is not a URL`);
|
if (typeof value.url !== "string") throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): "${domain}" is not a URL`);
|
||||||
out[domain] = httpUrl(value.url, `STALWART_SERVERS_FILE (${file}): "${domain}"`);
|
out[domain] = httpUrl(value.url, `MAIL_SERVERS_FILE (${file}): "${domain}"`);
|
||||||
if (value.adminUrl !== undefined) {
|
if (value.adminUrl !== undefined) {
|
||||||
if (typeof value.adminUrl !== "string") throw new Error(`Invalid STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl is not a URL`);
|
if (typeof value.adminUrl !== "string") throw new Error(`Invalid MAIL_SERVERS_FILE (${file}): "${domain}" adminUrl is not a URL`);
|
||||||
adminUrls[domain] = httpUrl(value.adminUrl, `STALWART_SERVERS_FILE (${file}): "${domain}" adminUrl`);
|
adminUrls[domain] = httpUrl(value.adminUrl, `MAIL_SERVERS_FILE (${file}): "${domain}" adminUrl`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { urls: out, adminUrls };
|
return { urls: out, adminUrls };
|
||||||
@@ -262,9 +262,23 @@ function httpUrl(raw: string, where: string): string {
|
|||||||
|
|
||||||
const stalwartServers = readStalwartServers();
|
const stalwartServers = readStalwartServers();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Signing in through the mail server's own page. On when OAUTH_CLIENT_SECRET
|
||||||
|
* is set: the secret of the confidential client the server registers for this
|
||||||
|
* webmail (INBUXA registers `ihasmail-inbuxa` from INBUXA_WEBMAIL_URL and
|
||||||
|
* INBUXA_WEBMAIL_CLIENT_SECRET). PUBLIC_URL is where browsers reach ihasmail,
|
||||||
|
* without BASE_PATH; the redirect URI is built from it and must match the one
|
||||||
|
* registered exactly.
|
||||||
|
*/
|
||||||
|
const oauthClientSecret = process.env.OAUTH_CLIENT_SECRET ?? "";
|
||||||
|
const publicUrl = process.env.PUBLIC_URL ? httpUrl(process.env.PUBLIC_URL, "PUBLIC_URL") : "";
|
||||||
|
if (oauthClientSecret && !publicUrl) {
|
||||||
|
throw new Error("OAUTH_CLIENT_SECRET is set but PUBLIC_URL is not: the sign-in redirect needs ihasmail's public address");
|
||||||
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
isProd,
|
isProd,
|
||||||
appName: env("APP_NAME", "ihasmail"),
|
appName: env("APP_NAME", "INBUXA"),
|
||||||
settingsPolicy: readSettingsPolicy(),
|
settingsPolicy: readSettingsPolicy(),
|
||||||
/**
|
/**
|
||||||
* What this build calls itself: `2.16.57`. Set by the image build from
|
* What this build calls itself: `2.16.57`. Set by the image build from
|
||||||
@@ -278,9 +292,10 @@ export const config = {
|
|||||||
*
|
*
|
||||||
* The AGPL asks whoever *runs* a modified version to offer that version's
|
* 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
|
* source, not the one it was forked from -- so anyone deploying a patched
|
||||||
* ihasmail should point this at their own tree.
|
* ihasmail should point this at their own tree. ihasmail-inbuxa is itself
|
||||||
|
* such a tree, so the default is INBUXA's fork.
|
||||||
*/
|
*/
|
||||||
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"),
|
sourceUrl: env("SOURCE_URL", "https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa"),
|
||||||
host: env("HOST", "0.0.0.0"),
|
host: env("HOST", "0.0.0.0"),
|
||||||
port: int("PORT", 8080),
|
port: int("PORT", 8080),
|
||||||
/**
|
/**
|
||||||
@@ -300,12 +315,12 @@ export const config = {
|
|||||||
stalwartServers: stalwartServers.urls,
|
stalwartServers: stalwartServers.urls,
|
||||||
/**
|
/**
|
||||||
* Where an administrator reaches Stalwart's own administration, for the
|
* Where an administrator reaches Stalwart's own administration, for the
|
||||||
* pointer on ihasmail's dashboard. Optional, and separate from STALWART_URL,
|
* pointer on ihasmail's dashboard. Optional, and separate from MAIL_SERVER_URL,
|
||||||
* which is how *this server* reaches Stalwart -- often an address no browser
|
* which is how *this server* reaches Stalwart -- often an address no browser
|
||||||
* can open. Unset, the dashboard names Stalwart's administration without a
|
* can open. Unset, the dashboard names Stalwart's administration without a
|
||||||
* link. A domain routed elsewhere takes its server's `adminUrl` instead.
|
* link. A domain routed elsewhere takes its server's `adminUrl` instead.
|
||||||
*/
|
*/
|
||||||
stalwartAdminUrl: process.env.STALWART_ADMIN_URL ? httpUrl(process.env.STALWART_ADMIN_URL, "STALWART_ADMIN_URL") : "",
|
stalwartAdminUrl: process.env.ADMIN_URL ? httpUrl(process.env.ADMIN_URL, "ADMIN_URL") : "",
|
||||||
stalwartAdminUrls: stalwartServers.adminUrls,
|
stalwartAdminUrls: stalwartServers.adminUrls,
|
||||||
/**
|
/**
|
||||||
* Say that an Enterprise-only section is Enterprise-only even on an
|
* Say that an Enterprise-only section is Enterprise-only even on an
|
||||||
@@ -314,6 +329,10 @@ export const config = {
|
|||||||
* should not suggest they come without the license.
|
* should not suggest they come without the license.
|
||||||
*/
|
*/
|
||||||
showEnterpriseNotices: bool("SHOW_ENTERPRISE_NOTICES", false),
|
showEnterpriseNotices: bool("SHOW_ENTERPRISE_NOTICES", false),
|
||||||
|
/** See the note above `config`. Empty keeps the password form. */
|
||||||
|
oauthClientSecret,
|
||||||
|
oauthClientId: env("OAUTH_CLIENT_ID", "ihasmail-inbuxa"),
|
||||||
|
publicUrl,
|
||||||
appSecret,
|
appSecret,
|
||||||
trustProxy: bool("TRUST_PROXY", true),
|
trustProxy: bool("TRUST_PROXY", true),
|
||||||
/**
|
/**
|
||||||
@@ -367,7 +386,7 @@ export const config = {
|
|||||||
/* See relayPushRaw(): pipe the push stream socket-to-socket instead of through fetch(). */
|
/* See relayPushRaw(): pipe the push stream socket-to-socket instead of through fetch(). */
|
||||||
rawPushRelay: process.env.RAW_PUSH_RELAY !== "0",
|
rawPushRelay: process.env.RAW_PUSH_RELAY !== "0",
|
||||||
/* See absoluteUpstream(): follow Stalwart's advertised origin instead of pinning to ours. */
|
/* See absoluteUpstream(): follow Stalwart's advertised origin instead of pinning to ours. */
|
||||||
followAdvertisedUrls: process.env.STALWART_FOLLOW_ADVERTISED_URLS === "1",
|
followAdvertisedUrls: process.env.MAIL_SERVER_FOLLOW_ADVERTISED_URLS === "1",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Config = typeof config;
|
export type Config = typeof config;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
process.env.STALWART_URL = "https://default.example";
|
process.env.MAIL_SERVER_URL = "https://default.example";
|
||||||
|
|
||||||
const { upstreamFor } = await import("./upstream.js");
|
const { upstreamFor } = await import("./upstream.js");
|
||||||
const { config } = await import("./config.js");
|
const { config } = await import("./config.js");
|
||||||
@@ -9,7 +9,7 @@ const { config } = await import("./config.js");
|
|||||||
/**
|
/**
|
||||||
* Which Stalwart a username goes to (#238).
|
* Which Stalwart a username goes to (#238).
|
||||||
*
|
*
|
||||||
* `STALWART_URL` is required and is the default. The mapping only adds domains
|
* `MAIL_SERVER_URL` is required and is the default. The mapping only adds domains
|
||||||
* that go elsewhere, so an installation with no mapping behaves exactly as it
|
* that go elsewhere, so an installation with no mapping behaves exactly as it
|
||||||
* always has -- which is what these first cases pin.
|
* always has -- which is what these first cases pin.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||||
process.env.APP_SECRET = "test-secret-for-ics-proxy";
|
process.env.APP_SECRET = "test-secret-for-ics-proxy";
|
||||||
|
|
||||||
const { safeFetch, safeFetchStatus } = await import("./imageproxy.js");
|
const { safeFetch, safeFetchStatus } = await import("./imageproxy.js");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|||||||
import { createServer, request as httpRequest, type IncomingMessage, type Server } from "node:http";
|
import { createServer, request as httpRequest, type IncomingMessage, type Server } from "node:http";
|
||||||
import { AddressInfo } from "node:net";
|
import { AddressInfo } from "node:net";
|
||||||
|
|
||||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||||
process.env.APP_SECRET = "test-secret-for-image-proxy";
|
process.env.APP_SECRET = "test-secret-for-image-proxy";
|
||||||
|
|
||||||
const { fetchPinned, isPrivateAddress } = await import("./imageproxy.js");
|
const { fetchPinned, isPrivateAddress } = await import("./imageproxy.js");
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ async function main() {
|
|||||||
const app = createApp();
|
const app = createApp();
|
||||||
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
||||||
console.log(`[ihasmail] ${config.appName} listening on http://${info.address}:${info.port}`);
|
console.log(`[ihasmail] ${config.appName} listening on http://${info.address}:${info.port}`);
|
||||||
console.log(`[ihasmail] upstream Stalwart: ${config.stalwartUrl}`);
|
console.log(`[ihasmail] mail server: ${config.stalwartUrl}`);
|
||||||
console.log(`[ihasmail] static dir: ${config.staticDir}`);
|
console.log(`[ihasmail] static dir: ${config.staticDir}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ process.env.MOCK_PORT = String(PORT);
|
|||||||
process.env.MOCK_USER = "[email protected]";
|
process.env.MOCK_USER = "[email protected]";
|
||||||
process.env.MOCK_PASS = "demo-password";
|
process.env.MOCK_PASS = "demo-password";
|
||||||
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap
|
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap
|
||||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||||
process.env.APP_SECRET = "test-secret-for-login-guard";
|
process.env.APP_SECRET = "test-secret-for-login-guard";
|
||||||
|
|
||||||
const mock = await import("./mock/index.js");
|
const mock = await import("./mock/index.js");
|
||||||
@@ -48,13 +48,12 @@ test("a server without the registry is refused, with good credentials", async ()
|
|||||||
assert.equal(res.body.error, "unsupported_server");
|
assert.equal(res.body.error, "unsupported_server");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the message says the credentials were fine, and names the way out", async () => {
|
test("the message says the credentials were fine, and that the server isn't supported", async () => {
|
||||||
const { body } = await login({ username: "[email protected]", password: "demo-password" });
|
const { body } = await login({ username: "[email protected]", password: "demo-password" });
|
||||||
// Someone hitting this has typed a correct password. Saying so is the
|
// Someone hitting this has typed a correct password. Saying so is the
|
||||||
// difference between "upgrade your server" and "try your password again".
|
// difference between "wrong server" and "try your password again".
|
||||||
assert.match(body.message, /credentials are fine/i);
|
assert.match(body.message, /credentials are fine/i);
|
||||||
assert.match(body.message, /0\.16/);
|
assert.match(body.message, /isn't one this webmail supports/);
|
||||||
assert.match(body.message, /stalwart-0\.15-support/, "the tag to build from if they cannot upgrade");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("no session is minted for a server we cannot talk to", async () => {
|
test("no session is minted for a server we cannot talk to", async () => {
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export function createDirectory(opts: Options) {
|
|||||||
push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0);
|
push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const applications: Obj[] = [{ id: "app1", description: "Stalwart Web Interface", enabled: true, urlPrefix: { "/admin": true, "/account": true } }];
|
const applications: Obj[] = [{ id: "app1", description: "Web Interface", enabled: true, urlPrefix: { "/admin": true, "/account": true } }];
|
||||||
|
|
||||||
/** Tenants: a name, limits, and whatever names them in its memberTenantId. */
|
/** Tenants: a name, limits, and whatever names them in its memberTenantId. */
|
||||||
const tenants: Obj[] = [
|
const tenants: Obj[] = [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* A tiny in-memory JMAP server that mimics the subset of Stalwart that ihasmail
|
* A tiny in-memory JMAP server that mimics the subset of Stalwart that ihasmail
|
||||||
* uses. For local development and demos only: `npm run mock` then point the
|
* uses. For local development and demos only: `npm run mock` then point the
|
||||||
* server at it with STALWART_URL=http://127.0.0.1:8788 (user: demo / pass: demo).
|
* server at it with MAIL_SERVER_URL=http://127.0.0.1:8788 (user: demo / pass: demo).
|
||||||
*/
|
*/
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||||
@@ -14,6 +14,7 @@ import { MAX_OBJECTS, MethodError, directory, enforceLimits, resolveRefs } from
|
|||||||
import { handlers } from "./handlers.js";
|
import { handlers } from "./handlers.js";
|
||||||
export { account } from "./config.js";
|
export { account } from "./config.js";
|
||||||
import { checkOtp } from "./auth.js";
|
import { checkOtp } from "./auth.js";
|
||||||
|
import { checkBearer, handleOAuth } from "./oauth.js";
|
||||||
import { sseClients, broadcast } from "./events.js";
|
import { sseClients, broadcast } from "./events.js";
|
||||||
|
|
||||||
/* ---------- http ---------- */
|
/* ---------- http ---------- */
|
||||||
@@ -24,6 +25,7 @@ function unauthorized(res: ServerResponse) {
|
|||||||
|
|
||||||
function checkAuth(req: IncomingMessage): boolean {
|
function checkAuth(req: IncomingMessage): boolean {
|
||||||
const h = req.headers.authorization ?? "";
|
const h = req.headers.authorization ?? "";
|
||||||
|
if (checkBearer(h)) return true;
|
||||||
if (!h.startsWith("Basic ")) return false;
|
if (!h.startsWith("Basic ")) return false;
|
||||||
const raw = Buffer.from(h.slice(6), "base64").toString();
|
const raw = Buffer.from(h.slice(6), "base64").toString();
|
||||||
const sep = raw.indexOf(":");
|
const sep = raw.indexOf(":");
|
||||||
@@ -77,6 +79,7 @@ const session = () => ({
|
|||||||
/** Exported so tests can drive the mock in-process and shut it down. */
|
/** Exported so tests can drive the mock in-process and shut it down. */
|
||||||
export const server = createServer(async (req, res) => {
|
export const server = createServer(async (req, res) => {
|
||||||
const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);
|
const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);
|
||||||
|
if (await handleOAuth(req, res, url)) return;
|
||||||
if (!checkAuth(req)) return unauthorized(res);
|
if (!checkAuth(req)) return unauthorized(res);
|
||||||
if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") {
|
if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") {
|
||||||
res.writeHead(200, { "content-type": "application/json" });
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
@@ -202,7 +205,7 @@ export const server = createServer(async (req, res) => {
|
|||||||
res.end(JSON.stringify({ error: "not found" }));
|
res.end(JSON.stringify({ error: "not found" }));
|
||||||
}).listen(PORT, "127.0.0.1", () => {
|
}).listen(PORT, "127.0.0.1", () => {
|
||||||
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
|
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
|
||||||
console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`);
|
console.log(`[mock-stalwart] run the app with: MAIL_SERVER_URL=http://127.0.0.1:${PORT} npm run dev`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Periodically inject a new inbox email to demo push
|
// Periodically inject a new inbox email to demo push
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* The mock's OAuth side, enough to sign in the way INBUXA's server does:
|
||||||
|
* metadata, a sign-in page, and a token endpoint for one confidential client.
|
||||||
|
*
|
||||||
|
* The sign-in page approves the demo user at once: there is no form, since
|
||||||
|
* what's being exercised is ihasmail's side of the flow. Tokens are tied to
|
||||||
|
* the password they were issued under, so a password change revokes them,
|
||||||
|
* as it does on the real server.
|
||||||
|
*/
|
||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { PORT, USER, account } from "./config.js";
|
||||||
|
|
||||||
|
export const OAUTH_CLIENT_ID = process.env.MOCK_OAUTH_CLIENT_ID ?? "ihasmail-inbuxa";
|
||||||
|
export const OAUTH_CLIENT_SECRET = process.env.MOCK_OAUTH_CLIENT_SECRET ?? "mock-oauth-secret";
|
||||||
|
/** Seconds an access token lasts. */
|
||||||
|
export let accessTokenTtl = Number(process.env.MOCK_OAUTH_TOKEN_TTL ?? 3600);
|
||||||
|
|
||||||
|
interface Grant { password: string }
|
||||||
|
const codes = new Map<string, { challenge: string; redirectUri: string; issuedAt: number }>();
|
||||||
|
const accessTokens = new Map<string, Grant & { expiresAt: number }>();
|
||||||
|
const refreshTokens = new Map<string, Grant>();
|
||||||
|
|
||||||
|
const base = () => `http://127.0.0.1:${PORT}`;
|
||||||
|
|
||||||
|
/** 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; },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A bearer token the mock issued, still valid under the current password. */
|
||||||
|
export function checkBearer(header: string): boolean {
|
||||||
|
if (!header.startsWith("Bearer ")) return false;
|
||||||
|
const t = accessTokens.get(header.slice(7));
|
||||||
|
return Boolean(t && t.expiresAt > Date.now() && t.password === account.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(res: ServerResponse, status: number, body: unknown) {
|
||||||
|
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readForm(req: IncomingMessage): Promise<URLSearchParams> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on("data", (c) => chunks.push(c));
|
||||||
|
req.on("end", () => resolve(new URLSearchParams(Buffer.concat(chunks).toString())));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
const body: Record<string, unknown> = { access_token: access, token_type: "bearer", expires_in: accessTokenTtl };
|
||||||
|
if (!refresh) {
|
||||||
|
const fresh = `mock-rt-${randomBytes(16).toString("hex")}`;
|
||||||
|
refreshTokens.set(fresh, { password: account.password });
|
||||||
|
body.refresh_token = fresh;
|
||||||
|
}
|
||||||
|
return json(res, 200, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles the OAuth routes; false for anything else. */
|
||||||
|
export async function handleOAuth(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
|
||||||
|
if (url.pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
||||||
|
json(res, 200, {
|
||||||
|
issuer: base(),
|
||||||
|
authorization_endpoint: `${base()}/login`,
|
||||||
|
token_endpoint: `${base()}/auth/token`,
|
||||||
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||||
|
response_types_supported: ["code"],
|
||||||
|
scopes_supported: ["openid", "offline_access"],
|
||||||
|
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
||||||
|
code_challenge_methods_supported: ["S256"],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/login" && req.method === "GET") {
|
||||||
|
const q = url.searchParams;
|
||||||
|
const redirectUri = q.get("redirect_uri") ?? "";
|
||||||
|
if (q.get("client_id") !== OAUTH_CLIENT_ID || q.get("response_type") !== "code" || !redirectUri || q.get("code_challenge_method") !== "S256") {
|
||||||
|
json(res, 400, { error: "invalid_request" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const code = randomBytes(16).toString("hex");
|
||||||
|
codes.set(code, { challenge: q.get("code_challenge") ?? "", redirectUri, issuedAt: Date.now() });
|
||||||
|
const back = new URL(redirectUri);
|
||||||
|
back.searchParams.set("code", code);
|
||||||
|
back.searchParams.set("state", q.get("state") ?? "");
|
||||||
|
res.writeHead(302, { location: back.toString() });
|
||||||
|
res.end();
|
||||||
|
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) {
|
||||||
|
json(res, 400, { error: "invalid_client" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (form.get("grant_type") === "authorization_code") {
|
||||||
|
const code = codes.get(form.get("code") ?? "");
|
||||||
|
codes.delete(form.get("code") ?? "");
|
||||||
|
const verifier = form.get("code_verifier") ?? "";
|
||||||
|
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||||
|
if (!code || code.challenge !== challenge || code.redirectUri !== form.get("redirect_uri") || Date.now() - code.issuedAt > 600_000) {
|
||||||
|
json(res, 400, { error: "invalid_grant" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
issue(res, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (form.get("grant_type") === "refresh_token") {
|
||||||
|
const refresh = form.get("refresh_token") ?? "";
|
||||||
|
const grant = refreshTokens.get(refresh);
|
||||||
|
if (!grant || grant.password !== account.password) {
|
||||||
|
json(res, 400, { error: "invalid_grant" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
issue(res, refresh);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
json(res, 400, { error: "unsupported_grant_type" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The username the mock signs in, for tests. */
|
||||||
|
export const OAUTH_USER = USER;
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { test, before, after, beforeEach } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing in on the mail server's own page, end to end against the mock's
|
||||||
|
* OAuth side: the redirect out, the callback, the session holding tokens
|
||||||
|
* instead of a password, token renewal, and what ends a session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PORT = 18811;
|
||||||
|
process.env.MOCK_PORT = String(PORT);
|
||||||
|
process.env.MOCK_USER = "[email protected]";
|
||||||
|
process.env.MOCK_PASS = "demo-password";
|
||||||
|
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||||
|
process.env.APP_SECRET = "test-secret-for-oauth";
|
||||||
|
process.env.OAUTH_CLIENT_SECRET = "mock-oauth-secret";
|
||||||
|
process.env.PUBLIC_URL = "https://webmail.example.test";
|
||||||
|
|
||||||
|
const mock = await import("./mock/index.js");
|
||||||
|
const { oauthMock } = await import("./mock/oauth.js");
|
||||||
|
const { createApp, pushCredential, sessions } = await import("./app.js");
|
||||||
|
const { resetOAuthState } = await import("./oauth.js");
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
const CALLBACK = "https://webmail.example.test/api/auth/callback";
|
||||||
|
|
||||||
|
/** A cookie jar, since sign-in sets two cookies on different paths. */
|
||||||
|
let jar = new Map<string, string>();
|
||||||
|
|
||||||
|
function keepCookies(res: Response) {
|
||||||
|
for (const header of res.headers.getSetCookie()) {
|
||||||
|
const [pair, ...attrs] = header.split(";");
|
||||||
|
const [name, value] = [pair!.slice(0, pair!.indexOf("=")), pair!.slice(pair!.indexOf("=") + 1)];
|
||||||
|
const expired = attrs.some((a) => /max-age=0\b/i.test(a.trim()) || /expires=thu, 01 jan 1970/i.test(a.trim()));
|
||||||
|
if (expired || value === "") jar.delete(name);
|
||||||
|
else jar.set(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(path: string, init: RequestInit = {}) {
|
||||||
|
const cookie = [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
||||||
|
const res = await app.request(path, {
|
||||||
|
...init,
|
||||||
|
headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}), ...(init.headers as Record<string, string>) },
|
||||||
|
});
|
||||||
|
keepCookies(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jsonOf(res: Response) {
|
||||||
|
const text = await res.text();
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leave for the server's page and come back: returns the callback URL. */
|
||||||
|
async function goToServerAndBack(username = "[email protected]"): Promise<URL> {
|
||||||
|
const start = await call(`/api/auth/oauth/start?username=${encodeURIComponent(username)}&remember=1`);
|
||||||
|
assert.equal(start.status, 302);
|
||||||
|
const signInPage = new URL(start.headers.get("location")!);
|
||||||
|
const approved = await fetch(signInPage, { redirect: "manual" });
|
||||||
|
assert.equal(approved.status, 302, "the mock's page approves the demo user");
|
||||||
|
return new URL(approved.headers.get("location")!);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn() {
|
||||||
|
const back = await goToServerAndBack();
|
||||||
|
const res = await call(`/api/auth/callback${back.search}`);
|
||||||
|
assert.equal(res.status, 302);
|
||||||
|
assert.equal(res.headers.get("location"), "/");
|
||||||
|
assert.ok(jar.get("ihm_session"), "a session cookie was set");
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jar = new Map();
|
||||||
|
oauthMock.reset();
|
||||||
|
resetOAuthState();
|
||||||
|
});
|
||||||
|
|
||||||
|
before(() => {});
|
||||||
|
after(() => {
|
||||||
|
(mock as { server?: { close(): void } }).server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the configuration tells the web app to use the server's page", async () => {
|
||||||
|
const body = await jsonOf(await call("/api/config"));
|
||||||
|
assert.equal(body.signIn, "oauth");
|
||||||
|
assert.equal(body.signInDirect, true, "one mail server: its page asks for the username, not ihasmail");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with one mail server, sign-in starts without an address", async () => {
|
||||||
|
const res = await call("/api/auth/oauth/start");
|
||||||
|
assert.equal(res.status, 302);
|
||||||
|
const to = new URL(res.headers.get("location")!);
|
||||||
|
assert.equal(to.searchParams.has("login_hint"), false);
|
||||||
|
const approved = await fetch(to, { redirect: "manual" });
|
||||||
|
const back = new URL(approved.headers.get("location")!);
|
||||||
|
assert.equal((await call(`/api/auth/callback${back.search}`)).headers.get("location"), "/");
|
||||||
|
assert.equal((await call("/api/auth/session")).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the password form is refused: ihasmail never sees a password", async () => {
|
||||||
|
const res = await call("/api/auth/login", { method: "POST", body: JSON.stringify({ username: "[email protected]", password: "demo-password" }) });
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
assert.equal((await jsonOf(res)).error, "oauth_required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("start sends the browser to the server's page with PKCE and a bound state", async () => {
|
||||||
|
const res = await call("/api/auth/oauth/[email protected]");
|
||||||
|
assert.equal(res.status, 302);
|
||||||
|
const to = new URL(res.headers.get("location")!);
|
||||||
|
assert.equal(`${to.origin}${to.pathname}`, `http://127.0.0.1:${PORT}/login`);
|
||||||
|
assert.equal(to.searchParams.get("client_id"), "ihasmail-inbuxa");
|
||||||
|
assert.equal(to.searchParams.get("redirect_uri"), CALLBACK);
|
||||||
|
assert.equal(to.searchParams.get("response_type"), "code");
|
||||||
|
assert.equal(to.searchParams.get("code_challenge_method"), "S256");
|
||||||
|
assert.match(to.searchParams.get("code_challenge") ?? "", /^[\w-]{43}$/);
|
||||||
|
assert.equal(to.searchParams.get("login_hint"), "[email protected]");
|
||||||
|
assert.equal(to.searchParams.get("scope"), "openid offline_access");
|
||||||
|
assert.equal(jar.get("ihm_session_signin"), to.searchParams.get("state"), "the state is bound to this browser");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a full sign-in holds tokens, and the session works", async () => {
|
||||||
|
await signIn();
|
||||||
|
const res = await call("/api/auth/session");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await jsonOf(res);
|
||||||
|
assert.equal(body.ihasmail.loginName, "[email protected]");
|
||||||
|
assert.equal(jar.get("ihm_session_signin"), undefined, "the state cookie is cleared");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the callback comes back cross-site, and is still accepted", async () => {
|
||||||
|
const back = await goToServerAndBack();
|
||||||
|
const res = await call(`/api/auth/callback${back.search}`, { headers: { "sec-fetch-site": "cross-site" } });
|
||||||
|
assert.equal(res.headers.get("location"), "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a callback from a sign-in this browser didn't start is refused", async () => {
|
||||||
|
const back = await goToServerAndBack();
|
||||||
|
jar.delete("ihm_session_signin");
|
||||||
|
const res = await call(`/api/auth/callback${back.search}`);
|
||||||
|
assert.equal(res.headers.get("location"), "/?signin_error=state_mismatch");
|
||||||
|
assert.equal(jar.get("ihm_session"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a state is good for one attempt", async () => {
|
||||||
|
const back = await goToServerAndBack();
|
||||||
|
const state = jar.get("ihm_session_signin")!;
|
||||||
|
await call(`/api/auth/callback${back.search}`);
|
||||||
|
jar = new Map([["ihm_session_signin", state]]);
|
||||||
|
const again = await call(`/api/auth/callback${back.search}`);
|
||||||
|
assert.equal(again.headers.get("location"), "/?signin_error=state_mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelling on the server's page comes back as an error, not a session", async () => {
|
||||||
|
await call("/api/auth/oauth/[email protected]");
|
||||||
|
const state = jar.get("ihm_session_signin")!;
|
||||||
|
const res = await call(`/api/auth/callback?error=access_denied&state=${state}`);
|
||||||
|
assert.equal(res.headers.get("location"), "/?signin_error=cancelled");
|
||||||
|
assert.equal(jar.get("ihm_session"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a code the server won't exchange is refused", async () => {
|
||||||
|
const back = await goToServerAndBack();
|
||||||
|
back.searchParams.set("code", "not-a-code");
|
||||||
|
const res = await call(`/api/auth/callback${back.search}`);
|
||||||
|
assert.equal(res.headers.get("location"), "/?signin_error=exchange_failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an access token about to expire is renewed without the person noticing", async () => {
|
||||||
|
oauthMock.setAccessTokenTtl(60); // inside the renewal margin from the start
|
||||||
|
await signIn();
|
||||||
|
oauthMock.expireAccessTokens(); // the one the session holds is now dead upstream
|
||||||
|
const res = await call("/api/auth/session?refresh=1");
|
||||||
|
assert.equal(res.status, 200, "renewed before the call went upstream");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renewal refused by the server ends the session", async () => {
|
||||||
|
await signIn();
|
||||||
|
const cookie = jar.get("ihm_session")!;
|
||||||
|
const session = sessions.resolve(cookie)!;
|
||||||
|
// Pretend the token is about to expire, then make the server refuse to renew it.
|
||||||
|
sessions.updateTokens(cookie, { ...session.tokens!, expiresAt: Date.now() + 1000, refresh: "revoked" });
|
||||||
|
const res = await call("/api/auth/session");
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
assert.equal(jar.get("ihm_session"), undefined, "and the cookie is cleared");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a password change signs the session out, since the server revokes its tokens", async () => {
|
||||||
|
await signIn();
|
||||||
|
const res = await call("/api/account/password", { method: "POST", body: JSON.stringify({ current: "demo-password", next: "new-password-123" }) });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal((await jsonOf(res)).signedOut, true);
|
||||||
|
assert.equal((await call("/api/auth/session")).status, 401);
|
||||||
|
// Put it back for the tests after this one.
|
||||||
|
const { account } = await import("./mock/config.js");
|
||||||
|
account.password = "demo-password";
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creating an app password checks the typed password with the server", async () => {
|
||||||
|
await signIn();
|
||||||
|
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" }) });
|
||||||
|
assert.equal(right.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("push keeps a credential that renews itself", async () => {
|
||||||
|
oauthMock.setAccessTokenTtl(60);
|
||||||
|
await signIn();
|
||||||
|
const session = sessions.resolve(jar.get("ihm_session"))!;
|
||||||
|
const credential = pushCredential(session);
|
||||||
|
const first = await credential.get();
|
||||||
|
oauthMock.expireAccessTokens();
|
||||||
|
const second = await credential.get();
|
||||||
|
assert.notEqual(second, first, "a fresh access token");
|
||||||
|
assert.match(second, /^Bearer mock-at-/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Signing in through the mail server's own page (OAuth 2.0 authorization code
|
||||||
|
* with PKCE), so ihasmail never handles a password to sign someone in.
|
||||||
|
*
|
||||||
|
* The flow, with ihasmail as a confidential client registered on the server:
|
||||||
|
*
|
||||||
|
* 1. `start()` picks the account's server from the username, reads the
|
||||||
|
* server's OAuth metadata, and sends the browser to its sign-in page with
|
||||||
|
* a PKCE challenge and a one-time `state`. The state is bound to the
|
||||||
|
* browser by a short-lived cookie, so a callback carrying somebody else's
|
||||||
|
* code can't sign this browser into their account.
|
||||||
|
* 2. The person signs in there, two-factor included, and the server sends the
|
||||||
|
* browser back to `/api/auth/callback` with a code.
|
||||||
|
* 3. `finish()` checks the state, exchanges the code (with the PKCE verifier
|
||||||
|
* and this client's secret) for an access and a refresh token, and the
|
||||||
|
* session keeps those, sealed, instead of a password.
|
||||||
|
*
|
||||||
|
* Access tokens last an hour; `refreshTokens()` renews them before they run
|
||||||
|
* out. A password change on the server revokes both tokens, which ends every
|
||||||
|
* session holding them -- the safe result, and the one the web app is told
|
||||||
|
* about.
|
||||||
|
*
|
||||||
|
* Nothing here is taken from another client's implementation; the shapes are
|
||||||
|
* RFC 6749, RFC 7636 and RFC 8414.
|
||||||
|
*/
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { randomToken } from "./crypto.js";
|
||||||
|
import { UpstreamError, absoluteUpstream } from "./upstream.js";
|
||||||
|
|
||||||
|
export interface TokenSet {
|
||||||
|
access: string;
|
||||||
|
refresh: string | null;
|
||||||
|
/** When the access token expires, in ms since the epoch. */
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Metadata {
|
||||||
|
authorizationEndpoint: string;
|
||||||
|
tokenEndpoint: string;
|
||||||
|
scopes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renew an access token this long before it expires. */
|
||||||
|
export const REFRESH_MARGIN_MS = 5 * 60_000;
|
||||||
|
/** How long a sign-in may take between leaving and coming back. */
|
||||||
|
const PENDING_TTL_MS = 10 * 60_000;
|
||||||
|
const METADATA_TTL_MS = 60 * 60_000;
|
||||||
|
const MAX_PENDING = 10_000;
|
||||||
|
|
||||||
|
export function oauthEnabled(): boolean {
|
||||||
|
return Boolean(config.oauthClientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether every account is on the same server. Then sign-in needs no address
|
||||||
|
* first: the server's page asks for the username itself. With several servers
|
||||||
|
* (MAIL_SERVERS_FILE), the domain picks the server, so the address comes
|
||||||
|
* first.
|
||||||
|
*/
|
||||||
|
export function singleServer(): boolean {
|
||||||
|
return Object.values(config.stalwartServers).every((url) => url === config.stalwartUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one redirect URI registered for this client on the server. */
|
||||||
|
export function redirectUri(): string {
|
||||||
|
return `${config.publicUrl}${config.basePath}/api/auth/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataCache = new Map<string, { metadata: Metadata; fetchedAt: number }>();
|
||||||
|
|
||||||
|
async function metadataFor(base: string): Promise<Metadata> {
|
||||||
|
const cached = metadataCache.get(base);
|
||||||
|
if (cached && Date.now() - cached.fetchedAt < METADATA_TTL_MS) return cached.metadata;
|
||||||
|
const res = await fetch(`${base}/.well-known/oauth-authorization-server`, {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new UpstreamError(`OAuth metadata request failed (${res.status})`, 502);
|
||||||
|
const doc = (await res.json()) as { authorization_endpoint?: string; token_endpoint?: string; scopes_supported?: string[] };
|
||||||
|
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
||||||
|
throw new UpstreamError("The mail server's OAuth metadata has no authorization or token endpoint", 502);
|
||||||
|
}
|
||||||
|
const metadata = {
|
||||||
|
// Where the *browser* goes, so the server's public address, as advertised.
|
||||||
|
authorizationEndpoint: new URL(doc.authorization_endpoint, base).toString(),
|
||||||
|
// Where this process goes, so the configured route, like every other call.
|
||||||
|
tokenEndpoint: absoluteUpstream(doc.token_endpoint, base),
|
||||||
|
scopes: doc.scopes_supported ?? [],
|
||||||
|
};
|
||||||
|
metadataCache.set(base, { metadata, fetchedAt: Date.now() });
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pending {
|
||||||
|
verifier: string;
|
||||||
|
base: string;
|
||||||
|
username: string;
|
||||||
|
remember: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = new Map<string, Pending>();
|
||||||
|
|
||||||
|
function sweepPending(now = Date.now()) {
|
||||||
|
for (const [state, p] of pending) if (now - p.createdAt > PENDING_TTL_MS) pending.delete(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function challengeOf(verifier: string): string {
|
||||||
|
return createHash("sha256").update(verifier).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }> {
|
||||||
|
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() });
|
||||||
|
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");
|
||||||
|
url.searchParams.set("client_id", config.oauthClientId);
|
||||||
|
url.searchParams.set("redirect_uri", redirectUri());
|
||||||
|
if (scope) url.searchParams.set("scope", scope);
|
||||||
|
url.searchParams.set("state", state);
|
||||||
|
url.searchParams.set("code_challenge", challengeOf(verifier));
|
||||||
|
url.searchParams.set("code_challenge_method", "S256");
|
||||||
|
if (params.username) url.searchParams.set("login_hint", params.username);
|
||||||
|
return { location: url.toString(), state };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SignInError extends Error {
|
||||||
|
constructor(readonly code: "state_mismatch" | "expired" | "denied" | "exchange_failed", message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }> {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
pending.delete(params.state);
|
||||||
|
if (Date.now() - p.createdAt > PENDING_TTL_MS) throw new SignInError("expired", "The sign-in took too long. Try again.");
|
||||||
|
const metadata = await metadataFor(p.base);
|
||||||
|
const tokens = await tokenRequest(metadata.tokenEndpoint, {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: params.code,
|
||||||
|
code_verifier: p.verifier,
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renew an access token. Null when the server refuses the refresh token --
|
||||||
|
* revoked by a password change, expired, or the client's secret changed --
|
||||||
|
* which ends the session. Throws when the server couldn't be asked.
|
||||||
|
*/
|
||||||
|
export async function refreshTokens(base: string, tokens: TokenSet): Promise<TokenSet | null> {
|
||||||
|
if (!tokens.refresh) return null;
|
||||||
|
const metadata = await metadataFor(base);
|
||||||
|
const renewed = await tokenRequest(metadata.tokenEndpoint, { grant_type: "refresh_token", refresh_token: tokens.refresh });
|
||||||
|
// The server hands out a new refresh token only when the old one is close
|
||||||
|
// to expiring; otherwise the old one stays good.
|
||||||
|
return renewed && { ...renewed, refresh: renewed.refresh ?? tokens.refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tokenRequest(endpoint: string, fields: Record<string, string>): Promise<TokenSet | null> {
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
||||||
|
body: new URLSearchParams({ ...fields, client_id: config.oauthClientId, client_secret: config.oauthClientSecret }),
|
||||||
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
|
});
|
||||||
|
if (res.status === 400 || res.status === 401) return null;
|
||||||
|
if (!res.ok) throw new UpstreamError(`Token request failed (${res.status})`, 502);
|
||||||
|
const body = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number; token_type?: string };
|
||||||
|
if (!body.access_token || (body.token_type && body.token_type.toLowerCase() !== "bearer")) {
|
||||||
|
throw new UpstreamError("The mail server returned no usable access token", 502);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
access: body.access_token,
|
||||||
|
refresh: body.refresh_token ?? null,
|
||||||
|
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsRefresh(tokens: TokenSet, now = Date.now()): boolean {
|
||||||
|
return tokens.expiresAt - now < REFRESH_MARGIN_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For tests. */
|
||||||
|
export function resetOAuthState(): void {
|
||||||
|
pending.clear();
|
||||||
|
metadataCache.clear();
|
||||||
|
}
|
||||||
+14
-11
@@ -1,10 +1,13 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||||
process.env.PUSH_URL = "https://ihasmail.example";
|
process.env.PUSH_URL = "https://ihasmail.example";
|
||||||
const push = await import("./push.js");
|
const push = await import("./push.js");
|
||||||
|
|
||||||
|
/** A fixed credential, as a password session hands push. */
|
||||||
|
const cred = (authorization: string) => ({ get: async () => authorization });
|
||||||
|
|
||||||
// Nothing in this file may reach the network. Background subscribe() calls
|
// Nothing in this file may reach the network. Background subscribe() calls
|
||||||
// outlive the test that started them, so the stub stays in place for the
|
// outlive the test that started them, so the stub stays in place for the
|
||||||
// whole file rather than per test; the per-test stubs below layer on top.
|
// whole file rather than per test; the per-test stubs below layer on top.
|
||||||
@@ -46,7 +49,7 @@ test("a tab opened before verification gets no fan-out, and a subscription is st
|
|||||||
const restore = stubUpstream();
|
const restore = stubUpstream();
|
||||||
try {
|
try {
|
||||||
const out = fakeOut();
|
const out = fakeOut();
|
||||||
const entry = push.attach("[email protected]", "a", "Basic x", out as never);
|
const entry = push.attach("[email protected]", "a", cred("Basic x"), out as never);
|
||||||
assert.equal(entry, null, "not verified yet, so the tab must keep its own relay");
|
assert.equal(entry, null, "not verified yet, so the tab must keep its own relay");
|
||||||
await new Promise((r) => setTimeout(r, 30));
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
const st = push.pushStatus();
|
const st = push.pushStatus();
|
||||||
@@ -59,7 +62,7 @@ test("verification then fan-out: one POST reaches every open tab for the account
|
|||||||
try {
|
try {
|
||||||
// First contact starts the subscription; wait for the stubbed create to land.
|
// First contact starts the subscription; wait for the stubbed create to land.
|
||||||
const first = fakeOut();
|
const first = fakeOut();
|
||||||
push.attach("[email protected]", "a", "Basic y", first as never);
|
push.attach("[email protected]", "a", cred("Basic y"), first as never);
|
||||||
await new Promise((r) => setTimeout(r, 30));
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
// Find the token Stalwart would have been given, the way Stalwart learns it: from the subscribe call.
|
// Find the token Stalwart would have been given, the way Stalwart learns it: from the subscribe call.
|
||||||
// We cannot read it back through the public API, so verify via the status transition instead:
|
// We cannot read it back through the public API, so verify via the status transition instead:
|
||||||
@@ -75,7 +78,7 @@ test("a StateChange is written to attached tabs as an SSE frame, and closed tabs
|
|||||||
const restore = stubUpstream();
|
const restore = stubUpstream();
|
||||||
try {
|
try {
|
||||||
const out1 = fakeOut(), out2 = fakeOut();
|
const out1 = fakeOut(), out2 = fakeOut();
|
||||||
push.attach("[email protected]", "a", "Basic z", out1 as never);
|
push.attach("[email protected]", "a", cred("Basic z"), out1 as never);
|
||||||
await new Promise((r) => setTimeout(r, 30));
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
// Verify by handing the module its own token: pushStatus does not expose it, so read it from the
|
// Verify by handing the module its own token: pushStatus does not expose it, so read it from the
|
||||||
// subscribe request the stub saw. Simplest faithful route: capture the URL Stalwart would POST to.
|
// subscribe request the stub saw. Simplest faithful route: capture the URL Stalwart would POST to.
|
||||||
@@ -88,14 +91,14 @@ test("a StateChange is written to attached tabs as an SSE frame, and closed tabs
|
|||||||
return real(input, init);
|
return real(input, init);
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
// Force a renewal-style subscribe so the URL passes through the capturing fetch.
|
// Force a renewal-style subscribe so the URL passes through the capturing fetch.
|
||||||
push.attach("[email protected]", "a", "Basic w", out1 as never);
|
push.attach("[email protected]", "a", cred("Basic w"), out1 as never);
|
||||||
await new Promise((r) => setTimeout(r, 30));
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
globalThis.fetch = real;
|
globalThis.fetch = real;
|
||||||
assert.ok(token, "the subscribe call carries the push URL with the token");
|
assert.ok(token, "the subscribe call carries the push URL with the token");
|
||||||
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
|
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
|
||||||
const entry = push.attach("[email protected]", "a", "Basic w", out1 as never);
|
const entry = push.attach("[email protected]", "a", cred("Basic w"), out1 as never);
|
||||||
assert.ok(entry, "verified: the tab is served by fan-out");
|
assert.ok(entry, "verified: the tab is served by fan-out");
|
||||||
push.attach("[email protected]", "a", "Basic w", out2 as never);
|
push.attach("[email protected]", "a", cred("Basic w"), out2 as never);
|
||||||
assert.equal(await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s1" } } }), 200);
|
assert.equal(await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s1" } } }), 200);
|
||||||
assert.match(out1.written.at(-1) ?? "", /^event: state\ndata: \{"@type":"StateChange"/);
|
assert.match(out1.written.at(-1) ?? "", /^event: state\ndata: \{"@type":"StateChange"/);
|
||||||
assert.equal(out2.written.length, 1);
|
assert.equal(out2.written.length, 1);
|
||||||
@@ -119,12 +122,12 @@ test("a tab on the relay is moved to fan-out when its account verifies, and its
|
|||||||
if (m) token = m[1];
|
if (m) token = m[1];
|
||||||
return real(input, init);
|
return real(input, init);
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
push.prepare("[email protected]", "a", "Basic m"); // sign-in starts the subscription
|
push.prepare("[email protected]", "a", cred("Basic m")); // sign-in starts the subscription
|
||||||
await new Promise((r) => setTimeout(r, 30));
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
globalThis.fetch = real;
|
globalThis.fetch = real;
|
||||||
assert.ok(token);
|
assert.ok(token);
|
||||||
const out = fakeOut(); let dropped = 0;
|
const out = fakeOut(); let dropped = 0;
|
||||||
assert.equal(push.attach("[email protected]", "a", "Basic m", out as never), null, "not yet verified: relay");
|
assert.equal(push.attach("[email protected]", "a", cred("Basic m"), out as never), null, "not yet verified: relay");
|
||||||
push.attachRelay("[email protected]", out as never, () => { dropped++; });
|
push.attachRelay("[email protected]", out as never, () => { dropped++; });
|
||||||
assert.equal(push.pushStatus().tabs.relay >= 1, true);
|
assert.equal(push.pushStatus().tabs.relay >= 1, true);
|
||||||
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
|
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
|
||||||
@@ -168,7 +171,7 @@ test("a new subscription clears what this installation left behind, and only tha
|
|||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
try {
|
try {
|
||||||
// The installation's prefix, learned the way the server makes it: from its first create.
|
// The installation's prefix, learned the way the server makes it: from its first create.
|
||||||
push.prepare("[email protected]", "a", "Basic p");
|
push.prepare("[email protected]", "a", cred("Basic p"));
|
||||||
await new Promise((r) => setTimeout(r, 30));
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
const firstCreate = calls.find(([n, a]) => n === "PushSubscription/set" && a.create);
|
const firstCreate = calls.find(([n, a]) => n === "PushSubscription/set" && a.create);
|
||||||
const deviceId = String(firstCreate?.[1].deviceClientId ?? "");
|
const deviceId = String(firstCreate?.[1].deviceClientId ?? "");
|
||||||
@@ -176,7 +179,7 @@ test("a new subscription clears what this installation left behind, and only tha
|
|||||||
ownPrefix = deviceId.slice(0, deviceId.lastIndexOf("-") + 1);
|
ownPrefix = deviceId.slice(0, deviceId.lastIndexOf("-") + 1);
|
||||||
|
|
||||||
calls.length = 0;
|
calls.length = 0;
|
||||||
push.prepare("[email protected]", "a", "Basic r");
|
push.prepare("[email protected]", "a", cred("Basic r"));
|
||||||
await new Promise((r) => setTimeout(r, 30));
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
const destroyed = calls.filter(([n, a]) => n === "PushSubscription/set" && a.destroy).flatMap(([, a]) => a.destroy as string[]);
|
const destroyed = calls.filter(([n, a]) => n === "PushSubscription/set" && a.destroy).flatMap(([, a]) => a.destroy as string[]);
|
||||||
assert.deepEqual(destroyed, ["mine-before"], "only this installation's leftover goes");
|
assert.deepEqual(destroyed, ["mine-before"], "only this installation's leftover goes");
|
||||||
|
|||||||
+18
-8
@@ -39,7 +39,7 @@ interface AccountPush {
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
base: string;
|
base: string;
|
||||||
token: string; // what Stalwart puts in the URL
|
token: string; // what Stalwart puts in the URL
|
||||||
authorization: string; // one live session's credential, for set/verify/renew
|
credential: PushCredential; // one live session's credential, for set/verify/renew
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
state: "pending" | "verified" | "failed";
|
state: "pending" | "verified" | "failed";
|
||||||
since: number;
|
since: number;
|
||||||
@@ -49,6 +49,15 @@ interface AccountPush {
|
|||||||
relays: Map<ServerResponse, () => void>;
|
relays: Map<ServerResponse, () => void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How push authenticates its own calls. A password session's is fixed; an
|
||||||
|
* OAuth session's renews its access token itself, since a subscription lives
|
||||||
|
* for days and an access token for an hour. See pushCredential() in app.ts.
|
||||||
|
*/
|
||||||
|
export interface PushCredential {
|
||||||
|
get(): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
const byKey = new Map<string, AccountPush>();
|
const byKey = new Map<string, AccountPush>();
|
||||||
const byToken = new Map<string, AccountPush>();
|
const byToken = new Map<string, AccountPush>();
|
||||||
let sweeper: NodeJS.Timeout | null = null;
|
let sweeper: NodeJS.Timeout | null = null;
|
||||||
@@ -60,10 +69,11 @@ export function pushEnabled(): boolean {
|
|||||||
function keyFor(base: string, username: string) { return `${base} ${username}`; }
|
function keyFor(base: string, username: string) { return `${base} ${username}`; }
|
||||||
|
|
||||||
async function jmap(entry: AccountPush, calls: unknown[]) {
|
async function jmap(entry: AccountPush, calls: unknown[]) {
|
||||||
const upstream = await getUpstreamSession(entry.key, entry.authorization, entry.base);
|
const authorization = await entry.credential.get();
|
||||||
|
const upstream = await getUpstreamSession(entry.key, authorization, entry.base);
|
||||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { authorization: entry.authorization, "content-type": "application/json", accept: "application/json" },
|
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
||||||
body: JSON.stringify({ using: USING, methodCalls: calls }),
|
body: JSON.stringify({ using: USING, methodCalls: calls }),
|
||||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
});
|
});
|
||||||
@@ -159,14 +169,14 @@ async function unsubscribe(entry: AccountPush) {
|
|||||||
* by the time the browser opens its stream the verification is usually
|
* by the time the browser opens its stream the verification is usually
|
||||||
* already in flight, and called again by attach() as a safety net.
|
* already in flight, and called again by attach() as a safety net.
|
||||||
*/
|
*/
|
||||||
export function prepare(username: string, accountId: string, authorization: string): AccountPush | null {
|
export function prepare(username: string, accountId: string, credential: PushCredential): AccountPush | null {
|
||||||
if (!pushEnabled()) return null;
|
if (!pushEnabled()) return null;
|
||||||
const base = upstreamFor(username);
|
const base = upstreamFor(username);
|
||||||
const key = keyFor(base, username);
|
const key = keyFor(base, username);
|
||||||
let entry = byKey.get(key);
|
let entry = byKey.get(key);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
entry = { key, username, accountId, base, token: randomBytes(32).toString("base64url"),
|
entry = { key, username, accountId, base, token: randomBytes(32).toString("base64url"),
|
||||||
authorization, subscriptionId: null, state: "pending", since: Date.now(), expires: 0, tabs: new Set(), relays: new Map() };
|
credential, subscriptionId: null, state: "pending", since: Date.now(), expires: 0, tabs: new Set(), relays: new Map() };
|
||||||
byKey.set(key, entry); byToken.set(entry.token, entry);
|
byKey.set(key, entry); byToken.set(entry.token, entry);
|
||||||
subscribe(entry).catch((err) => {
|
subscribe(entry).catch((err) => {
|
||||||
entry!.state = "failed";
|
entry!.state = "failed";
|
||||||
@@ -174,7 +184,7 @@ export function prepare(username: string, accountId: string, authorization: stri
|
|||||||
});
|
});
|
||||||
startSweeper();
|
startSweeper();
|
||||||
} else {
|
} else {
|
||||||
entry.authorization = authorization; // keep a live credential for renewals
|
entry.credential = credential; // keep a live credential for renewals
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
@@ -183,8 +193,8 @@ export function prepare(username: string, accountId: string, authorization: stri
|
|||||||
* Called when a tab opens. Returns the account's push entry if the tab can
|
* Called when a tab opens. Returns the account's push entry if the tab can
|
||||||
* be served by fan-out right now, or null if it must hold its own relay.
|
* be served by fan-out right now, or null if it must hold its own relay.
|
||||||
*/
|
*/
|
||||||
export function attach(username: string, accountId: string, authorization: string, out: ServerResponse): AccountPush | null {
|
export function attach(username: string, accountId: string, credential: PushCredential, out: ServerResponse): AccountPush | null {
|
||||||
const entry = prepare(username, accountId, authorization);
|
const entry = prepare(username, accountId, credential);
|
||||||
if (!entry || entry.state !== "verified") return null;
|
if (!entry || entry.state !== "verified") return null;
|
||||||
entry.tabs.add(out);
|
entry.tabs.add(out);
|
||||||
out.on("close", () => { entry.tabs.delete(out); });
|
out.on("close", () => { entry.tabs.delete(out); });
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const PORT = 18813;
|
|||||||
process.env.MOCK_PORT = String(PORT);
|
process.env.MOCK_PORT = String(PORT);
|
||||||
process.env.MOCK_USER = "[email protected]";
|
process.env.MOCK_USER = "[email protected]";
|
||||||
process.env.MOCK_PASS = "demo-password";
|
process.env.MOCK_PASS = "demo-password";
|
||||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||||
process.env.APP_SECRET = "test-secret-for-request-limits";
|
process.env.APP_SECRET = "test-secret-for-request-limits";
|
||||||
|
|
||||||
const mock = await import("./mock/index.js");
|
const mock = await import("./mock/index.js");
|
||||||
|
|||||||
+39
-11
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { deriveKey, open, randomToken, safeEqual, seal, sha256 } from "./crypto.js";
|
import { deriveKey, open, randomToken, safeEqual, seal, sha256 } from "./crypto.js";
|
||||||
|
import type { TokenSet } from "./oauth.js";
|
||||||
|
|
||||||
export interface StoredSession {
|
export interface StoredSession {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,7 +11,7 @@ export interface StoredSession {
|
|||||||
secretHash: string;
|
secretHash: string;
|
||||||
/** base64 random salt for key derivation */
|
/** base64 random salt for key derivation */
|
||||||
salt: string;
|
salt: string;
|
||||||
/** sealed JSON {username, password} */
|
/** sealed JSON: `{u, p}` for a password, `{u, t}` for OAuth tokens (see oauth.ts) */
|
||||||
sealedCredentials: string;
|
sealedCredentials: string;
|
||||||
username: string;
|
username: string;
|
||||||
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
|
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
|
||||||
@@ -28,8 +29,10 @@ export interface LiveSession {
|
|||||||
username: string;
|
username: string;
|
||||||
/** See `accountKey`. */
|
/** See `accountKey`. */
|
||||||
account: string;
|
account: string;
|
||||||
/** Basic Authorization header value for upstream calls. */
|
/** Authorization header value for upstream calls: Basic, or Bearer for OAuth. */
|
||||||
authorization: string;
|
authorization: string;
|
||||||
|
/** The OAuth tokens behind `authorization`, or null for a password session. */
|
||||||
|
tokens: TokenSet | null;
|
||||||
remember: boolean;
|
remember: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
lastSeenAt: number;
|
lastSeenAt: number;
|
||||||
@@ -71,7 +74,9 @@ export interface CreateSessionParams {
|
|||||||
username: string;
|
username: string;
|
||||||
/** From `accountKey`; defaults to the lower-cased username. */
|
/** From `accountKey`; defaults to the lower-cased username. */
|
||||||
account?: string;
|
account?: string;
|
||||||
password: string;
|
/** Exactly one of `password` and `tokens`. */
|
||||||
|
password?: string;
|
||||||
|
tokens?: TokenSet;
|
||||||
remember: boolean;
|
remember: boolean;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -107,6 +112,8 @@ export interface SessionBackend {
|
|||||||
create(params: CreateSessionParams): { cookie: string; session: LiveSession };
|
create(params: CreateSessionParams): { cookie: string; session: LiveSession };
|
||||||
resolve(cookie: string | undefined): LiveSession | null;
|
resolve(cookie: string | undefined): LiveSession | null;
|
||||||
reseal(cookie: string | undefined, password: string): boolean;
|
reseal(cookie: string | undefined, password: string): boolean;
|
||||||
|
/** Store renewed OAuth tokens in place of the ones the session holds. */
|
||||||
|
updateTokens(cookie: string | undefined, tokens: TokenSet): boolean;
|
||||||
destroy(id: string): void;
|
destroy(id: string): void;
|
||||||
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
|
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
|
||||||
destroyAllForUser(account: string, exceptId?: string): number;
|
destroyAllForUser(account: string, exceptId?: string): number;
|
||||||
@@ -115,6 +122,15 @@ export interface SessionBackend {
|
|||||||
|
|
||||||
const COOKIE_SEP = ".";
|
const COOKIE_SEP = ".";
|
||||||
|
|
||||||
|
/** What a session seals: a password, or OAuth tokens. */
|
||||||
|
type Sealed = { u: string; p: string } | { u: string; t: TokenSet };
|
||||||
|
|
||||||
|
function sealable(username: string, params: { password?: string; tokens?: TokenSet }): Sealed {
|
||||||
|
if (params.tokens) return { u: username, t: params.tokens };
|
||||||
|
if (params.password !== undefined) return { u: username, p: params.password };
|
||||||
|
throw new Error("a session needs a password or tokens");
|
||||||
|
}
|
||||||
|
|
||||||
export class SessionStore implements SessionBackend {
|
export class SessionStore implements SessionBackend {
|
||||||
private sessions = new Map<string, StoredSession>();
|
private sessions = new Map<string, StoredSession>();
|
||||||
private dirty = false;
|
private dirty = false;
|
||||||
@@ -194,7 +210,7 @@ export class SessionStore implements SessionBackend {
|
|||||||
id,
|
id,
|
||||||
secretHash: sha256(secret),
|
secretHash: sha256(secret),
|
||||||
salt: salt.toString("base64"),
|
salt: salt.toString("base64"),
|
||||||
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
sealedCredentials: seal(JSON.stringify(sealable(params.username, params)), key),
|
||||||
username: params.username,
|
username: params.username,
|
||||||
account: params.account ?? params.username.trim().toLowerCase(),
|
account: params.account ?? params.username.trim().toLowerCase(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -207,7 +223,7 @@ export class SessionStore implements SessionBackend {
|
|||||||
this.sessions.set(id, stored);
|
this.sessions.set(id, stored);
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
const cookie = `${id}${COOKIE_SEP}${secret}`;
|
const cookie = `${id}${COOKIE_SEP}${secret}`;
|
||||||
return { cookie, session: this.toLive(stored, params.username, params.password) };
|
return { cookie, session: this.toLive(stored, sealable(params.username, params)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a cookie to a live session (with decrypted upstream credentials). */
|
/** Resolve a cookie to a live session (with decrypted upstream credentials). */
|
||||||
@@ -229,9 +245,9 @@ export class SessionStore implements SessionBackend {
|
|||||||
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
||||||
const json = open(stored.sealedCredentials, key);
|
const json = open(stored.sealedCredentials, key);
|
||||||
if (!json) return null;
|
if (!json) return null;
|
||||||
let creds: { u: string; p: string };
|
let creds: Sealed;
|
||||||
try {
|
try {
|
||||||
creds = JSON.parse(json) as { u: string; p: string };
|
creds = JSON.parse(json) as Sealed;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -242,7 +258,7 @@ export class SessionStore implements SessionBackend {
|
|||||||
stored.expiresAt = now + ttl;
|
stored.expiresAt = now + ttl;
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
return this.toLive(stored, creds.u, creds.p);
|
return this.toLive(stored, creds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -255,6 +271,14 @@ export class SessionStore implements SessionBackend {
|
|||||||
* secret half of it, which the server never keeps.
|
* secret half of it, which the server never keeps.
|
||||||
*/
|
*/
|
||||||
reseal(cookie: string | undefined, password: string): boolean {
|
reseal(cookie: string | undefined, password: string): boolean {
|
||||||
|
return this.rewrite(cookie, (username) => ({ u: username, p: password }));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTokens(cookie: string | undefined, tokens: TokenSet): boolean {
|
||||||
|
return this.rewrite(cookie, (username) => ({ u: username, t: tokens }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private rewrite(cookie: string | undefined, next: (username: string) => Sealed): boolean {
|
||||||
if (!cookie) return false;
|
if (!cookie) return false;
|
||||||
const idx = cookie.indexOf(COOKIE_SEP);
|
const idx = cookie.indexOf(COOKIE_SEP);
|
||||||
if (idx <= 0) return false;
|
if (idx <= 0) return false;
|
||||||
@@ -264,7 +288,7 @@ export class SessionStore implements SessionBackend {
|
|||||||
if (!stored) return false;
|
if (!stored) return false;
|
||||||
if (!safeEqual(stored.secretHash, sha256(secret))) return false;
|
if (!safeEqual(stored.secretHash, sha256(secret))) return false;
|
||||||
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
||||||
stored.sealedCredentials = seal(JSON.stringify({ u: stored.username, p: password }), key);
|
stored.sealedCredentials = seal(JSON.stringify(next(stored.username)), key);
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -295,12 +319,16 @@ export class SessionStore implements SessionBackend {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toLive(s: StoredSession, username: string, password: string): LiveSession {
|
private toLive(s: StoredSession, creds: Sealed): LiveSession {
|
||||||
|
const username = creds.u;
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
username,
|
username,
|
||||||
account: accountOf(s),
|
account: accountOf(s),
|
||||||
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
authorization: "t" in creds
|
||||||
|
? `Bearer ${creds.t.access}`
|
||||||
|
: `Basic ${Buffer.from(`${username}:${creds.p}`, "utf8").toString("base64")}`,
|
||||||
|
tokens: "t" in creds ? creds.t : null,
|
||||||
remember: s.remember,
|
remember: s.remember,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
lastSeenAt: s.lastSeenAt,
|
lastSeenAt: s.lastSeenAt,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ test("the example's commentary cannot be mistaken for a section", () => {
|
|||||||
* reason: an example that no longer loads is worse than no example, because
|
* reason: an example that no longer loads is worse than no example, because
|
||||||
* the first experience of the feature is a server that refuses to start.
|
* the first experience of the feature is a server that refuses to start.
|
||||||
*/
|
*/
|
||||||
const SERVERS = fileURLToPath(new URL("../../stalwart-servers.example.json", import.meta.url));
|
const SERVERS = fileURLToPath(new URL("../../mail-servers.example.json", import.meta.url));
|
||||||
|
|
||||||
test("the example server mapping is valid JSON", () => {
|
test("the example server mapping is valid JSON", () => {
|
||||||
assert.doesNotThrow(() => JSON.parse(readFileSync(SERVERS, "utf8")));
|
assert.doesNotThrow(() => JSON.parse(readFileSync(SERVERS, "utf8")));
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ writeFileSync(join(root, "sw.js"), "/* worker */\n");
|
|||||||
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
|
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
|
||||||
|
|
||||||
process.env.STATIC_DIR = root;
|
process.env.STATIC_DIR = root;
|
||||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||||
const { createApp } = await import("./app.js");
|
const { createApp } = await import("./app.js");
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
|
|||||||
writeFileSync(join(root, "img.png"), "not really a png");
|
writeFileSync(join(root, "img.png"), "not really a png");
|
||||||
|
|
||||||
process.env.STATIC_DIR = root;
|
process.env.STATIC_DIR = root;
|
||||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
process.env.MAIL_SERVER_URL = "http://127.0.0.1:1";
|
||||||
const { createApp } = await import("./app.js");
|
const { createApp } = await import("./app.js");
|
||||||
|
|
||||||
const cacheControl = async (path: string) => {
|
const cacheControl = async (path: string) => {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const SESSION_CACHE_MS = 5 * 60_000;
|
|||||||
/**
|
/**
|
||||||
* The Stalwart a username belongs to.
|
* The Stalwart a username belongs to.
|
||||||
*
|
*
|
||||||
* `STALWART_URL` is the default and is always the answer for a domain nobody
|
* `MAIL_SERVER_URL` is the default and is always the answer for a domain nobody
|
||||||
* mapped -- and for a bare username, which Stalwart accepts and which has no
|
* mapped -- and for a bare username, which Stalwart accepts and which has no
|
||||||
* domain to map (#238).
|
* domain to map (#238).
|
||||||
*
|
*
|
||||||
@@ -59,7 +59,7 @@ export function upstreamFor(username: string): string {
|
|||||||
* Where the administrator signed in as `username` opens Stalwart's own
|
* Where the administrator signed in as `username` opens Stalwart's own
|
||||||
* administration.
|
* administration.
|
||||||
*
|
*
|
||||||
* What the operator configured wins -- STALWART_ADMIN_URL for the default
|
* What the operator configured wins -- ADMIN_URL for the default
|
||||||
* server, a servers file entry's `adminUrl` for a routed domain -- and what was
|
* server, a servers file entry's `adminUrl` for a routed domain -- and what was
|
||||||
* found on the account's own server (`detected`) is used otherwise. Routing is
|
* found on the account's own server (`detected`) is used otherwise. Routing is
|
||||||
* the same as `upstreamFor`: a routed domain is never pointed at the default
|
* the same as `upstreamFor`: a routed domain is never pointed at the default
|
||||||
@@ -93,7 +93,7 @@ export function adminPrefixFrom(responses: [string, Record<string, unknown>, str
|
|||||||
/**
|
/**
|
||||||
* The public origin a Stalwart session belongs to: the host it advertises in
|
* The public origin a Stalwart session belongs to: the host it advertises in
|
||||||
* its own URLs, which is the address people reach it at even when this server
|
* its own URLs, which is the address people reach it at even when this server
|
||||||
* talks to it on a private one (STALWART_URL=http://127.0.0.1:…). A relative
|
* talks to it on a private one (MAIL_SERVER_URL=http://127.0.0.1:…). A relative
|
||||||
* URL falls back to the configured base.
|
* URL falls back to the configured base.
|
||||||
*/
|
*/
|
||||||
export function advertisedOrigin(session: Pick<UpstreamSession, "apiUrl" | "baseUrl">): string | null {
|
export function advertisedOrigin(session: Pick<UpstreamSession, "apiUrl" | "baseUrl">): string | null {
|
||||||
@@ -427,7 +427,7 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
|
/** Resolve a possibly-relative upstream URL template against MAIL_SERVER_URL. */
|
||||||
/**
|
/**
|
||||||
* Resolve a URL Stalwart handed us against the server we were configured to
|
* Resolve a URL Stalwart handed us against the server we were configured to
|
||||||
* talk to.
|
* talk to.
|
||||||
@@ -435,7 +435,7 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
|
|||||||
* Stalwart advertises absolute URLs in its session -- apiUrl, eventSourceUrl
|
* Stalwart advertises absolute URLs in its session -- apiUrl, eventSourceUrl
|
||||||
* and the rest -- built from its public hostname, which is always https. A
|
* and the rest -- built from its public hostname, which is always https. A
|
||||||
* proxy that follows them takes every upstream call, and every held push
|
* proxy that follows them takes every upstream call, and every held push
|
||||||
* stream, out through the public route even when STALWART_URL names a private
|
* stream, out through the public route even when MAIL_SERVER_URL names a private
|
||||||
* plain-HTTP hop on the same network. Measured, that TLS leg is ~80 KiB of
|
* plain-HTTP hop on the same network. Measured, that TLS leg is ~80 KiB of
|
||||||
* native OpenSSL state per signed-in tab: 60% of what a tab costs, and the
|
* native OpenSSL state per signed-in tab: 60% of what a tab costs, and the
|
||||||
* whole difference between 1,665 and 3,680 tabs in 256 MiB.
|
* whole difference between 1,665 and 3,680 tabs in 256 MiB.
|
||||||
@@ -443,7 +443,7 @@ export function localizeSession(s: UpstreamSession, extras: Record<string, unkno
|
|||||||
* So by default only the path and query are taken from the advertised URL;
|
* So by default only the path and query are taken from the advertised URL;
|
||||||
* scheme, host and port come from the configured base. That is what a proxy
|
* scheme, host and port come from the configured base. That is what a proxy
|
||||||
* should have done all along -- the operator named the route on purpose.
|
* should have done all along -- the operator named the route on purpose.
|
||||||
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behavior for a setup
|
* MAIL_SERVER_FOLLOW_ADVERTISED_URLS=1 restores the old behavior for a setup
|
||||||
* that genuinely needs to reach Stalwart at a different origin than the one
|
* that genuinely needs to reach Stalwart at a different origin than the one
|
||||||
* it was given.
|
* it was given.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" />
|
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" />
|
||||||
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>ihasmail</title>
|
<title>INBUXA</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ihasmail",
|
"name": "INBUXA",
|
||||||
"short_name": "ihasmail",
|
"short_name": "INBUXA",
|
||||||
"description": "Fast, friendly JMAP webmail for Stalwart",
|
"description": "INBUXA webmail",
|
||||||
"_comment": "JSON has no comments, so: every URL below is relative on purpose. Manifest members resolve against the manifest's own address, so these follow BASE_PATH with nothing substituted into them at build time. Root-absolute values pinned the installed app, its scope and its shortcuts to the domain root whatever the mount was.",
|
"_comment": "JSON has no comments, so: every URL below is relative on purpose. Manifest members resolve against the manifest's own address, so these follow BASE_PATH with nothing substituted into them at build time. Root-absolute values pinned the installed app, its scope and its shortcuts to the domain root whatever the mount was.",
|
||||||
"_comment_id": "There is deliberately no `id`. It is the one member NOT resolved against this file's address -- the spec resolves it against the origin of start_url, so `./`, `mail` and `/mail` all mean the same thing at the domain root and none of them can name a subpath mount. Adding one would therefore break the same thing the note above describes. Worse, the default id IS start_url, which is already mount-correct: writing an id now would give every installed copy a new identity and orphan it as a second app rather than updating it. If one is ever wanted it has to be substituted at build time from BASE_PATH, and the changeover costs everybody their install.",
|
"_comment_id": "There is deliberately no `id`. It is the one member NOT resolved against this file's address -- the spec resolves it against the origin of start_url, so `./`, `mail` and `/mail` all mean the same thing at the domain root and none of them can name a subpath mount. Adding one would therefore break the same thing the note above describes. Worse, the default id IS start_url, which is already mount-correct: writing an id now would give every installed copy a new identity and orphan it as a second app rather than updating it. If one is ever wanted it has to be substituted at build time from BASE_PATH, and the changeover costs everybody their install.",
|
||||||
"start_url": "mail",
|
"start_url": "mail",
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { INBUXA_CAP, legacyProtocolsOff } from "../client";
|
||||||
|
import type { JmapSession } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server says, per account, whether legacy mail protocols are off for it
|
||||||
|
* (legacy-protocols LP-19, contract C-1). Anything short of a plain
|
||||||
|
* "disabled" -- an older server, another server, no session yet -- reads as
|
||||||
|
* on, so the notice never appears where it isn't true.
|
||||||
|
*/
|
||||||
|
describe("legacyProtocolsOff", () => {
|
||||||
|
const session = (cap: Record<string, unknown> | undefined) =>
|
||||||
|
({
|
||||||
|
accounts: { a: { name: "[email protected]", accountCapabilities: cap ? { [INBUXA_CAP]: cap } : {} } },
|
||||||
|
}) as unknown as JmapSession;
|
||||||
|
|
||||||
|
it("is on only when the account's capability says disabled", () => {
|
||||||
|
expect(legacyProtocolsOff(session({ legacyProtocols: "disabled" }), "a")).toBe(true);
|
||||||
|
expect(legacyProtocolsOff(session({ legacyProtocols: "enabled" }), "a")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads an older or other server, or no session, as on", () => {
|
||||||
|
expect(legacyProtocolsOff(session({ logo: null }), "a")).toBe(false);
|
||||||
|
expect(legacyProtocolsOff(session(undefined), "a")).toBe(false);
|
||||||
|
expect(legacyProtocolsOff(session({ legacyProtocols: "disabled" }), "b")).toBe(false);
|
||||||
|
expect(legacyProtocolsOff(null, "a")).toBe(false);
|
||||||
|
expect(legacyProtocolsOff(session({ legacyProtocols: "disabled" }), null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,21 @@ export const CAP = {
|
|||||||
/** Stalwart's own capability, which carries its `x:` registry methods. */
|
/** Stalwart's own capability, which carries its `x:` registry methods. */
|
||||||
export const STALWART_CAP = "urn:stalwart:jmap";
|
export const STALWART_CAP = "urn:stalwart:jmap";
|
||||||
|
|
||||||
|
/** INBUXA's own capability (contract C-1), on the signed-in account. */
|
||||||
|
export const INBUXA_CAP = "urn:inbuxa:jmap";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether legacy mail protocols -- IMAP, POP3, ManageSieve, sending from mail
|
||||||
|
* apps -- are off for this account: the stricter of the server's switch and
|
||||||
|
* its organization's, as the server reports it (legacy-protocols LP-19). Any
|
||||||
|
* server that doesn't say reads as on.
|
||||||
|
*/
|
||||||
|
export function legacyProtocolsOff(session: JmapSession | null, accountId: Id | null): boolean {
|
||||||
|
if (!session || !accountId) return false;
|
||||||
|
const cap = session.accounts[accountId]?.accountCapabilities?.[INBUXA_CAP] as { legacyProtocols?: string } | undefined;
|
||||||
|
return cap?.legacyProtocols === "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
export class JmapMethodError extends Error {
|
export class JmapMethodError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly method: string,
|
public readonly method: string,
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface JmapSession {
|
|||||||
maxUploadBytes: number;
|
maxUploadBytes: number;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
loginName: string;
|
loginName: string;
|
||||||
|
/** "oauth" when signed in on the mail server's page (ihasmail-inbuxa); absent from older servers. */
|
||||||
|
signIn?: "oauth" | "password";
|
||||||
remember: boolean;
|
remember: boolean;
|
||||||
/** Locale configured for the account in Stalwart, if the server exposes it. */
|
/** Locale configured for the account in Stalwart, if the server exposes it. */
|
||||||
userLocale?: string | null;
|
userLocale?: string | null;
|
||||||
@@ -38,7 +40,7 @@ export interface JmapSession {
|
|||||||
server?: {
|
server?: {
|
||||||
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
|
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
|
||||||
edition?: string | null;
|
edition?: string | null;
|
||||||
/** Where Stalwart's own administration is (STALWART_ADMIN_URL), for a session that may administer. */
|
/** Where Stalwart's own administration is (ADMIN_URL), for a session that may administer. */
|
||||||
adminUrl?: string | null;
|
adminUrl?: string | null;
|
||||||
/** SHOW_ENTERPRISE_NOTICES: an Enterprise-only section says so even on Enterprise. */
|
/** SHOW_ENTERPRISE_NOTICES: an Enterprise-only section says so even on Enterprise. */
|
||||||
enterpriseNotices?: boolean;
|
enterpriseNotices?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* An instance renamed with APP_NAME should be called by its name everywhere,
|
||||||
|
* not only on the sign-in page and in the title bar. So no sentence shown to
|
||||||
|
* a person may write "ihasmail" into itself: it takes the name as {app}.
|
||||||
|
*
|
||||||
|
* The exceptions are the places where "ihasmail" is not the app's name but a
|
||||||
|
* literal a person could go and look at: the Files folder, the Sieve script
|
||||||
|
* and the project's own address. Renaming those would rename real data.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
|
||||||
|
/** Strings that name a stored thing, not the app. */
|
||||||
|
const LITERALS = [
|
||||||
|
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.",
|
||||||
|
"“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.",
|
||||||
|
"Another script (“{name}”) is active. Saving rules here will activate the “ihasmail” script instead.",
|
||||||
|
"ihasmail.org",
|
||||||
|
"ihasmail",
|
||||||
|
];
|
||||||
|
|
||||||
|
function sources(dir: string, out: string[] = []): string[] {
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
const path = join(dir, name);
|
||||||
|
if (statSync(path).isDirectory()) {
|
||||||
|
if (name === "locales" || name === "__tests__") continue;
|
||||||
|
sources(path, out);
|
||||||
|
} else if (/\.tsx?$/.test(name)) {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every translated string in a file, however `t` was imported. */
|
||||||
|
function translatedStrings(code: string): string[] {
|
||||||
|
return [...code.matchAll(/\b(?:t|tNode|translate)\(\s*"((?:[^"\\]|\\.)*)"/g)].map((m) =>
|
||||||
|
JSON.parse(`"${m[1]}"`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("text that names the app", () => {
|
||||||
|
it("takes the name as {app} instead of writing ihasmail into the sentence", () => {
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const file of sources(SRC)) {
|
||||||
|
for (const s of translatedStrings(readFileSync(file, "utf8"))) {
|
||||||
|
if (s.includes("ihasmail") && !LITERALS.includes(s)) {
|
||||||
|
offenders.push(`${file.slice(SRC.length)}: ${s.slice(0, 60)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a placeholder in every translation of those strings", () => {
|
||||||
|
const catalogs = readdirSync(join(SRC, "locales")).filter((f) => f.endsWith(".ts") && f !== "index.ts");
|
||||||
|
const wrong: string[] = [];
|
||||||
|
for (const name of catalogs) {
|
||||||
|
const code = readFileSync(join(SRC, "locales", name), "utf8");
|
||||||
|
for (const m of code.matchAll(/^\s*"((?:[^"\\]|\\.)*)": "((?:[^"\\]|\\.)*)",$/gm)) {
|
||||||
|
const key = JSON.parse(`"${m[1]}"`);
|
||||||
|
const value = JSON.parse(`"${m[2]}"`);
|
||||||
|
// A key that takes the name must not hard-code it in the translation.
|
||||||
|
if (key.includes("{app}") && value.includes("ihasmail")) wrong.push(`${name}: ${key.slice(0, 50)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(wrong).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { CONFIRM_PHRASE, impactEntries, parseTenantLegacy, phraseMatches } from "../adminLegacyProtocols";
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts each account on the panel once, with every protocol and its latest use", () => {
|
||||||
|
const { recent } = parseTenantLegacy({
|
||||||
|
recentLegacyUse: [
|
||||||
|
{ accountId: "a", name: "[email protected]", protocol: "submission", lastUsedAt: 100 },
|
||||||
|
{ accountId: "a", name: "[email protected]", protocol: "imap", lastUsedAt: 300 },
|
||||||
|
{ accountId: "b", name: "[email protected]", protocol: "pop3", lastUsedAt: 200 },
|
||||||
|
{ accountId: "c", name: "broken" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(impactEntries(recent!)).toEqual([
|
||||||
|
{ name: "[email protected]", protocols: ["IMAP", "SMTP"], lastUsedAt: 300 },
|
||||||
|
{ name: "[email protected]", protocols: ["POP3"], lastUsedAt: 200 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes only the exact phrase", () => {
|
||||||
|
expect(phraseMatches(CONFIRM_PHRASE)).toBe(true);
|
||||||
|
expect(phraseMatches(` ${CONFIRM_PHRASE}`)).toBe(false);
|
||||||
|
expect(phraseMatches(CONFIRM_PHRASE.toUpperCase())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { client, INBUXA_CAP } from "@/jmap/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One tenant's legacy mail protocols switch: INBUXA's
|
||||||
|
* `inbuxa:TenantProtocolPolicy` (legacy-protocols LP-9 to LP-18).
|
||||||
|
*
|
||||||
|
* Turning it off refuses sign-in over IMAP, POP3, ManageSieve and SMTP
|
||||||
|
* submission on the tenant's domains, so only this webmail and other JMAP
|
||||||
|
* apps work there. It closes no port -- other tenants share them. Turning it
|
||||||
|
* back on is refused by the server while the server has legacy protocols off
|
||||||
|
* for everyone.
|
||||||
|
*
|
||||||
|
* Only INBUXA serves it. On any other server the method is unknown, which
|
||||||
|
* `fetchTenantLegacy` reports as `null` so the sheet shows nothing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OBJECT = "inbuxa:TenantProtocolPolicy";
|
||||||
|
|
||||||
|
/** The phrase that turns legacy protocols off (LP-17). Turning them back on needs none. */
|
||||||
|
export const CONFIRM_PHRASE = "turn off legacy mail";
|
||||||
|
|
||||||
|
/** Whether the typed confirmation matches: exactly, no trimming, no case folding. */
|
||||||
|
export function phraseMatches(typed: string): boolean {
|
||||||
|
return typed === CONFIRM_PHRASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One account's last sign-in over one legacy protocol, as the server reports it (LP-15). */
|
||||||
|
export interface RecentUse {
|
||||||
|
accountId: string;
|
||||||
|
name: string;
|
||||||
|
protocol: string;
|
||||||
|
/** Milliseconds since the epoch. */
|
||||||
|
lastUsedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantLegacy {
|
||||||
|
off: boolean;
|
||||||
|
/** Null from a server too old to say who uses legacy apps -- not the same as nobody. */
|
||||||
|
recent: RecentUse[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRecent(raw: unknown): RecentUse[] | null {
|
||||||
|
if (!Array.isArray(raw)) return null;
|
||||||
|
return raw.flatMap((entry) => {
|
||||||
|
if (!entry || typeof entry !== "object") return [];
|
||||||
|
const r = entry as Record<string, unknown>;
|
||||||
|
if (typeof r.name !== "string" || typeof r.protocol !== "string" || typeof r.lastUsedAt !== "number") return [];
|
||||||
|
return [{ accountId: typeof r.accountId === "string" ? r.accountId : "", name: r.name, protocol: r.protocol, lastUsedAt: r.lastUsedAt }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTenantLegacy(raw: Record<string, unknown>): TenantLegacy {
|
||||||
|
return { off: raw.legacyProtocols === "disabled", recent: parseRecent(raw.recentLegacyUse) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tenant's switch, or null where the server has none (not INBUXA, or too old). */
|
||||||
|
export async function fetchTenantLegacy(tenantId: string): Promise<TenantLegacy | null> {
|
||||||
|
try {
|
||||||
|
const res = await client.call<{ list?: Record<string, unknown>[] }>(`${OBJECT}/get`, { ids: [tenantId] }, [INBUXA_CAP]);
|
||||||
|
return res.list?.[0] ? parseTenantLegacy(res.list[0]) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns the tenant's switch. A refusal (LP-9) comes back as the server's words. */
|
||||||
|
export async function setTenantLegacy(tenantId: string, off: boolean): Promise<void> {
|
||||||
|
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> | null }>(
|
||||||
|
`${OBJECT}/set`,
|
||||||
|
{ update: { [tenantId]: { legacyProtocols: off ? "disabled" : "enabled" } } },
|
||||||
|
[INBUXA_CAP],
|
||||||
|
);
|
||||||
|
const failed = res.notUpdated?.[tenantId];
|
||||||
|
if (failed) throw new Error(failed.description ?? failed.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROTOCOL_ORDER = ["imap", "pop3", "manageSieve", "submission"];
|
||||||
|
const PROTOCOL_LABELS: Record<string, string> = { imap: "IMAP", pop3: "POP3", manageSieve: "ManageSieve", submission: "SMTP" };
|
||||||
|
|
||||||
|
/** One account on the impact panel: every protocol it used, and when it last used any. */
|
||||||
|
export interface ImpactEntry {
|
||||||
|
name: string;
|
||||||
|
protocols: string[];
|
||||||
|
lastUsedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The impact panel's lines (LP-15): one per account, most recent first. */
|
||||||
|
export function impactEntries(recent: RecentUse[]): ImpactEntry[] {
|
||||||
|
const byAccount = new Map<string, { name: string; protocols: Set<string>; lastUsedAt: number }>();
|
||||||
|
for (const use of recent) {
|
||||||
|
const key = use.accountId || use.name;
|
||||||
|
const entry = byAccount.get(key) ?? { name: use.name, protocols: new Set<string>(), lastUsedAt: 0 };
|
||||||
|
entry.protocols.add(use.protocol);
|
||||||
|
entry.lastUsedAt = Math.max(entry.lastUsedAt, use.lastUsedAt);
|
||||||
|
byAccount.set(key, entry);
|
||||||
|
}
|
||||||
|
return [...byAccount.values()]
|
||||||
|
.map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
protocols: [...e.protocols].sort((a, b) => PROTOCOL_ORDER.indexOf(a) - PROTOCOL_ORDER.indexOf(b)).map((p) => PROTOCOL_LABELS[p] ?? p),
|
||||||
|
lastUsedAt: e.lastUsedAt,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.lastUsedAt - a.lastUsedAt || a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
+26
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import { useSession } from "@/store/session";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What this instance calls itself, when nothing has said otherwise yet.
|
* What this instance calls itself, when nothing has said otherwise yet.
|
||||||
*
|
*
|
||||||
@@ -10,4 +12,27 @@
|
|||||||
* One constant rather than the string written out at each of them, because
|
* One constant rather than the string written out at each of them, because
|
||||||
* three copies of a default is how two of them end up stale.
|
* three copies of a default is how two of them end up stale.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_APP_NAME = "ihasmail";
|
// ihasmail-inbuxa: INBUXA's webmail goes by INBUXA, so it can't be taken for
|
||||||
|
// public ihasmail. APP_NAME still names a deployment whatever it likes.
|
||||||
|
export const DEFAULT_APP_NAME = "INBUXA";
|
||||||
|
/**
|
||||||
|
* What this instance calls itself, right now.
|
||||||
|
*
|
||||||
|
* Text that names the app reads it from here rather than writing "ihasmail"
|
||||||
|
* into the sentence, so an instance renamed with `APP_NAME` is called by its
|
||||||
|
* name everywhere, not only on the sign-in page and in the title bar. The
|
||||||
|
* name goes into the sentence as the `{app}` placeholder, which also lets a
|
||||||
|
* translator put it where their language wants it.
|
||||||
|
*
|
||||||
|
* Two shapes for the same fact: the hook for components, and the plain
|
||||||
|
* function for the few places that build strings outside React (the service
|
||||||
|
* worker's facts, for one). Both fall back to the default until the session
|
||||||
|
* arrives.
|
||||||
|
*/
|
||||||
|
export function useAppName(): string {
|
||||||
|
return useSession((s) => s.session?.ihasmail?.appName)?.trim() || DEFAULT_APP_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentAppName(): string {
|
||||||
|
return useSession.getState().session?.ihasmail?.appName?.trim() || DEFAULT_APP_NAME;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
snap,
|
snap,
|
||||||
movePatch,
|
movePatch,
|
||||||
moveByDaysPatch,
|
moveByDaysPatch,
|
||||||
|
moveAcrossPatch,
|
||||||
|
columnsMoved,
|
||||||
dayDelta,
|
dayDelta,
|
||||||
resizePatch,
|
resizePatch,
|
||||||
SNAP_MINUTES,
|
SNAP_MINUTES,
|
||||||
@@ -180,10 +182,22 @@ describe("the patch a drag sends, computed in the event's own frame", () => {
|
|||||||
expect(resizePatch(3600, -600)).toEqual({ duration: "PT15M" });
|
expect(resizePatch(3600, -600)).toEqual({ duration: "PT15M" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves by days and minutes together, as a week-grid drag does", () => {
|
||||||
|
expect(moveAcrossPatch("2026-09-04T14:00:00", 2, 90)).toEqual({ start: "2026-09-06T15:30:00" });
|
||||||
|
expect(moveAcrossPatch("2026-09-04T14:00:00", -1, 0)).toEqual({ start: "2026-09-03T14:00:00" });
|
||||||
|
expect(moveAcrossPatch("2026-09-04T14:00:00", 0, -30)).toEqual({ start: "2026-09-04T13:30:00" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the days as days, so a clock change does not move the hour", () => {
|
||||||
|
// US clocks go back on 1 November 2026; 14:00 stays 14:00 across it.
|
||||||
|
expect(moveAcrossPatch("2026-10-31T14:00:00", 2, 0)).toEqual({ start: "2026-11-02T14:00:00" });
|
||||||
|
});
|
||||||
|
|
||||||
it("says nothing at all about a start it cannot read", () => {
|
it("says nothing at all about a start it cannot read", () => {
|
||||||
expect(movePatch("not a date", 30)).toEqual({});
|
expect(movePatch("not a date", 30)).toEqual({});
|
||||||
expect(moveByDaysPatch("", 3)).toEqual({});
|
expect(moveByDaysPatch("", 3)).toEqual({});
|
||||||
expect(moveByDaysPatch("2026-09-04T14:00:00", Number.NaN)).toEqual({});
|
expect(moveByDaysPatch("2026-09-04T14:00:00", Number.NaN)).toEqual({});
|
||||||
|
expect(moveAcrossPatch("not a date", 1, 30)).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,3 +242,23 @@ describe("pixelsToMinutes", () => {
|
|||||||
expect(SNAP_MINUTES).toBe(15);
|
expect(SNAP_MINUTES).toBe(15);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("columnsMoved", () => {
|
||||||
|
it("counts whole columns, to the nearest", () => {
|
||||||
|
expect(columnsMoved(100, 100, 2, 7)).toBe(1);
|
||||||
|
expect(columnsMoved(140, 100, 2, 7)).toBe(1);
|
||||||
|
expect(columnsMoved(160, 100, 2, 7)).toBe(2);
|
||||||
|
expect(columnsMoved(-40, 100, 2, 7)).toBe(0);
|
||||||
|
expect(columnsMoved(-160, 100, 3, 7)).toBe(-2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at the edges of the week instead of wrapping", () => {
|
||||||
|
expect(columnsMoved(-900, 100, 2, 7)).toBe(-2);
|
||||||
|
expect(columnsMoved(900, 100, 2, 7)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never moves sideways in a one-day grid or before it is measured", () => {
|
||||||
|
expect(columnsMoved(500, 100, 0, 1)).toBe(0);
|
||||||
|
expect(columnsMoved(500, 0, 0, 7)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -142,6 +142,35 @@ export function moveByDaysPatch(storedStart: string, days: number): DragPatch {
|
|||||||
return { start: formatStored(moved) };
|
return { start: formatStored(moved) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moved by whole days and by minutes at once -- the week grid, where a drag
|
||||||
|
* goes sideways to another day and up or down to another hour in the same
|
||||||
|
* gesture.
|
||||||
|
*
|
||||||
|
* The days go first and as days, for the reason moveByDaysPatch gives: a day
|
||||||
|
* added to a wall clock keeps its time of day across a clock change, where
|
||||||
|
* 1440 minutes would not.
|
||||||
|
*/
|
||||||
|
export function moveAcrossPatch(storedStart: string, days: number, deltaMinutes: number): DragPatch {
|
||||||
|
const byDays = days ? moveByDaysPatch(storedStart, days).start : storedStart;
|
||||||
|
if (!byDays) return {};
|
||||||
|
return snap(deltaMinutes) ? movePatch(byDays, deltaMinutes) : { start: byDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many columns sideways the pointer has gone, kept inside the grid.
|
||||||
|
*
|
||||||
|
* Counted from the column the drag began in, so an event that crosses
|
||||||
|
* midnight moves by the same amount whichever of its two halves was picked
|
||||||
|
* up. Past the first or last column it stops at the edge rather than
|
||||||
|
* wrapping: the week on screen is the only week a drag can reach.
|
||||||
|
*/
|
||||||
|
export function columnsMoved(deltaPixels: number, columnWidth: number, fromIndex: number, columnCount: number): number {
|
||||||
|
if (!columnWidth || columnCount < 2) return 0;
|
||||||
|
const moved = Math.round(deltaPixels / columnWidth) || 0; // never -0
|
||||||
|
return Math.max(-fromIndex, Math.min(columnCount - 1 - fromIndex, moved));
|
||||||
|
}
|
||||||
|
|
||||||
/** Whole days between two local dates, ignoring the time of day on each. */
|
/** Whole days between two local dates, ignoring the time of day on each. */
|
||||||
export function dayDelta(from: Date, to: Date): number {
|
export function dayDelta(from: Date, to: Date): number {
|
||||||
const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime();
|
const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime();
|
||||||
|
|||||||
+1
-1
@@ -120,7 +120,7 @@ export function plural(n: number, forms: PluralForms, vars?: Vars): string {
|
|||||||
*
|
*
|
||||||
* So the sentence stays whole and the elements are placeholders in it:
|
* So the sentence stays whole and the elements are placeholders in it:
|
||||||
*
|
*
|
||||||
* tNode("Open {scheme} links in ihasmail.", { scheme: <code>mailto:</code> })
|
* tNode("Open {scheme} links in {app}.", { scheme: <code>mailto:</code> }, { app: "ihasmail" })
|
||||||
*
|
*
|
||||||
* A translator sees one sentence with a named hole and can put the hole
|
* A translator sees one sentence with a named hole and can put the hole
|
||||||
* wherever their language wants it.
|
* wherever their language wants it.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const UI_LANGUAGES: readonly UiLanguage[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** Where to report a bad translation. Beta languages depend on it. */
|
/** Where to report a bad translation. Beta languages depend on it. */
|
||||||
export const TRANSLATION_ISSUE_URL = "https://github.com/Coffey-Labs/ihasmail/issues/new?title=Translation%3A%20";
|
export const TRANSLATION_ISSUE_URL = "https://git.coffeylabs.org/coffey-labs/ihasmail/issues/new?title=Translation%3A%20";
|
||||||
|
|
||||||
export const DEFAULT_UI_LANGUAGE = "en";
|
export const DEFAULT_UI_LANGUAGE = "en";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { canPlaceFolder, compareFolders, neighbour, placeFolder, siblingsOf } from "../folderOrder";
|
||||||
|
import type { Id, Mailbox } from "@/jmap/types";
|
||||||
|
|
||||||
|
const RIGHTS = { mayRename: true, mayCreateChild: true } as Mailbox["myRights"];
|
||||||
|
|
||||||
|
const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null, sortOrder = 0, over: Partial<Mailbox> = {}): Mailbox =>
|
||||||
|
({ id, name, parentId, role, sortOrder, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: RIGHTS, ...over });
|
||||||
|
|
||||||
|
const tree = (...list: Mailbox[]): Record<Id, Mailbox> => Object.fromEntries(list.map((m) => [m.id, m]));
|
||||||
|
|
||||||
|
/** As Stalwart hands it over before anybody orders anything: every sortOrder 0. */
|
||||||
|
const fresh = tree(
|
||||||
|
mb("zeta", "Zeta", null),
|
||||||
|
mb("trash", "Deleted Items", null, "trash"),
|
||||||
|
mb("sent", "Sent Items", null, "sent"),
|
||||||
|
mb("inbox", "Inbox", null, "inbox"),
|
||||||
|
mb("alpha", "Alpha", null),
|
||||||
|
mb("junk", "Junk Mail", null, "junk"),
|
||||||
|
mb("drafts", "Drafts", null, "drafts"),
|
||||||
|
mb("work", "Work", null),
|
||||||
|
mb("clients", "Clients", "work"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const names = (all: Record<Id, Mailbox>, parentId: Id | null = null) => siblingsOf(all, parentId).map((m) => m.id);
|
||||||
|
|
||||||
|
/** Apply what `placeFolder` asks for, as the server would. */
|
||||||
|
function apply(all: Record<Id, Mailbox>, updates: Record<Id, Partial<Mailbox>> | null): Record<Id, Mailbox> {
|
||||||
|
const next = { ...all };
|
||||||
|
for (const [id, patch] of Object.entries(updates ?? {})) next[id] = { ...next[id]!, ...patch };
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("compareFolders", () => {
|
||||||
|
it("lists Inbox, then the special folders in mail-client order, then the rest A–Z, when nothing is ordered yet", () => {
|
||||||
|
// #402: Sent landed fourth from the bottom among the reporter's 88 folders.
|
||||||
|
expect(names(fresh)).toEqual(["inbox", "drafts", "sent", "junk", "trash", "alpha", "work", "zeta"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a saved order ahead of the special-folder default", () => {
|
||||||
|
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(names(ordered)).toEqual(["inbox", "zeta", "sent", "alpha", "drafts", "junk", "trash", "work"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Inbox first whatever its sortOrder says", () => {
|
||||||
|
const a = mb("inbox", "Inbox", null, "inbox", 99);
|
||||||
|
const b = mb("alpha", "Alpha", null, null, 1);
|
||||||
|
expect(compareFolders(a, b)).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts names numerically, not by character", () => {
|
||||||
|
const all = tree(mb("f10", "Folder 10", null), mb("f9", "Folder 9", null));
|
||||||
|
expect(names(all)).toEqual(["f9", "f10"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("placeFolder", () => {
|
||||||
|
it("numbers the whole level 10 apart, with the folder where it was dropped", () => {
|
||||||
|
const next = apply(fresh, placeFolder(fresh, "zeta", "drafts", "before"));
|
||||||
|
expect(names(next)).toEqual(["inbox", "zeta", "drafts", "sent", "junk", "trash", "alpha", "work"]);
|
||||||
|
expect(siblingsOf(next, null).map((m) => m.sortOrder)).toEqual([10, 20, 30, 40, 50, 60, 70, 80]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes only the folders whose number changes", () => {
|
||||||
|
const once = apply(fresh, placeFolder(fresh, "zeta", "drafts", "before"));
|
||||||
|
// Swapping the last two leaves everything above them where it was.
|
||||||
|
expect(Object.keys(placeFolder(once, "work", "alpha", "before")!).sort()).toEqual(["alpha", "work"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for nothing when the folder is dropped where it already is", () => {
|
||||||
|
expect(placeFolder(fresh, "sent", "drafts", "after")).toBeNull();
|
||||||
|
expect(placeFolder(fresh, "sent", "junk", "before")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a folder to another level, and gives it a place there", () => {
|
||||||
|
const updates = placeFolder(fresh, "alpha", "clients", "before")!;
|
||||||
|
expect(updates.alpha).toEqual({ sortOrder: 10, parentId: "work" });
|
||||||
|
expect(names(apply(fresh, updates), "work")).toEqual(["alpha", "clients"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canPlaceFolder", () => {
|
||||||
|
it("lets a special folder be reordered among its siblings", () => {
|
||||||
|
expect(canPlaceFolder(fresh, "sent", "alpha", "after")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a special folder move to another level", () => {
|
||||||
|
expect(canPlaceFolder(fresh, "sent", "clients", "before")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts nothing above Inbox", () => {
|
||||||
|
expect(canPlaceFolder(fresh, "sent", "inbox", "before")).toBe(false);
|
||||||
|
expect(canPlaceFolder(fresh, "sent", "inbox", "after")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not put a folder inside its own subtree", () => {
|
||||||
|
expect(canPlaceFolder(fresh, "work", "clients", "before")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs the right to rename, which RFC 8621 folds moving into", () => {
|
||||||
|
const locked = apply(fresh, { alpha: { myRights: { ...RIGHTS, mayRename: false } } });
|
||||||
|
expect(canPlaceFolder(locked, "alpha", "zeta", "after")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("neighbour", () => {
|
||||||
|
it("steps past the folder above or below", () => {
|
||||||
|
expect(neighbour(fresh, "alpha", "up")).toEqual({ targetId: "trash", placement: "before" });
|
||||||
|
expect(neighbour(fresh, "alpha", "down")).toEqual({ targetId: "work", placement: "after" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has nowhere to go past either end, or above Inbox", () => {
|
||||||
|
expect(neighbour(fresh, "zeta", "down")).toBeNull();
|
||||||
|
expect(neighbour(fresh, "drafts", "up")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips folders that aren't on screen, so every step visibly moves", () => {
|
||||||
|
const hidden = apply(fresh, { trash: { isSubscribed: false } });
|
||||||
|
expect(neighbour(hidden, "alpha", "up", (m) => m.isSubscribed)).toEqual({ targetId: "junk", placement: "before" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { Id, Mailbox } from "@/jmap/types";
|
||||||
|
import { ROLE_ORDER } from "@/store/mail/mailboxes";
|
||||||
|
import { canDropFolder, descendantIds } from "./folderMove";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The order folders are listed in, at every level of the tree (#402).
|
||||||
|
*
|
||||||
|
* Inbox always comes first. After that the folder's own `sortOrder` decides,
|
||||||
|
* which is where a folder dragged into place keeps its position, and where
|
||||||
|
* any other JMAP client that orders folders keeps its choice too. Stalwart
|
||||||
|
* gives every folder 0 until somebody orders it, so for everyone who never
|
||||||
|
* has, the tie-breaks decide: special folders first, in the usual mail-client
|
||||||
|
* order (Drafts, Sent, Archive, Junk, Trash), then the rest A–Z.
|
||||||
|
*/
|
||||||
|
export function compareFolders(a: Mailbox, b: Mailbox): number {
|
||||||
|
if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1;
|
||||||
|
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
|
||||||
|
const ra = roleRank(a);
|
||||||
|
const rb = roleRank(b);
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleRank(m: Mailbox): number {
|
||||||
|
return m.role && m.role in ROLE_ORDER ? ROLE_ORDER[m.role]! : Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
.filter((m) => (m.parentId && mailboxes[m.parentId] ? m.parentId : null) === parentId)
|
||||||
|
.sort(compareFolders);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Placement = "before" | "after";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `draggedId` may be put just above or below `targetId`.
|
||||||
|
*
|
||||||
|
* Special folders can be reordered but not reparented, so they may only land
|
||||||
|
* among their own siblings. Nothing goes above Inbox, which stays first.
|
||||||
|
*/
|
||||||
|
export function canPlaceFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, targetId: Id, placement: Placement): boolean {
|
||||||
|
const dragged = mailboxes[draggedId];
|
||||||
|
const target = mailboxes[targetId];
|
||||||
|
if (!dragged || !target || draggedId === targetId) return false;
|
||||||
|
if (!dragged.myRights.mayRename) return false;
|
||||||
|
if (target.role === "inbox" && placement === "before") return false;
|
||||||
|
if (descendantIds(mailboxes, draggedId).has(targetId)) return false;
|
||||||
|
const from = parentOf(mailboxes, dragged);
|
||||||
|
const to = parentOf(mailboxes, target);
|
||||||
|
return from === to || canDropFolder(mailboxes, draggedId, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The updates that put `draggedId` just above or below `targetId`, or null when
|
||||||
|
* it is already there.
|
||||||
|
*
|
||||||
|
* The new level is numbered afresh, 10 apart, so that another client can put
|
||||||
|
* a folder between two of them without renumbering. Only folders whose number
|
||||||
|
* actually changes are written.
|
||||||
|
*/
|
||||||
|
export function placeFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, targetId: Id, placement: Placement): Record<Id, Partial<Mailbox>> | null {
|
||||||
|
const dragged = mailboxes[draggedId]!;
|
||||||
|
const parentId = parentOf(mailboxes, mailboxes[targetId]!);
|
||||||
|
const reparent = parentOf(mailboxes, dragged) !== parentId;
|
||||||
|
const current = siblingsOf(mailboxes, parentId);
|
||||||
|
const order = current.filter((m) => m.id !== draggedId);
|
||||||
|
const at = order.findIndex((m) => m.id === targetId) + (placement === "after" ? 1 : 0);
|
||||||
|
order.splice(at, 0, dragged);
|
||||||
|
// Dropped where it already was. Renumbering would change nothing anyone sees.
|
||||||
|
if (!reparent && order.every((m, i) => m.id === current[i]!.id)) return null;
|
||||||
|
|
||||||
|
const updates: Record<Id, Partial<Mailbox>> = {};
|
||||||
|
order.forEach((m, i) => {
|
||||||
|
const sortOrder = (i + 1) * 10;
|
||||||
|
if (m.sortOrder !== sortOrder) updates[m.id] = { sortOrder };
|
||||||
|
});
|
||||||
|
if (reparent) updates[draggedId] = { ...updates[draggedId], parentId };
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The neighbour to place a folder against for "Move up" / "Move down", if it
|
||||||
|
* has one. Only folders on screen count (`shown`), so each step visibly moves
|
||||||
|
* the folder rather than passing a hidden one.
|
||||||
|
*/
|
||||||
|
export function neighbour(mailboxes: Record<Id, Mailbox>, id: Id, direction: "up" | "down", shown: (m: Mailbox) => boolean = () => true): { targetId: Id; placement: Placement } | null {
|
||||||
|
const m = mailboxes[id];
|
||||||
|
if (!m) return null;
|
||||||
|
const level = siblingsOf(mailboxes, parentOf(mailboxes, m)).filter((x) => x.id === id || shown(x));
|
||||||
|
const i = level.findIndex((x) => x.id === id);
|
||||||
|
const other = level[direction === "up" ? i - 1 : i + 1];
|
||||||
|
if (!other) return null;
|
||||||
|
const placement = direction === "up" ? "before" : "after";
|
||||||
|
return canPlaceFolder(mailboxes, id, other.id, placement) ? { targetId: other.id, placement } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentOf(mailboxes: Record<Id, Mailbox>, m: Mailbox): Id | null {
|
||||||
|
return m.parentId && mailboxes[m.parentId] ? m.parentId : null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { withBase } from "../basePath";
|
import { withBase } from "../basePath";
|
||||||
|
|
||||||
let baseTitle = "ihasmail";
|
let baseTitle = "INBUXA";
|
||||||
let faviconCanvas: HTMLCanvasElement | null = null;
|
let faviconCanvas: HTMLCanvasElement | null = null;
|
||||||
let baseFavicon: HTMLImageElement | null = null;
|
let baseFavicon: HTMLImageElement | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -4,5 +4,8 @@
|
|||||||
* The AGPL asks whoever runs a modified version to offer *that* version's
|
* The AGPL asks whoever runs a modified version to offer *that* version's
|
||||||
* source. The server says where its own lives, via SOURCE_URL; this is only the
|
* source. The server says where its own lives, via SOURCE_URL; this is only the
|
||||||
* fallback for when it has not been asked yet, or has nothing to say.
|
* fallback for when it has not been asked yet, or has nothing to say.
|
||||||
|
*
|
||||||
|
* ihasmail-inbuxa: INBUXA runs a modified ihasmail, so the offer is INBUXA's
|
||||||
|
* fork and not the project it came from.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_SOURCE_URL = "https://github.com/Coffey-Labs/ihasmail";
|
export const DEFAULT_SOURCE_URL = "https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa";
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* was installed, which is the same condition background notifications already
|
* was installed, which is the same condition background notifications already
|
||||||
* carry — a push subscription has to be renewed from a tab too.
|
* carry — a push subscription has to be renewed from a tab too.
|
||||||
*/
|
*/
|
||||||
|
import { currentAppName } from "@/lib/brand";
|
||||||
import { withBase } from "../basePath";
|
import { withBase } from "../basePath";
|
||||||
import { SW_CACHE_NAME } from "./swCache";
|
import { SW_CACHE_NAME } from "./swCache";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -57,7 +58,7 @@ export async function publishWorkerFacts(accountId: string | null, archiveId: st
|
|||||||
noSubject: t("(no subject)"),
|
noSubject: t("(no subject)"),
|
||||||
archive: t("Archive"),
|
archive: t("Archive"),
|
||||||
markRead: t("Mark as read"),
|
markRead: t("Mark as read"),
|
||||||
failed: t("Could not do that — open ihasmail and try again"),
|
failed: t("Could not do that — open {app} and try again", { app: currentAppName() }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
|||||||
+68
-31
@@ -147,8 +147,8 @@ export const catalog: Catalog = {
|
|||||||
"The numbers your role can see, as the server reports them.": "Die Zahlen, die Ihre Rolle sehen darf, so wie der Server sie meldet.",
|
"The numbers your role can see, as the server reports them.": "Die Zahlen, die Ihre Rolle sehen darf, so wie der Server sie meldet.",
|
||||||
"Nothing to show": "Nichts anzuzeigen",
|
"Nothing to show": "Nichts anzuzeigen",
|
||||||
"Could not be loaded": "Konnte nicht geladen werden",
|
"Could not be loaded": "Konnte nicht geladen werden",
|
||||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Detaillierte Metriken, die Zustellwarteschlange, Protokolle und Servereinstellungen finden Sie in der Verwaltung von Stalwart selbst.",
|
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Detaillierte Metriken, die Zustellwarteschlange, Protokolle und Servereinstellungen finden Sie in INBUXA Admin.",
|
||||||
"Open Stalwart admin": "Stalwart-Verwaltung öffnen",
|
"Open INBUXA Admin": "INBUXA Admin öffnen",
|
||||||
"Default group role": "Standardrolle für Gruppen",
|
"Default group role": "Standardrolle für Gruppen",
|
||||||
"A group needs an address.": "Eine Gruppe braucht eine Adresse.",
|
"A group needs an address.": "Eine Gruppe braucht eine Adresse.",
|
||||||
"New group": "Neue Gruppe",
|
"New group": "Neue Gruppe",
|
||||||
@@ -217,10 +217,10 @@ export const catalog: Catalog = {
|
|||||||
"New role": "Neue Rolle",
|
"New role": "Neue Rolle",
|
||||||
"This role carries permissions yours doesn't, so you can view it but not change it.": "Diese Rolle hat Berechtigungen, die Ihre nicht hat. Sie können sie ansehen, aber nicht ändern.",
|
"This role carries permissions yours doesn't, so you can view it but not change it.": "Diese Rolle hat Berechtigungen, die Ihre nicht hat. Sie können sie ansehen, aber nicht ändern.",
|
||||||
"Your role lets you view roles but not change them.": "Ihre Rolle erlaubt es, Rollen anzusehen, aber nicht zu ändern.",
|
"Your role lets you view roles but not change them.": "Ihre Rolle erlaubt es, Rollen anzusehen, aber nicht zu ändern.",
|
||||||
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart vergibt diese Rolle standardmäßig an {kinds}. Eine Änderung betrifft alle, die sie auf diesem Weg haben.",
|
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Der Mailserver vergibt diese Rolle standardmäßig an {kinds}. Eine Änderung betrifft alle, die sie auf diesem Weg haben.",
|
||||||
"Builds on": "Baut auf",
|
"Builds on": "Baut auf",
|
||||||
"Permissions": "Berechtigungen",
|
"Permissions": "Berechtigungen",
|
||||||
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart vergibt diese Rolle standardmäßig, daher kann sie nicht gelöscht werden. Ändern Sie zuerst die Standardwerte in der Verwaltung von Stalwart selbst.",
|
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Der Mailserver vergibt diese Rolle standardmäßig, daher kann sie nicht gelöscht werden. Ändern Sie zuerst die Standardwerte in INBUXA Admin.",
|
||||||
"This role carries permissions yours doesn't.": "Diese Rolle hat Berechtigungen, die Ihre nicht hat.",
|
"This role carries permissions yours doesn't.": "Diese Rolle hat Berechtigungen, die Ihre nicht hat.",
|
||||||
"Create role": "Rolle anlegen",
|
"Create role": "Rolle anlegen",
|
||||||
"builds on this one": "baut auf dieser auf",
|
"builds on this one": "baut auf dieser auf",
|
||||||
@@ -247,7 +247,7 @@ export const catalog: Catalog = {
|
|||||||
"Delete role": "Rolle löschen",
|
"Delete role": "Rolle löschen",
|
||||||
"It can't be undone.": "Das lässt sich nicht rückgängig machen.",
|
"It can't be undone.": "Das lässt sich nicht rückgängig machen.",
|
||||||
"Type {name} to confirm": "Geben Sie {name} zur Bestätigung ein",
|
"Type {name} to confirm": "Geben Sie {name} zur Bestätigung ein",
|
||||||
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Die Berechtigungsliste von Stalwart konnte nicht geladen werden, daher lassen sich Berechtigungen hier nicht ändern. ({reason})",
|
"The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Die Berechtigungsliste des Mailservers konnte nicht geladen werden, daher lassen sich Berechtigungen hier nicht ändern. ({reason})",
|
||||||
"Named sets of permissions, given to accounts, groups and tenants.": "Benannte Sätze von Berechtigungen, vergeben an Konten, Gruppen und Mandanten.",
|
"Named sets of permissions, given to accounts, groups and tenants.": "Benannte Sätze von Berechtigungen, vergeben an Konten, Gruppen und Mandanten.",
|
||||||
"Search roles": "Rollen durchsuchen",
|
"Search roles": "Rollen durchsuchen",
|
||||||
"No roles match": "Keine passenden Rollen",
|
"No roles match": "Keine passenden Rollen",
|
||||||
@@ -270,11 +270,11 @@ export const catalog: Catalog = {
|
|||||||
"{used} used": "{used} belegt",
|
"{used} used": "{used} belegt",
|
||||||
"Your role lets you view tenants but not change them.": "Ihre Rolle erlaubt es, Mandanten anzusehen, aber nicht zu ändern.",
|
"Your role lets you view tenants but not change them.": "Ihre Rolle erlaubt es, Mandanten anzusehen, aber nicht zu ändern.",
|
||||||
"Logo": "Logo",
|
"Logo": "Logo",
|
||||||
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Eine https-Adresse oder eine Data-URL eines Bildes. Stalwart zeigt es den Personen des Mandanten dort, wo es ein Logo zeigt.",
|
"An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Eine https-Adresse oder eine Data-URL eines Bildes. Der Mailserver zeigt es den Personen des Mandanten dort, wo er ein Logo zeigt.",
|
||||||
"What it holds": "Enthält",
|
"What it holds": "Enthält",
|
||||||
"{n} of {limit}": "{n} von {limit}",
|
"{n} of {limit}": "{n} von {limit}",
|
||||||
"Limits": "Limits",
|
"Limits": "Limits",
|
||||||
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart verweigert es, mehr anzulegen, als ein Limit erlaubt. Ein leeres Feld bedeutet kein Limit.",
|
"The mail server refuses to create more than a limit allows. An empty field is no limit.": "Der Mailserver verweigert es, mehr anzulegen, als ein Limit erlaubt. Ein leeres Feld bedeutet kein Limit.",
|
||||||
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Das Höchste, was jemandem in diesem Mandanten erlaubt sein kann: Die eigenen Rollen werden auf das beschränkt, was diese gewähren. Angeboten werden nur Rollen, deren Berechtigungen Sie selbst haben.",
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Das Höchste, was jemandem in diesem Mandanten erlaubt sein kann: Die eigenen Rollen werden auf das beschränkt, was diese gewähren. Angeboten werden nur Rollen, deren Berechtigungen Sie selbst haben.",
|
||||||
"Checking what is still in this tenant…": "Es wird geprüft, was noch in diesem Mandanten ist…",
|
"Checking what is still in this tenant…": "Es wird geprüft, was noch in diesem Mandanten ist…",
|
||||||
"It still holds accounts, domains or other things. Move them out first.": "Er enthält noch Konten, Domains oder anderes. Verschieben Sie diese zuerst.",
|
"It still holds accounts, domains or other things. Move them out first.": "Er enthält noch Konten, Domains oder anderes. Verschieben Sie diese zuerst.",
|
||||||
@@ -290,7 +290,6 @@ export const catalog: Catalog = {
|
|||||||
"Still holds {things}. Move them out first.": "Enthält noch {things}. Verschieben Sie diese zuerst.",
|
"Still holds {things}. Move them out first.": "Enthält noch {things}. Verschieben Sie diese zuerst.",
|
||||||
"Delete tenant": "Mandant löschen",
|
"Delete tenant": "Mandant löschen",
|
||||||
"Separate organizations on one server, each with its own people, domains and limits.": "Getrennte Organisationen auf einem Server, jede mit eigenen Personen, Domains und Limits.",
|
"Separate organizations on one server, each with its own people, domains and limits.": "Getrennte Organisationen auf einem Server, jede mit eigenen Personen, Domains und Limits.",
|
||||||
"Tenants are a Stalwart Enterprise feature.": "Mandanten sind eine Funktion von Stalwart Enterprise.",
|
|
||||||
"Search tenants": "Mandanten durchsuchen",
|
"Search tenants": "Mandanten durchsuchen",
|
||||||
"No tenants match": "Keine passenden Mandanten",
|
"No tenants match": "Keine passenden Mandanten",
|
||||||
"No tenants yet": "Noch keine Mandanten",
|
"No tenants yet": "Noch keine Mandanten",
|
||||||
@@ -518,7 +517,7 @@ export const catalog: Catalog = {
|
|||||||
"Waiting on the server — goes out {when}.": "Wartet auf dem Server — geht {when} raus.",
|
"Waiting on the server — goes out {when}.": "Wartet auf dem Server — geht {when} raus.",
|
||||||
"Scheduled — click to clear the schedule": "Geplant — zum Aufheben klicken",
|
"Scheduled — click to clear the schedule": "Geplant — zum Aufheben klicken",
|
||||||
"Nothing scheduled": "Nichts geplant",
|
"Nothing scheduled": "Nichts geplant",
|
||||||
"The message waits on the server, so it goes out whether or not ihasmail is open.": "Die Nachricht wartet auf dem Server und wird gesendet, ob ihasmail geöffnet ist oder nicht.",
|
"The message waits on the server, so it goes out whether or not {app} is open.": "Die Nachricht wartet auf dem Server und wird gesendet, ob {app} geöffnet ist oder nicht.",
|
||||||
"This server holds a message for up to {span}.": "Dieser Server hält eine Nachricht bis zu {span} zurück.",
|
"This server holds a message for up to {span}.": "Dieser Server hält eine Nachricht bis zu {span} zurück.",
|
||||||
"Date and time to send": "Datum und Uhrzeit für den Versand",
|
"Date and time to send": "Datum und Uhrzeit für den Versand",
|
||||||
"Undo send window": "Zeitfenster zum Rückgängigmachen",
|
"Undo send window": "Zeitfenster zum Rückgängigmachen",
|
||||||
@@ -737,7 +736,7 @@ export const catalog: Catalog = {
|
|||||||
"Sections": "Bereiche",
|
"Sections": "Bereiche",
|
||||||
"General": "Allgemein",
|
"General": "Allgemein",
|
||||||
"Appearance": "Darstellung",
|
"Appearance": "Darstellung",
|
||||||
"Make ihasmail yours.": "Machen Sie ihasmail zu Ihrem.",
|
"Make {app} yours.": "Machen Sie {app} zu Ihrem.",
|
||||||
"Reading": "Lesen",
|
"Reading": "Lesen",
|
||||||
"Reading pane": "Lesebereich",
|
"Reading pane": "Lesebereich",
|
||||||
"Right of the list": "Rechts von der Liste",
|
"Right of the list": "Rechts von der Liste",
|
||||||
@@ -754,6 +753,7 @@ export const catalog: Catalog = {
|
|||||||
"Attachment reminder": "Anhang-Erinnerung",
|
"Attachment reminder": "Anhang-Erinnerung",
|
||||||
"Warn when the message mentions an attachment but none is attached.": "Warnen, wenn die Nachricht einen Anhang erwähnt, aber keiner angehängt ist.",
|
"Warn when the message mentions an attachment but none is attached.": "Warnen, wenn die Nachricht einen Anhang erwähnt, aber keiner angehängt ist.",
|
||||||
"Spell check while typing": "Rechtschreibprüfung während der Eingabe",
|
"Spell check while typing": "Rechtschreibprüfung während der Eingabe",
|
||||||
|
"Open the composer full screen": "Nachrichten im Vollbild verfassen",
|
||||||
"Confirm before deleting": "Vor dem Löschen bestätigen",
|
"Confirm before deleting": "Vor dem Löschen bestätigen",
|
||||||
"Show message snippets": "Nachrichtenvorschau anzeigen",
|
"Show message snippets": "Nachrichtenvorschau anzeigen",
|
||||||
"Preview the first line of each message in the list.": "Die erste Zeile jeder Nachricht in der Liste anzeigen.",
|
"Preview the first line of each message in the list.": "Die erste Zeile jeder Nachricht in der Liste anzeigen.",
|
||||||
@@ -832,7 +832,8 @@ export const catalog: Catalog = {
|
|||||||
"Reset to defaults": "Auf Standard zurücksetzen",
|
"Reset to defaults": "Auf Standard zurücksetzen",
|
||||||
"Default mail app": "Standard-E-Mail-Programm",
|
"Default mail app": "Standard-E-Mail-Programm",
|
||||||
"Documentation": "Dokumentation",
|
"Documentation": "Dokumentation",
|
||||||
"About ihasmail": "Über ihasmail",
|
"About {app}": "Über {app}",
|
||||||
|
"Built on {project}": "Basiert auf {project}",
|
||||||
"Server": "Server",
|
"Server": "Server",
|
||||||
"Server capabilities": "Server-Funktionen",
|
"Server capabilities": "Server-Funktionen",
|
||||||
"Accounts": "Konten",
|
"Accounts": "Konten",
|
||||||
@@ -923,6 +924,7 @@ export const catalog: Catalog = {
|
|||||||
"Turn off two-factor authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
"Turn off two-factor authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||||
"Your password alone will be enough to sign in again.": "Ihr Passwort allein genügt dann wieder zum Anmelden.",
|
"Your password alone will be enough to sign in again.": "Ihr Passwort allein genügt dann wieder zum Anmelden.",
|
||||||
"App passwords": "App-Passwörter",
|
"App passwords": "App-Passwörter",
|
||||||
|
"Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "Ihre Organisation erlaubt nur {app} und JMAP-Apps. Mail-Apps auf Smartphones und Computern können sich daher nicht mit diesem Konto verbinden.",
|
||||||
"New app password for": "Neues App-Passwort für",
|
"New app password for": "Neues App-Passwort für",
|
||||||
"Your new app password": "Ihr neues App-Passwort",
|
"Your new app password": "Ihr neues App-Passwort",
|
||||||
"Secret": "Geheimnis",
|
"Secret": "Geheimnis",
|
||||||
@@ -950,8 +952,8 @@ export const catalog: Catalog = {
|
|||||||
"Notifications": "Benachrichtigungen",
|
"Notifications": "Benachrichtigungen",
|
||||||
"Notifications are blocked in your browser settings.": "Benachrichtigungen sind in Ihren Browsereinstellungen blockiert.",
|
"Notifications are blocked in your browser settings.": "Benachrichtigungen sind in Ihren Browsereinstellungen blockiert.",
|
||||||
"Not supported in this browser.": "In diesem Browser nicht unterstützt.",
|
"Not supported in this browser.": "In diesem Browser nicht unterstützt.",
|
||||||
"Desktop notifications while ihasmail is open": "Desktop-Benachrichtigungen, solange ihasmail geöffnet ist",
|
"Desktop notifications while {app} is open": "Desktop-Benachrichtigungen, solange {app} geöffnet ist",
|
||||||
"Notify me even when ihasmail is closed": "Auch benachrichtigen, wenn ihasmail geschlossen ist",
|
"Notify me even when {app} is closed": "Auch benachrichtigen, wenn {app} geschlossen ist",
|
||||||
"Play a sound for new mail": "Ton bei neuer E-Mail abspielen",
|
"Play a sound for new mail": "Ton bei neuer E-Mail abspielen",
|
||||||
"Test notification": "Testbenachrichtigung",
|
"Test notification": "Testbenachrichtigung",
|
||||||
"Background notifications are on": "Hintergrundbenachrichtigungen sind aktiviert",
|
"Background notifications are on": "Hintergrundbenachrichtigungen sind aktiviert",
|
||||||
@@ -1064,7 +1066,7 @@ export const catalog: Catalog = {
|
|||||||
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Neue Identitäten müssen eine Adresse verwenden, von der dieses Konto senden darf (auf dem Server eingerichtete Aliase).",
|
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Neue Identitäten müssen eine Adresse verwenden, von der dieses Konto senden darf (auf dem Server eingerichtete Aliase).",
|
||||||
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Wird beim Verfassen nicht angeboten. Die Adresse empfängt weiterhin Nachrichten, und Sie können wieder von ihr senden, indem Sie sie erneut einblenden.",
|
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Wird beim Verfassen nicht angeboten. Die Adresse empfängt weiterhin Nachrichten, und Sie können wieder von ihr senden, indem Sie sie erneut einblenden.",
|
||||||
"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.": "Jede Identität ist eine Absenderadresse mit eigenem Namen, eigener Antwortadresse und eigener Signatur. Die Standardidentität ist beim Verfassen vorausgewählt; legen Sie eine Antwortadresse fest, wenn Antworten woanders hingehen sollen als an die Absenderadresse.",
|
"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.": "Jede Identität ist eine Absenderadresse mit eigenem Namen, eigener Antwortadresse und eigener Signatur. Die Standardidentität ist beim Verfassen vorausgewählt; legen Sie eine Antwortadresse fest, wenn Antworten woanders hingehen sollen als an die Absenderadresse.",
|
||||||
"This signature is larger than the server's {limit}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.": "Diese Signatur überschreitet das Limit des Servers von {limit} Byte. ihasmail behält die vollständige Fassung in Ihren Dateien und speichert eine kurze Textfassung auf dem Server — andere E-Mail-Programme sehen die Nur-Text-Fassung.",
|
"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.": "Diese Signatur \u00fcberschreitet das Limit des Servers von {limit} Byte. {app} beh\u00e4lt die vollst\u00e4ndige Fassung in Ihren Dateien und speichert eine kurze Textfassung auf dem Server \u2014 andere E-Mail-Programme sehen die Nur-Text-Fassung.",
|
||||||
"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.": "Kategorien im Outlook-Stil, die Sie Terminen über das Rechtsklick-Menü oder den Termin-Editor zuweisen können. Der Kategoriename wird im Termin gespeichert und daher mit anderen Clients synchronisiert.",
|
"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.": "Kategorien im Outlook-Stil, die Sie Terminen über das Rechtsklick-Menü oder den Termin-Editor zuweisen können. Der Kategoriename wird im Termin gespeichert und daher mit anderen Clients synchronisiert.",
|
||||||
"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.": "Nur-Text-Nachrichten folgen dem Design bereits. Ist dies aktiviert, gilt das auch für HTML-Nachrichten ohne eigene Farben, statt sie auf einer weißen Fläche darzustellen. Nachrichten mit eigener Gestaltung bleiben genau so, wie der Absender sie entworfen hat.",
|
"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.": "Nur-Text-Nachrichten folgen dem Design bereits. Ist dies aktiviert, gilt das auch für HTML-Nachrichten ohne eigene Farben, statt sie auf einer weißen Fläche darzustellen. Nachrichten mit eigener Gestaltung bleiben genau so, wie der Absender sie entworfen hat.",
|
||||||
"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.": "Das ist unabhängig von {setting} unter „Allgemein“, wo festgelegt wird, wie Datum, Uhrzeit und Zahlen geschrieben werden. Sie können eine englische Oberfläche mit deutschen Datumsangaben lesen — oder umgekehrt.",
|
"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.": "Das ist unabhängig von {setting} unter „Allgemein“, wo festgelegt wird, wie Datum, Uhrzeit und Zahlen geschrieben werden. Sie können eine englische Oberfläche mit deutschen Datumsangaben lesen — oder umgekehrt.",
|
||||||
@@ -1072,29 +1074,29 @@ export const catalog: Catalog = {
|
|||||||
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Dieser Bildschirm hat keinen Touchscreen, hier ändert sich also nichts. Ihr Telefon oder Tablet übernimmt diese Einstellungen.",
|
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Dieser Bildschirm hat keinen Touchscreen, hier ändert sich also nichts. Ihr Telefon oder Tablet übernimmt diese Einstellungen.",
|
||||||
"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.": "Eine Nachricht gedrückt halten wählt sie aus, einen Ordner gedrückt halten öffnet dessen Menü. Ziehen Sie die Nachrichtenliste nach unten, um nach neuer Post zu sehen.",
|
"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.": "Eine Nachricht gedrückt halten wählt sie aus, einen Ordner gedrückt halten öffnet dessen Menü. Ziehen Sie die Nachrichtenliste nach unten, um nach neuer Post zu sehen.",
|
||||||
"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.": "Eine Bestätigung verrät dem Anfragenden, dass diese Adresse aktiv ist und wann die Nachricht gelesen wurde, und der Absender bestimmt, wohin sie geht — deshalb gibt es keine automatische Option. Bei Massensendungen, Mailinglisten und allem, was als automatisch versendet gekennzeichnet ist, wird sie nie angeboten.",
|
"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.": "Eine Bestätigung verrät dem Anfragenden, dass diese Adresse aktiv ist und wann die Nachricht gelesen wurde, und der Absender bestimmt, wohin sie geht — deshalb gibt es keine automatische Option. Bei Massensendungen, Mailinglisten und allem, was als automatisch versendet gekennzeichnet ist, wird sie nie angeboten.",
|
||||||
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.": "Dieser Browser kann keine Programme für {scheme}-Links registrieren. Safari hat insbesondere keine solche Schnittstelle — Sie können ihasmail dennoch über Ihr Betriebssystem als Standard festlegen, wenn Sie es als App installieren.",
|
"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.": "Dieser Browser kann keine Programme f\u00fcr {scheme}-Links registrieren. Safari hat insbesondere keine solche Schnittstelle \u2014 Sie k\u00f6nnen {app} dennoch \u00fcber Ihr Betriebssystem als Standard festlegen, wenn Sie es als App installieren.",
|
||||||
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Für die Registrierung von {scheme}-Links ist eine sichere Verbindung (HTTPS) erforderlich.",
|
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Für die Registrierung von {scheme}-Links ist eine sichere Verbindung (HTTPS) erforderlich.",
|
||||||
"Open {scheme} links — in web pages, documents and other apps — in ihasmail 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 › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).": "{scheme}-Links — auf Webseiten, in Dokumenten und anderen Programmen — in ihasmail öffnen statt in einem Desktop-Mailprogramm. Ihr Browser fragt nach einer Bestätigung, und Sie können das später in seinen eigenen Einstellungen ändern (Chrome: Einstellungen › Datenschutz und Sicherheit › Website-Einstellungen › Protokoll-Handler; Firefox: Einstellungen › Allgemein › Anwendungen).",
|
"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 auf Webseiten, in Dokumenten und anderen Programmen \u2014 in {app} \u00f6ffnen statt in einem Desktop-Mailprogramm. Ihr Browser fragt nach einer Best\u00e4tigung, und Sie k\u00f6nnen das sp\u00e4ter in seinen eigenen Einstellungen \u00e4ndern (Chrome: Einstellungen \u203a Datenschutz und Sicherheit \u203a Website-Einstellungen \u203a Protokoll-Handler; Firefox: Einstellungen \u203a Allgemein \u203a Anwendungen).",
|
||||||
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "In diesem Browser angefordert. Ob es gewirkt hat, entscheidet der Browser — prüfen Sie dessen Einstellungen, falls E-Mail-Links weiterhin anderswo geöffnet werden.",
|
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "In diesem Browser angefordert. Ob es gewirkt hat, entscheidet der Browser — prüfen Sie dessen Einstellungen, falls E-Mail-Links weiterhin anderswo geöffnet werden.",
|
||||||
"For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.": "Für einen systemweiten Standard installieren Sie ihasmail zuerst als App (in Chrome: das Installationssymbol in der Adressleiste). Ihr Betriebssystem kann ihasmail dann überall dort direkt anbieten, wo es nach einem E-Mail-Programm fragt.",
|
"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.": "Für einen systemweiten Standard installieren Sie {app} zuerst als App (in Chrome: das Installationssymbol in der Adressleiste). Ihr Betriebssystem kann {app} dann überall dort direkt anbieten, wo es nach einem E-Mail-Programm fragt.",
|
||||||
"Needs a browser with the Push API and a mail server that publishes a push key.": "Erfordert einen Browser mit Push-API und einen Mailserver, der einen Push-Schlüssel veröffentlicht.",
|
"Needs a browser with the Push API and a mail server that publishes a push key.": "Erfordert einen Browser mit Push-API und einen Mailserver, der einen Push-Schlüssel veröffentlicht.",
|
||||||
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Ihr Mailserver stellt diese direkt an Ihren Browser zu, sodass sie auch ohne geöffneten ihasmail-Tab ankommen — mit Absender und Betreff. Ihr Browser muss dennoch laufen: Beenden Sie ihn vollständig, warten die Benachrichtigungen und kommen an, sobald Sie ihn wieder öffnen.",
|
"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.": "Ihr Mailserver stellt diese direkt an Ihren Browser zu, sodass sie auch ohne ge\u00f6ffneten {app}-Tab ankommen \u2014 mit Absender und Betreff. Ihr Browser muss dennoch laufen: Beenden Sie ihn vollst\u00e4ndig, warten die Benachrichtigungen und kommen an, sobald Sie ihn wieder \u00f6ffnen.",
|
||||||
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Ihr Mailserver kann diesen Browser wecken, übermittelt aber weder Absender noch Betreff. Ihr Browser muss dennoch laufen.",
|
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Ihr Mailserver kann diesen Browser wecken, übermittelt aber weder Absender noch Betreff. Ihr Browser muss dennoch laufen.",
|
||||||
"This is what a new-mail notification looks like.": "So sieht eine Benachrichtigung über neue Post aus.",
|
"This is what a new-mail notification looks like.": "So sieht eine Benachrichtigung über neue Post aus.",
|
||||||
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Sie sind als {user} angemeldet. Ihr Passwort wird nie im Browser gespeichert; der Server hält es pro Sitzung verschlüsselt vor, um mit Stalwart zu kommunizieren.",
|
"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.": "Sie sind als {user} angemeldet. Ihr Passwort wird nie im Browser gespeichert; der Server hält es pro Sitzung verschlüsselt vor, um mit dem Mailserver zu kommunizieren.",
|
||||||
"App passwords are managed by your mail administrator.": "App-Passwörter werden von Ihrer Mail-Administration verwaltet.",
|
"App passwords are managed by your mail administrator.": "App-Passwörter werden von Ihrer Mail-Administration verwaltet.",
|
||||||
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Wenn Sie Ihr Passwort ändern, werden Ihre anderen Webmail-Sitzungen abgemeldet. App-Passwörter funktionieren weiterhin.",
|
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Wenn Sie Ihr Passwort ändern, werden Ihre anderen Webmail-Sitzungen abgemeldet. App-Passwörter funktionieren weiterhin.",
|
||||||
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Für dieses Konto ist die Zwei-Faktor-Authentifizierung aktiviert. ihasmail kann Sie noch nicht per Code anmelden; die Anmeldung auf einem anderen Gerät benötigt daher ein App-Passwort — oder Sie deaktivieren die Zwei-Faktor-Authentifizierung hier.",
|
"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.": "F\u00fcr dieses Konto ist die Zwei-Faktor-Authentifizierung aktiviert. {app} kann Sie noch nicht per Code anmelden; die Anmeldung auf einem anderen Ger\u00e4t ben\u00f6tigt daher ein App-Passwort \u2014 oder Sie deaktivieren die Zwei-Faktor-Authentifizierung hier.",
|
||||||
"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.": "Ein eigenes Passwort für ein E-Mail-Programm oder Gerät, das Sie einzeln widerrufen können. App-Passwörter umgehen Zwei-Faktor-Codes und funktionieren daher auch in Programmen, die keinen abfragen können.",
|
"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.": "Ein eigenes Passwort für ein E-Mail-Programm oder Gerät, das Sie einzeln widerrufen können. App-Passwörter umgehen Zwei-Faktor-Codes und funktionieren daher auch in Programmen, die keinen abfragen können.",
|
||||||
"Copy it into {name} now — it isn't shown again.": "Übertragen Sie es jetzt nach {name} — es wird nicht erneut angezeigt.",
|
"Copy it into {name} now — it isn't shown again.": "Übertragen Sie es jetzt nach {name} — es wird nicht erneut angezeigt.",
|
||||||
"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.": "Im Verzeichnis wurden keine weiteren Benutzer gefunden, es kann also niemand Neues hinzugefügt werden. Bestehende Freigaben sind unten aufgeführt und können weiterhin entfernt werden.",
|
"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.": "Im Verzeichnis wurden keine weiteren Benutzer gefunden, es kann also niemand Neues hinzugefügt werden. Bestehende Freigaben sind unten aufgeführt und können weiterhin entfernt werden.",
|
||||||
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart gibt seine Versionsnummer nicht an E-Mail-Programme weiter, daher nennt ihasmail die Edition, sofern der Server eine angibt. ihasmail benötigt 0.16 oder neuer; die Anmeldung verweigert ältere Versionen.",
|
"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 gibt seine Versionsnummer nicht an E-Mail-Programme weiter, daher nennt {app} die Edition, sofern der Server eine angibt. {app} ben\u00f6tigt 0.16 oder neuer; die Anmeldung verweigert \u00e4ltere Versionen.",
|
||||||
"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.": "Es {damage}, daher können die enthaltenen Regeln weder angezeigt noch bearbeitet werden — das Speichern des angekommenen Teils würde den Rest überschreiben. Laden Sie die Seite neu und versuchen Sie es erneut. Ihre Regeln liegen weiterhin auf dem Server; hier wurde nichts daran geändert.",
|
"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.": "Es {damage}, daher können die enthaltenen Regeln weder angezeigt noch bearbeitet werden — das Speichern des angekommenen Teils würde den Rest überschreiben. Laden Sie die Seite neu und versuchen Sie es erneut. Ihre Regeln liegen weiterhin auf dem Server; hier wurde nichts daran geändert.",
|
||||||
"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).": "Der visuelle Regeleditor verwaltet nur Skripte, die er selbst erstellt hat. Sie können das Skript im Reiter {tab} bearbeiten oder neu mit Regeln beginnen (das vorhandene Skript bleibt erhalten, wird aber deaktiviert).",
|
"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).": "Der visuelle Regeleditor verwaltet nur Skripte, die er selbst erstellt hat. Sie können das Skript im Reiter {tab} bearbeiten oder neu mit Regeln beginnen (das vorhandene Skript bleibt erhalten, wird aber deaktiviert).",
|
||||||
"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.": "Ihr Filterskript {damage}, daher ist nur ein Teil angekommen. Eine Regel hinzuzufügen würde diesen Teil über das Ganze schreiben. Laden Sie die Seite neu und versuchen Sie es erneut.",
|
"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.": "Ihr Filterskript {damage}, daher ist nur ein Teil angekommen. Eine Regel hinzuzufügen würde diesen Teil über das Ganze schreiben. Laden Sie die Seite neu und versuchen Sie es erneut.",
|
||||||
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Ihr Filterskript konnte gerade nicht gelesen werden; eine Regel hinzuzufügen würde riskieren, es zu überschreiben. Laden Sie die Seite neu und versuchen Sie es erneut.",
|
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Ihr Filterskript konnte gerade nicht gelesen werden; eine Regel hinzuzufügen würde riskieren, es zu überschreiben. Laden Sie die Seite neu und versuchen Sie es erneut.",
|
||||||
"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.": "Ihr aktives Sieve-Skript wurde von Hand geschrieben, daher können Regeln nicht automatisch hinzugefügt werden. Öffnen Sie {where}, um das Skript zu bearbeiten oder zu verwalteten Regeln zu wechseln.",
|
"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.": "Ihr aktives Sieve-Skript wurde von Hand geschrieben, daher können Regeln nicht automatisch hinzugefügt werden. Öffnen Sie {where}, um das Skript zu bearbeiten oder zu verwalteten Regeln zu wechseln.",
|
||||||
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Hier erscheinen nur Sprachen, in die ihasmail übersetzt wurde; die Liste wächst also mit den Übersetzungen und nicht vorab — eine Sprache ohne hinterlegte Texte würde die Seite behaupten lassen, sie sei in einer Sprache, in der sie nicht ist.",
|
"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 erscheinen nur Sprachen, in die {app} \u00fcbersetzt wurde; die Liste w\u00e4chst also mit den \u00dcbersetzungen und nicht vorab \u2014 eine Sprache ohne hinterlegte Texte w\u00fcrde die Seite behaupten lassen, sie sei in einer Sprache, in der sie nicht ist.",
|
||||||
|
|
||||||
// ── Labels defined as constants, translated where they render ──────
|
// ── Labels defined as constants, translated where they render ──────
|
||||||
// The catalogue checker cannot see these: they reach t() as a variable,
|
// The catalogue checker cannot see these: they reach t() as a variable,
|
||||||
@@ -1138,7 +1140,7 @@ export const catalog: Catalog = {
|
|||||||
"Drop here for the top level": "Hierher ziehen für die oberste Ebene",
|
"Drop here for the top level": "Hierher ziehen für die oberste Ebene",
|
||||||
|
|
||||||
// ── Remaining prose ────────────────────────────────────────────────
|
// ── Remaining prose ────────────────────────────────────────────────
|
||||||
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "Die Version von ihasmail ist das Datum des Commits, aus dem es gebaut wurde, gefolgt davon, woher dieser Commit stammt: {example} wurde aus einem Commit vom 30. August 2026 gebaut, der über Pull Request 129 kam. Ein Commit, der nicht über einen solchen kam, trägt stattdessen seinen kurzen SHA — {sha}. Die Version sagt bewusst nichts über Stalwart aus; was dieser Build vom Server benötigt, steht in der Zeile darüber.",
|
"{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.": "Die Version von {app} ist das Datum des Commits, aus dem es gebaut wurde, gefolgt davon, woher dieser Commit stammt: {example} wurde aus einem Commit vom 30. August 2026 gebaut, der \u00fcber Pull Request 129 kam. Ein Commit, der nicht \u00fcber einen solchen kam, tr\u00e4gt stattdessen seinen kurzen SHA \u2014 {sha}. Die Version sagt bewusst nichts \u00fcber Stalwart aus; was dieser Build vom Server ben\u00f6tigt, steht in der Zeile dar\u00fcber.",
|
||||||
|
|
||||||
// ── Weekdays, schedule presets, rule operators ─────────────────────
|
// ── Weekdays, schedule presets, rule operators ─────────────────────
|
||||||
// Header names (List-Id, X-Spam-Status) stay English: they are the actual
|
// Header names (List-Id, X-Spam-Status) stay English: they are the actual
|
||||||
@@ -1191,10 +1193,10 @@ export const catalog: Catalog = {
|
|||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Neue Nachricht",
|
"New message": "Neue Nachricht",
|
||||||
"Start a new message with what was shared?": "Neue Nachricht mit dem geteilten Inhalt beginnen?",
|
"Start a new message with what was shared?": "Neue Nachricht mit dem geteilten Inhalt beginnen?",
|
||||||
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Es wurde etwas mit ihasmail geteilt. Gesendet wird erst, wenn Sie „Senden“ wählen. Wenn Sie dies nicht gerade selbst geteilt haben, verwerfen Sie es.",
|
"Something was shared with {app}. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Es wurde etwas mit {app} geteilt. Gesendet wird erst, wenn Sie „Senden“ wählen. Wenn Sie dies nicht gerade selbst geteilt haben, verwerfen Sie es.",
|
||||||
"Start a message": "Nachricht beginnen",
|
"Start a message": "Nachricht beginnen",
|
||||||
"New mail": "Neue E-Mail",
|
"New mail": "Neue E-Mail",
|
||||||
"Could not do that — open ihasmail and try again": "Nicht möglich – öffnen Sie ihasmail und versuchen Sie es erneut",
|
"Could not do that \u2014 open {app} and try again": "Nicht m\u00f6glich \u2013 \u00f6ffnen Sie {app} und versuchen Sie es erneut",
|
||||||
"Sending…": "Wird gesendet…",
|
"Sending…": "Wird gesendet…",
|
||||||
"Saving…": "Wird gespeichert…",
|
"Saving…": "Wird gespeichert…",
|
||||||
"Error": "Fehler",
|
"Error": "Fehler",
|
||||||
@@ -1242,6 +1244,20 @@ export const catalog: Catalog = {
|
|||||||
"Could not save filters: {error}": "Filter konnten nicht gespeichert werden: {error}",
|
"Could not save filters: {error}": "Filter konnten nicht gespeichert werden: {error}",
|
||||||
"Could not send the receipt: {error}": "Die Lesebestätigung konnte nicht gesendet werden: {error}",
|
"Could not send the receipt: {error}": "Die Lesebestätigung konnte nicht gesendet werden: {error}",
|
||||||
"Could not sign in.": "Anmeldung fehlgeschlagen.",
|
"Could not sign in.": "Anmeldung fehlgeschlagen.",
|
||||||
|
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "Sie sind als {user} angemeldet. Diese Webmail sieht Ihr Passwort nie: Sie hält ein Anmelde-Token Ihres Mailservers, pro Sitzung verschlüsselt.",
|
||||||
|
"Mail server": "Mailserver",
|
||||||
|
"You'll enter your password on your mail server's sign-in page.": "Ihr Passwort geben Sie auf der Anmeldeseite Ihres Mailservers ein.",
|
||||||
|
"You'll sign in on your mail server's own page.": "Sie melden sich auf der eigenen Seite Ihres Mailservers an.",
|
||||||
|
"This sign-in didn't start in this browser. Try again.": "Diese Anmeldung wurde nicht in diesem Browser begonnen. Bitte versuchen Sie es erneut.",
|
||||||
|
"The sign-in took too long. Try again.": "Die Anmeldung hat zu lange gedauert. Bitte versuchen Sie es erneut.",
|
||||||
|
"Sign-in was cancelled.": "Die Anmeldung wurde abgebrochen.",
|
||||||
|
"The mail server didn't accept the sign-in. Try again.": "Der Mailserver hat die Anmeldung nicht angenommen. Bitte versuchen Sie es erneut.",
|
||||||
|
"That account is on a different mail server than the address you entered. Sign in with that address.": "Dieses Konto liegt auf einem anderen Mailserver als die eingegebene Adresse. Melden Sie sich mit dieser Adresse an.",
|
||||||
|
"This mail server isn't supported.": "Dieser Mailserver wird nicht unterstützt.",
|
||||||
|
"Couldn't reach the mail server. Try again in a moment.": "Der Mailserver ist nicht erreichbar. Bitte versuchen Sie es gleich noch einmal.",
|
||||||
|
"Your password was changed. Sign in with the new one.": "Ihr Passwort wurde geändert. Melden Sie sich mit dem neuen an.",
|
||||||
|
"Your sign-in ended. Sign in again.": "Ihre Anmeldung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||||
|
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Wenn Sie Ihr Passwort ändern, werden Sie überall abgemeldet, auch hier, und melden sich mit dem neuen wieder an. App-Passwörter funktionieren weiterhin.",
|
||||||
"Could not store image: {error}": "Bild konnte nicht gespeichert werden: {error}",
|
"Could not store image: {error}": "Bild konnte nicht gespeichert werden: {error}",
|
||||||
"Could not update labels: {error}": "Labels konnten nicht aktualisiert werden: {error}",
|
"Could not update labels: {error}": "Labels konnten nicht aktualisiert werden: {error}",
|
||||||
"Could not update the Scheduled folder: {error}": "Der Ordner „Geplant“ konnte nicht aktualisiert werden: {error}",
|
"Could not update the Scheduled folder: {error}": "Der Ordner „Geplant“ konnte nicht aktualisiert werden: {error}",
|
||||||
@@ -1364,7 +1380,7 @@ export const catalog: Catalog = {
|
|||||||
"Undo window: {seconds}s": "Rückgängig-Zeitfenster: {seconds}s",
|
"Undo window: {seconds}s": "Rückgängig-Zeitfenster: {seconds}s",
|
||||||
"You're all caught up": "Sie sind auf dem neuesten Stand",
|
"You're all caught up": "Sie sind auf dem neuesten Stand",
|
||||||
"Your browser refused the request: {error}": "Ihr Browser hat die Anfrage abgelehnt: {error}",
|
"Your browser refused the request: {error}": "Ihr Browser hat die Anfrage abgelehnt: {error}",
|
||||||
"Your browser will ask whether to open mail links in ihasmail": "Ihr Browser wird fragen, ob Mail-Links in ihasmail geöffnet werden sollen",
|
"Your browser will ask whether to open mail links in {app}": "Ihr Browser wird fragen, ob Mail-Links in {app} geöffnet werden sollen",
|
||||||
"Your message mentions an attachment, but nothing is attached.": "Ihre Nachricht erwähnt einen Anhang, aber es ist nichts angehängt.",
|
"Your message mentions an attachment, but nothing is attached.": "Ihre Nachricht erwähnt einen Anhang, aber es ist nichts angehängt.",
|
||||||
"event": "Termin",
|
"event": "Termin",
|
||||||
"Hide password": "Passwort verbergen",
|
"Hide password": "Passwort verbergen",
|
||||||
@@ -1456,7 +1472,7 @@ export const catalog: Catalog = {
|
|||||||
"Also count these domains as inside": "Diese Domains ebenfalls als intern werten",
|
"Also count these domains as inside": "Diese Domains ebenfalls als intern werten",
|
||||||
"Always": "Immer",
|
"Always": "Immer",
|
||||||
"Always showing images from": "Bilder immer anzeigen von",
|
"Always showing images from": "Bilder immer anzeigen von",
|
||||||
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.": "Ein vom Server des Absenders geladenes Bild verrät ihm, dass die Nachricht geöffnet wurde, wann und ungefähr von wo. Freigegebene Bilder werden vom Server von ihasmail abgerufen und nicht vom Browser, sodass der Absender nichts davon erfährt.",
|
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.": "Ein vom Server des Absenders geladenes Bild verrät ihm, dass die Nachricht geöffnet wurde, wann und ungefähr von wo. Freigegebene Bilder werden vom Server von {app} abgerufen und nicht vom Browser, sodass der Absender nichts davon erfährt.",
|
||||||
"Applies to": "Gilt für",
|
"Applies to": "Gilt für",
|
||||||
"Archive by month": "Nach Monat archivieren",
|
"Archive by month": "Nach Monat archivieren",
|
||||||
"Archive by year": "Nach Jahr archivieren",
|
"Archive by year": "Nach Jahr archivieren",
|
||||||
@@ -1677,8 +1693,8 @@ export const catalog: Catalog = {
|
|||||||
"Fingerprint": "Fingerabdruck",
|
"Fingerprint": "Fingerabdruck",
|
||||||
"Hide details": "Details ausblenden",
|
"Hide details": "Details ausblenden",
|
||||||
"Issued by": "Ausgestellt von",
|
"Issued by": "Ausgestellt von",
|
||||||
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Sie ist mit OpenPGP signiert, und ihasmail hat keine Möglichkeit, den öffentlichen Schlüssel des Absenders zu beschaffen.",
|
"It is signed with OpenPGP, and {app} has no way to fetch the sender's public key.": "Sie ist mit OpenPGP signiert, und {app} hat keine Möglichkeit, den öffentlichen Schlüssel des Absenders zu beschaffen.",
|
||||||
"It uses a signature algorithm ihasmail cannot check yet.": "Sie verwendet ein Signaturverfahren, das ihasmail noch nicht prüfen kann.",
|
"It uses a signature algorithm {app} cannot check yet.": "Sie verwendet ein Signaturverfahren, das {app} noch nicht prüfen kann.",
|
||||||
"It was made with a certificate belonging to {name}, which does not cover this address.": "Sie wurde mit einem Zertifikat von {name} erstellt, das diese Adresse nicht abdeckt.",
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Sie wurde mit einem Zertifikat von {name} erstellt, das diese Adresse nicht abdeckt.",
|
||||||
"Previous fingerprint": "Vorheriger Fingerabdruck",
|
"Previous fingerprint": "Vorheriger Fingerabdruck",
|
||||||
"Signed at": "Signiert am",
|
"Signed at": "Signiert am",
|
||||||
@@ -1695,18 +1711,39 @@ export const catalog: Catalog = {
|
|||||||
"The signature is not for this sender.": "Die Signatur gehört nicht zu diesem Absender.",
|
"The signature is not for this sender.": "Die Signatur gehört nicht zu diesem Absender.",
|
||||||
"The signed part is missing either the message or the signature.": "Im signierten Teil fehlt entweder die Nachricht oder die Signatur.",
|
"The signed part is missing either the message or the signature.": "Im signierten Teil fehlt entweder die Nachricht oder die Signatur.",
|
||||||
"The signer has changed.": "Der Unterzeichner hat gewechselt.",
|
"The signer has changed.": "Der Unterzeichner hat gewechselt.",
|
||||||
"This message is signed, and ihasmail could not check the signature.": "Diese Nachricht ist signiert, und ihasmail konnte die Signatur nicht prüfen.",
|
"This message is signed, and {app} could not check the signature.": "Diese Nachricht ist signiert, und {app} konnte die Signatur nicht prüfen.",
|
||||||
"This signature does not check out.": "Diese Signatur stimmt nicht.",
|
"This signature does not check out.": "Diese Signatur stimmt nicht.",
|
||||||
"Valid until": "Gültig bis",
|
"Valid until": "Gültig bis",
|
||||||
"a different certificate": "einem anderen Zertifikat",
|
"a different certificate": "einem anderen Zertifikat",
|
||||||
"an unnamed signer": "einem unbenannten Unterzeichner",
|
"an unnamed signer": "einem unbenannten Unterzeichner",
|
||||||
"as claimed by the signer": "laut Angabe des Unterzeichners",
|
"as claimed by the signer": "laut Angabe des Unterzeichners",
|
||||||
"first seen {date}": "zuerst gesehen {date}",
|
"first seen {date}": "zuerst gesehen {date}",
|
||||||
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail weist Sie darauf hin, wenn eine spätere Nachricht von dieser Adresse von jemand anderem signiert ist.",
|
"{app} will tell you if a later message from this address is signed by anybody else.": "{app} weist Sie darauf hin, wenn eine spätere Nachricht von dieser Adresse von jemand anderem signiert ist.",
|
||||||
"itself, or an issuer it does not name": "sich selbst, oder einem nicht genannten Aussteller",
|
"itself, or an issuer it does not name": "sich selbst, oder einem nicht genannten Aussteller",
|
||||||
"no address": "keine Adresse",
|
"no address": "keine Adresse",
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"Legacy mail apps": "Mail-Apps über ältere Protokolle",
|
||||||
|
"Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.": "Aus für {tenant}. Nur {app} und JMAP-Apps können sich bei den Domains dieser Organisation anmelden.",
|
||||||
|
"On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.": "An für {tenant}. Mail-Apps können auf den Domains dieser Organisation IMAP, POP3 und ManageSieve nutzen.",
|
||||||
|
"Turn legacy protocols back on": "Ältere Mailprotokolle wieder einschalten",
|
||||||
|
"Turn off legacy protocols…": "Ältere Mailprotokolle ausschalten…",
|
||||||
|
"No account used a legacy mail app in the last 30 days.": "In den letzten 30 Tagen hat kein Konto eine Mail-App mit älteren Protokollen verwendet.",
|
||||||
|
"Their mail apps will stop working the moment you turn this on:": "Deren Mail-Apps funktionieren nicht mehr, sobald Sie dies einschalten:",
|
||||||
|
"Only {app} and JMAP apps will work.": "Nur {app} und JMAP-Apps funktionieren dann noch.",
|
||||||
|
"Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {tenant}.": "Ältere Mailprotokolle (IMAP, POP3, ManageSieve und das Senden aus Mail-Apps) werden für alle in {tenant} ausgeschaltet.",
|
||||||
|
"Phone and desktop mail apps will stop receiving and sending mail. That's iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.": "Mail-Apps auf Smartphones und Computern können keine Mails mehr empfangen und senden. Das betrifft Mail auf iPhone und iPad, die Gmail- und Outlook-Apps, Outlook, Thunderbird und Apple Mail. Dort erscheinen Anmeldefehler.",
|
||||||
|
"Filters managed from a mail app (ManageSieve) will stop working. Filters set in {app} keep working.": "Filter, die über eine Mail-App verwaltet werden (ManageSieve), funktionieren nicht mehr. In {app} eingerichtete Filter funktionieren weiterhin.",
|
||||||
|
"Incoming mail is not affected. Calendars and contacts are not affected.": "Eingehende Mails sind nicht betroffen. Kalender und Kontakte sind nicht betroffen.",
|
||||||
|
"People keep full access through {app}, which can be installed as an app on phones and computers.": "Alle behalten vollen Zugriff über {app}, das sich auf Smartphones und Computern als App installieren lässt.",
|
||||||
|
"Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and {app} (JMAP) are not affected and cannot be turned off here.": "Das Senden aus Mail-Apps (SMTP-Submission) funktioniert nicht mehr, die Ports bleiben aber offen: Mail-Apps erfahren, dass sie sich nicht anmelden können. Eingehende Mails (SMTP) und {app} (JMAP) sind nicht betroffen und lassen sich hier nicht ausschalten.",
|
||||||
|
"You can turn legacy protocols back on at any time.": "Sie können ältere Mailprotokolle jederzeit wieder einschalten.",
|
||||||
|
"To confirm, type {phrase}": "Zur Bestätigung {phrase} eingeben",
|
||||||
|
"Turn off legacy protocols": "Ältere Mailprotokolle ausschalten",
|
||||||
|
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Ältere Mailprotokolle sind für Ihre Organisation ausgeschaltet. Nur {app} und JMAP-Apps können sich anmelden.",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"{n} accounts used a legacy mail app in the last 30 days.": {one: "{n} Konto hat in den letzten 30 Tagen eine Mail-App mit älteren Protokollen verwendet.", other: "{n} Konten haben in den letzten 30 Tagen eine Mail-App mit älteren Protokollen verwendet."},
|
||||||
// ── Administration: domains ────────────────────────────────────
|
// ── Administration: domains ────────────────────────────────────
|
||||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} Konto verwendet diese Domain. Verschieben oder löschen Sie es zuerst.", other: "{n} Konten verwenden diese Domain. Verschieben oder löschen Sie sie zuerst." },
|
"{n} accounts use this domain. Move or delete them first.": { one: "{n} Konto verwendet diese Domain. Verschieben oder löschen Sie es zuerst.", other: "{n} Konten verwenden diese Domain. Verschieben oder löschen Sie sie zuerst." },
|
||||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Der Server nimmt keine E-Mails mehr für diese Domain an, und ihr {n} DKIM-Schlüssel wird gelöscht. Dies kann nicht rückgängig gemacht werden.", other: "Der Server nimmt keine E-Mails mehr für diese Domain an, und ihre {n} DKIM-Schlüssel werden gelöscht. Dies kann nicht rückgängig gemacht werden." },
|
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Der Server nimmt keine E-Mails mehr für diese Domain an, und ihr {n} DKIM-Schlüssel wird gelöscht. Dies kann nicht rückgängig gemacht werden.", other: "Der Server nimmt keine E-Mails mehr für diese Domain an, und ihre {n} DKIM-Schlüssel werden gelöscht. Dies kann nicht rückgängig gemacht werden." },
|
||||||
|
|||||||
+68
-31
@@ -139,8 +139,8 @@ export const catalog: Catalog = {
|
|||||||
"The numbers your role can see, as the server reports them.": "Las cifras que su rol puede ver, tal como las informa el servidor.",
|
"The numbers your role can see, as the server reports them.": "Las cifras que su rol puede ver, tal como las informa el servidor.",
|
||||||
"Nothing to show": "Nada que mostrar",
|
"Nothing to show": "Nada que mostrar",
|
||||||
"Could not be loaded": "No se pudo cargar",
|
"Could not be loaded": "No se pudo cargar",
|
||||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Las métricas detalladas, la cola de entrega, los registros y la configuración del servidor están en la administración de Stalwart.",
|
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Las métricas detalladas, la cola de entrega, los registros y los ajustes del servidor están en INBUXA Admin.",
|
||||||
"Open Stalwart admin": "Abrir la administración de Stalwart",
|
"Open INBUXA Admin": "Abrir INBUXA Admin",
|
||||||
"Default group role": "Rol de grupo predeterminado",
|
"Default group role": "Rol de grupo predeterminado",
|
||||||
"A group needs an address.": "Un grupo necesita una dirección.",
|
"A group needs an address.": "Un grupo necesita una dirección.",
|
||||||
"New group": "Nuevo grupo",
|
"New group": "Nuevo grupo",
|
||||||
@@ -209,10 +209,10 @@ export const catalog: Catalog = {
|
|||||||
"New role": "Nuevo rol",
|
"New role": "Nuevo rol",
|
||||||
"This role carries permissions yours doesn't, so you can view it but not change it.": "Este rol tiene permisos que el suyo no tiene, así que puede verlo pero no modificarlo.",
|
"This role carries permissions yours doesn't, so you can view it but not change it.": "Este rol tiene permisos que el suyo no tiene, así que puede verlo pero no modificarlo.",
|
||||||
"Your role lets you view roles but not change them.": "Su rol le permite ver los roles, pero no modificarlos.",
|
"Your role lets you view roles but not change them.": "Su rol le permite ver los roles, pero no modificarlos.",
|
||||||
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart asigna este rol de forma predeterminada a {kinds}. Un cambio aquí afecta a todos los que lo tienen de esa forma.",
|
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "El servidor de correo asigna este rol de forma predeterminada a {kinds}. Un cambio aquí afecta a todos los que lo tienen así.",
|
||||||
"Builds on": "Se basa en",
|
"Builds on": "Se basa en",
|
||||||
"Permissions": "Permisos",
|
"Permissions": "Permisos",
|
||||||
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart asigna este rol de forma predeterminada, así que no se puede eliminar. Cambie primero los valores predeterminados en la administración de Stalwart.",
|
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "El servidor de correo asigna este rol de forma predeterminada, así que no se puede eliminar. Cambie primero los valores predeterminados en INBUXA Admin.",
|
||||||
"This role carries permissions yours doesn't.": "Este rol tiene permisos que el suyo no tiene.",
|
"This role carries permissions yours doesn't.": "Este rol tiene permisos que el suyo no tiene.",
|
||||||
"Create role": "Crear rol",
|
"Create role": "Crear rol",
|
||||||
"builds on this one": "se basa en este",
|
"builds on this one": "se basa en este",
|
||||||
@@ -239,7 +239,7 @@ export const catalog: Catalog = {
|
|||||||
"Delete role": "Eliminar rol",
|
"Delete role": "Eliminar rol",
|
||||||
"It can't be undone.": "No se puede deshacer.",
|
"It can't be undone.": "No se puede deshacer.",
|
||||||
"Type {name} to confirm": "Escriba {name} para confirmar",
|
"Type {name} to confirm": "Escriba {name} para confirmar",
|
||||||
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "No se pudo cargar la lista de permisos de Stalwart, así que aquí no se pueden cambiar los permisos. ({reason})",
|
"The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "No se pudo cargar la lista de permisos del servidor de correo, así que aquí no se pueden cambiar los permisos. ({reason})",
|
||||||
"Named sets of permissions, given to accounts, groups and tenants.": "Conjuntos de permisos con nombre, asignados a cuentas, grupos e inquilinos.",
|
"Named sets of permissions, given to accounts, groups and tenants.": "Conjuntos de permisos con nombre, asignados a cuentas, grupos e inquilinos.",
|
||||||
"Search roles": "Buscar roles",
|
"Search roles": "Buscar roles",
|
||||||
"No roles match": "Ningún rol coincide",
|
"No roles match": "Ningún rol coincide",
|
||||||
@@ -262,11 +262,11 @@ export const catalog: Catalog = {
|
|||||||
"{used} used": "{used} usados",
|
"{used} used": "{used} usados",
|
||||||
"Your role lets you view tenants but not change them.": "Su rol le permite ver los inquilinos, pero no modificarlos.",
|
"Your role lets you view tenants but not change them.": "Su rol le permite ver los inquilinos, pero no modificarlos.",
|
||||||
"Logo": "Logotipo",
|
"Logo": "Logotipo",
|
||||||
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Una dirección https o una URL data de una imagen. Stalwart la muestra a las personas del inquilino donde muestra un logotipo.",
|
"An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Una dirección https o una URL de datos de una imagen. El servidor de correo la muestra a las personas del inquilino donde muestra un logotipo.",
|
||||||
"What it holds": "Contenido",
|
"What it holds": "Contenido",
|
||||||
"{n} of {limit}": "{n} de {limit}",
|
"{n} of {limit}": "{n} de {limit}",
|
||||||
"Limits": "Límites",
|
"Limits": "Límites",
|
||||||
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart se niega a crear más de lo que permite un límite. Un campo vacío significa sin límite.",
|
"The mail server refuses to create more than a limit allows. An empty field is no limit.": "El servidor de correo se niega a crear más de lo que permite un límite. Un campo vacío significa sin límite.",
|
||||||
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Lo máximo que se puede permitir a cualquiera en este inquilino: sus propios roles se reducen a lo que estos conceden. Solo se ofrecen los roles cuyos permisos usted mismo tiene.",
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Lo máximo que se puede permitir a cualquiera en este inquilino: sus propios roles se reducen a lo que estos conceden. Solo se ofrecen los roles cuyos permisos usted mismo tiene.",
|
||||||
"Checking what is still in this tenant…": "Comprobando lo que aún hay en este inquilino…",
|
"Checking what is still in this tenant…": "Comprobando lo que aún hay en este inquilino…",
|
||||||
"It still holds accounts, domains or other things. Move them out first.": "Aún tiene cuentas, dominios u otros elementos. Muévalos primero.",
|
"It still holds accounts, domains or other things. Move them out first.": "Aún tiene cuentas, dominios u otros elementos. Muévalos primero.",
|
||||||
@@ -282,7 +282,6 @@ export const catalog: Catalog = {
|
|||||||
"Still holds {things}. Move them out first.": "Aún tiene {things}. Muévalos primero.",
|
"Still holds {things}. Move them out first.": "Aún tiene {things}. Muévalos primero.",
|
||||||
"Delete tenant": "Eliminar inquilino",
|
"Delete tenant": "Eliminar inquilino",
|
||||||
"Separate organizations on one server, each with its own people, domains and limits.": "Organizaciones separadas en un mismo servidor, cada una con sus propias personas, dominios y límites.",
|
"Separate organizations on one server, each with its own people, domains and limits.": "Organizaciones separadas en un mismo servidor, cada una con sus propias personas, dominios y límites.",
|
||||||
"Tenants are a Stalwart Enterprise feature.": "Los inquilinos son una función de Stalwart Enterprise.",
|
|
||||||
"Search tenants": "Buscar inquilinos",
|
"Search tenants": "Buscar inquilinos",
|
||||||
"No tenants match": "Ningún inquilino coincide",
|
"No tenants match": "Ningún inquilino coincide",
|
||||||
"No tenants yet": "Aún no hay inquilinos",
|
"No tenants yet": "Aún no hay inquilinos",
|
||||||
@@ -510,7 +509,7 @@ export const catalog: Catalog = {
|
|||||||
"Waiting on the server — goes out {when}.": "Esperando en el servidor: se enviará {when}.",
|
"Waiting on the server — goes out {when}.": "Esperando en el servidor: se enviará {when}.",
|
||||||
"Scheduled — click to clear the schedule": "Programado: haga clic para anular la programación",
|
"Scheduled — click to clear the schedule": "Programado: haga clic para anular la programación",
|
||||||
"Nothing scheduled": "Nada programado",
|
"Nothing scheduled": "Nada programado",
|
||||||
"The message waits on the server, so it goes out whether or not ihasmail is open.": "El mensaje espera en el servidor, así que se envía tanto si ihasmail está abierto como si no.",
|
"The message waits on the server, so it goes out whether or not {app} is open.": "El mensaje espera en el servidor, así que se envía tanto si {app} está abierto como si no.",
|
||||||
"This server holds a message for up to {span}.": "Este servidor retiene un mensaje hasta {span}.",
|
"This server holds a message for up to {span}.": "Este servidor retiene un mensaje hasta {span}.",
|
||||||
"Date and time to send": "Fecha y hora de envío",
|
"Date and time to send": "Fecha y hora de envío",
|
||||||
"Undo send window": "Margen para deshacer el envío",
|
"Undo send window": "Margen para deshacer el envío",
|
||||||
@@ -732,7 +731,7 @@ export const catalog: Catalog = {
|
|||||||
"Sections": "Secciones",
|
"Sections": "Secciones",
|
||||||
"General": "General",
|
"General": "General",
|
||||||
"Appearance": "Apariencia",
|
"Appearance": "Apariencia",
|
||||||
"Make ihasmail yours.": "Haga suyo ihasmail.",
|
"Make {app} yours.": "Haga suyo {app}.",
|
||||||
"Reading": "Lectura",
|
"Reading": "Lectura",
|
||||||
"Reading pane": "Panel de lectura",
|
"Reading pane": "Panel de lectura",
|
||||||
"Right of the list": "A la derecha de la lista",
|
"Right of the list": "A la derecha de la lista",
|
||||||
@@ -749,6 +748,7 @@ export const catalog: Catalog = {
|
|||||||
"Attachment reminder": "Aviso de adjunto",
|
"Attachment reminder": "Aviso de adjunto",
|
||||||
"Warn when the message mentions an attachment but none is attached.": "Avisar cuando el mensaje menciona un adjunto pero no hay ninguno.",
|
"Warn when the message mentions an attachment but none is attached.": "Avisar cuando el mensaje menciona un adjunto pero no hay ninguno.",
|
||||||
"Spell check while typing": "Corrección ortográfica al escribir",
|
"Spell check while typing": "Corrección ortográfica al escribir",
|
||||||
|
"Open the composer full screen": "Redactar mensajes a pantalla completa",
|
||||||
"Confirm before deleting": "Confirmar antes de eliminar",
|
"Confirm before deleting": "Confirmar antes de eliminar",
|
||||||
"Show message snippets": "Mostrar un fragmento de los mensajes",
|
"Show message snippets": "Mostrar un fragmento de los mensajes",
|
||||||
"Preview the first line of each message in the list.": "Mostrar la primera línea de cada mensaje en la lista.",
|
"Preview the first line of each message in the list.": "Mostrar la primera línea de cada mensaje en la lista.",
|
||||||
@@ -828,7 +828,8 @@ export const catalog: Catalog = {
|
|||||||
"Reset to defaults": "Restablecer los valores predeterminados",
|
"Reset to defaults": "Restablecer los valores predeterminados",
|
||||||
"Default mail app": "Aplicación de correo predeterminada",
|
"Default mail app": "Aplicación de correo predeterminada",
|
||||||
"Documentation": "Documentación",
|
"Documentation": "Documentación",
|
||||||
"About ihasmail": "Acerca de ihasmail",
|
"About {app}": "Acerca de {app}",
|
||||||
|
"Built on {project}": "Basado en {project}",
|
||||||
"About": "Acerca de",
|
"About": "Acerca de",
|
||||||
"Server": "Servidor",
|
"Server": "Servidor",
|
||||||
"Server capabilities": "Funciones del servidor",
|
"Server capabilities": "Funciones del servidor",
|
||||||
@@ -929,6 +930,7 @@ export const catalog: Catalog = {
|
|||||||
"Turn off two-factor authentication": "Desactivar la autenticación en dos pasos",
|
"Turn off two-factor authentication": "Desactivar la autenticación en dos pasos",
|
||||||
"Your password alone will be enough to sign in again.": "Su contraseña por sí sola bastará para volver a iniciar sesión.",
|
"Your password alone will be enough to sign in again.": "Su contraseña por sí sola bastará para volver a iniciar sesión.",
|
||||||
"App passwords": "Contraseñas de aplicación",
|
"App passwords": "Contraseñas de aplicación",
|
||||||
|
"Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "Su organización solo permite {app} y aplicaciones JMAP, así que las aplicaciones de correo del teléfono y del ordenador no pueden conectarse a esta cuenta.",
|
||||||
"New app password for": "Contraseña de aplicación nueva para",
|
"New app password for": "Contraseña de aplicación nueva para",
|
||||||
"Your new app password": "Su contraseña de aplicación nueva",
|
"Your new app password": "Su contraseña de aplicación nueva",
|
||||||
"Secret": "Secreto",
|
"Secret": "Secreto",
|
||||||
@@ -956,8 +958,8 @@ export const catalog: Catalog = {
|
|||||||
"Notifications": "Notificaciones",
|
"Notifications": "Notificaciones",
|
||||||
"Notifications are blocked in your browser settings.": "Las notificaciones están bloqueadas en la configuración de su navegador.",
|
"Notifications are blocked in your browser settings.": "Las notificaciones están bloqueadas en la configuración de su navegador.",
|
||||||
"Not supported in this browser.": "No compatible con este navegador.",
|
"Not supported in this browser.": "No compatible con este navegador.",
|
||||||
"Desktop notifications while ihasmail is open": "Notificaciones del sistema mientras ihasmail está abierto",
|
"Desktop notifications while {app} is open": "Notificaciones del sistema mientras {app} está abierto",
|
||||||
"Notify me even when ihasmail is closed": "Avisarme incluso cuando ihasmail esté cerrado",
|
"Notify me even when {app} is closed": "Avisarme incluso cuando {app} esté cerrado",
|
||||||
"Play a sound for new mail": "Reproducir un sonido al llegar correo",
|
"Play a sound for new mail": "Reproducir un sonido al llegar correo",
|
||||||
"Test notification": "Probar la notificación",
|
"Test notification": "Probar la notificación",
|
||||||
"Background notifications are on": "Las notificaciones en segundo plano están activadas",
|
"Background notifications are on": "Las notificaciones en segundo plano están activadas",
|
||||||
@@ -1126,7 +1128,7 @@ export const catalog: Catalog = {
|
|||||||
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Una identidad nueva debe usar una dirección desde la que esta cuenta tenga permiso para enviar (alias configurados en el servidor).",
|
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Una identidad nueva debe usar una dirección desde la que esta cuenta tenga permiso para enviar (alias configurados en el servidor).",
|
||||||
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "No se ofrece al redactar. La dirección sigue recibiendo correo, y puede volver a enviar desde ella mostrándola de nuevo.",
|
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "No se ofrece al redactar. La dirección sigue recibiendo correo, y puede volver a enviar desde ella mostrándola de nuevo.",
|
||||||
"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.": "Cada identidad es una dirección de envío con su propio nombre, dirección de respuesta y firma. La identidad predeterminada se preselecciona al redactar; defina una dirección de respuesta cuando las respuestas deban llegar a un sitio distinto del remitente.",
|
"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.": "Cada identidad es una dirección de envío con su propio nombre, dirección de respuesta y firma. La identidad predeterminada se preselecciona al redactar; defina una dirección de respuesta cuando las respuestas deban llegar a un sitio distinto del remitente.",
|
||||||
"This signature is larger than the server's {limit}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.": "Esta firma supera el límite de {limit} bytes del servidor. ihasmail conservará la versión completa en sus Archivos y guardará una versión corta de texto en el servidor: los demás clientes verán la versión en texto sin formato.",
|
"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.": "Esta firma supera el l\u00edmite de {limit} bytes del servidor. {app} conservar\u00e1 la versi\u00f3n completa en sus Archivos y guardar\u00e1 una versi\u00f3n corta de texto en el servidor: los dem\u00e1s clientes ver\u00e1n la versi\u00f3n en texto sin formato.",
|
||||||
"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.": "Categorías al estilo de Outlook que puede asignar a los eventos desde el menú contextual o el editor de eventos. El nombre de la categoría se guarda en el evento, así que se sincroniza con otros clientes.",
|
"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.": "Categorías al estilo de Outlook que puede asignar a los eventos desde el menú contextual o el editor de eventos. El nombre de la categoría se guarda en el evento, así que se sincroniza con otros clientes.",
|
||||||
"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.": "El correo en texto sin formato ya sigue el tema. Con esta opción, el correo HTML sin colores propios también lo hace, en lugar de mostrarse sobre un fondo blanco. Los mensajes con estilo propio se dejan exactamente como los diseñó el remitente.",
|
"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.": "El correo en texto sin formato ya sigue el tema. Con esta opción, el correo HTML sin colores propios también lo hace, en lugar de mostrarse sobre un fondo blanco. Los mensajes con estilo propio se dejan exactamente como los diseñó el remitente.",
|
||||||
"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.": "Esto es independiente de {setting} en General, que determina cómo se escriben las fechas, horas y números. Puede leer una interfaz en inglés con fechas en español, o al revés.",
|
"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.": "Esto es independiente de {setting} en General, que determina cómo se escriben las fechas, horas y números. Puede leer una interfaz en inglés con fechas en español, o al revés.",
|
||||||
@@ -1134,40 +1136,40 @@ export const catalog: Catalog = {
|
|||||||
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Esta pantalla no es táctil, así que nada de esto cambia su comportamiento. Su teléfono o tableta tomará estos ajustes.",
|
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Esta pantalla no es táctil, así que nada de esto cambia su comportamiento. Su teléfono o tableta tomará estos ajustes.",
|
||||||
"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.": "Mantener pulsado un mensaje lo selecciona, y mantener pulsada una carpeta abre su menú. Tire hacia abajo de la parte superior de la lista para comprobar si hay correo nuevo.",
|
"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.": "Mantener pulsado un mensaje lo selecciona, y mantener pulsada una carpeta abre su menú. Tire hacia abajo de la parte superior de la lista para comprobar si hay correo nuevo.",
|
||||||
"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.": "Una confirmación le dice a quien la pidió que esta dirección está activa y cuándo se leyó el mensaje, y el remitente elige adónde va; por eso no hay opción automática. Al correo masivo, las listas de correo y todo lo marcado como enviado automáticamente nunca se les ofrece una.",
|
"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.": "Una confirmación le dice a quien la pidió que esta dirección está activa y cuándo se leyó el mensaje, y el remitente elige adónde va; por eso no hay opción automática. Al correo masivo, las listas de correo y todo lo marcado como enviado automáticamente nunca se les ofrece una.",
|
||||||
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.": "Este navegador no puede registrar aplicaciones para los enlaces {scheme}. Safari, en particular, no dispone de esa interfaz: aun así puede establecer ihasmail como predeterminado desde su sistema operativo si lo instala como aplicación.",
|
"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.": "Este navegador no puede registrar aplicaciones para los enlaces {scheme}. Safari, en particular, no dispone de esa interfaz: aun as\u00ed puede establecer {app} como predeterminado desde su sistema operativo si lo instala como aplicaci\u00f3n.",
|
||||||
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Registrarse para los enlaces {scheme} requiere una conexión segura (HTTPS).",
|
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Registrarse para los enlaces {scheme} requiere una conexión segura (HTTPS).",
|
||||||
"Open {scheme} links — in web pages, documents and other apps — in ihasmail 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 › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).": "Abrir los enlaces {scheme} —en páginas web, documentos y otras aplicaciones— con ihasmail en lugar de con un cliente de correo local. Su navegador le pedirá confirmación, y podrá cambiarlo más tarde en su propia configuración (Chrome: Configuración › Privacidad y seguridad › Configuración de sitios › Controladores de protocolo; Firefox: Configuración › General › Aplicaciones).",
|
"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).": "Abrir los enlaces {scheme} \u2014en p\u00e1ginas web, documentos y otras aplicaciones\u2014 con {app} en lugar de con un cliente de correo local. Su navegador le pedir\u00e1 confirmaci\u00f3n, y podr\u00e1 cambiarlo m\u00e1s tarde en su propia configuraci\u00f3n (Chrome: Configuraci\u00f3n \u203a Privacidad y seguridad \u203a Configuraci\u00f3n de sitios \u203a Controladores de protocolo; Firefox: Configuraci\u00f3n \u203a General \u203a Aplicaciones).",
|
||||||
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Solicitado en este navegador. Que haya surtido efecto depende de él: revise su configuración si los enlaces de correo siguen abriéndose en otro sitio.",
|
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Solicitado en este navegador. Que haya surtido efecto depende de él: revise su configuración si los enlaces de correo siguen abriéndose en otro sitio.",
|
||||||
"For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.": "Para un valor predeterminado en todo el sistema, instale antes ihasmail como aplicación (en Chrome: el icono de instalación de la barra de direcciones). Su sistema operativo podrá entonces ofrecer ihasmail directamente allí donde pregunte qué aplicación de correo usar.",
|
"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.": "Para un valor predeterminado en todo el sistema, instale antes {app} como aplicación (en Chrome: el icono de instalación de la barra de direcciones). Su sistema operativo podrá entonces ofrecer {app} directamente allí donde pregunte qué aplicación de correo usar.",
|
||||||
"Needs a browser with the Push API and a mail server that publishes a push key.": "Requiere un navegador con la API Push y un servidor de correo que publique una clave push.",
|
"Needs a browser with the Push API and a mail server that publishes a push key.": "Requiere un navegador con la API Push y un servidor de correo que publique una clave push.",
|
||||||
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Su servidor de correo las entrega directamente a su navegador, así que llegan sin ninguna pestaña de ihasmail abierta, con el remitente y el asunto. Aun así, su navegador debe estar en marcha: si lo cierra por completo, las notificaciones esperan y llegan cuando vuelva a abrirlo.",
|
"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.": "Su servidor de correo las entrega directamente a su navegador, as\u00ed que llegan sin ninguna pesta\u00f1a de {app} abierta, con el remitente y el asunto. Aun as\u00ed, su navegador debe estar en marcha: si lo cierra por completo, las notificaciones esperan y llegan cuando vuelva a abrirlo.",
|
||||||
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Su servidor de correo puede despertar a este navegador, pero no incluirá el remitente ni el asunto. Aun así, su navegador debe estar en marcha.",
|
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Su servidor de correo puede despertar a este navegador, pero no incluirá el remitente ni el asunto. Aun así, su navegador debe estar en marcha.",
|
||||||
"This is what a new-mail notification looks like.": "Así es una notificación de correo nuevo.",
|
"This is what a new-mail notification looks like.": "Así es una notificación de correo nuevo.",
|
||||||
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Ha iniciado sesión como {user}. Su contraseña nunca se guarda en el navegador; el servidor la conserva cifrada por sesión para comunicarse con Stalwart.",
|
"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.": "Ha iniciado sesión como {user}. Su contraseña nunca se guarda en el navegador; el servidor la conserva cifrada por sesión para comunicarse con el servidor de correo.",
|
||||||
"App passwords are managed by your mail administrator.": "Las contraseñas de aplicación las gestiona su administrador de correo.",
|
"App passwords are managed by your mail administrator.": "Las contraseñas de aplicación las gestiona su administrador de correo.",
|
||||||
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Cambiar la contraseña cierra sus demás sesiones de webmail. Las contraseñas de aplicación siguen funcionando.",
|
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Cambiar la contraseña cierra sus demás sesiones de webmail. Las contraseñas de aplicación siguen funcionando.",
|
||||||
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Esta cuenta tiene activada la autenticación en dos pasos. ihasmail todavía no puede iniciar su sesión con un código, así que iniciar sesión en otro dispositivo requiere una contraseña de aplicación; o puede desactivar aquí la autenticación en dos pasos.",
|
"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.": "Esta cuenta tiene activada la autenticaci\u00f3n en dos pasos. {app} todav\u00eda no puede iniciar su sesi\u00f3n con un c\u00f3digo, as\u00ed que iniciar sesi\u00f3n en otro dispositivo requiere una contrase\u00f1a de aplicaci\u00f3n; o puede desactivar aqu\u00ed la autenticaci\u00f3n en dos pasos.",
|
||||||
"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.": "Una contraseña aparte para una aplicación de correo o un dispositivo, que puede revocar por separado. Las contraseñas de aplicación se saltan los códigos de dos pasos, así que siguen funcionando en aplicaciones que no pueden pedir uno.",
|
"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.": "Una contraseña aparte para una aplicación de correo o un dispositivo, que puede revocar por separado. Las contraseñas de aplicación se saltan los códigos de dos pasos, así que siguen funcionando en aplicaciones que no pueden pedir uno.",
|
||||||
"Copy it into {name} now — it isn't shown again.": "Cópiela ahora en {name}: no se volverá a mostrar.",
|
"Copy it into {name} now — it isn't shown again.": "Cópiela ahora en {name}: no se volverá a mostrar.",
|
||||||
"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.": "No se han encontrado más usuarios en el directorio, así que no se puede añadir a nadie nuevo. Lo que ya está compartido aparece abajo y todavía se puede quitar.",
|
"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.": "No se han encontrado más usuarios en el directorio, así que no se puede añadir a nadie nuevo. Lo que ya está compartido aparece abajo y todavía se puede quitar.",
|
||||||
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart no comunica su número de versión a los clientes de correo, así que ihasmail indica la edición cuando el servidor la proporciona. ihasmail requiere la versión 0.16 o posterior, y el inicio de sesión rechaza cualquier versión anterior.",
|
"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 no comunica su n\u00famero de versi\u00f3n a los clientes de correo, as\u00ed que {app} indica la edici\u00f3n cuando el servidor la proporciona. {app} requiere la versi\u00f3n 0.16 o posterior, y el inicio de sesi\u00f3n rechaza cualquier versi\u00f3n anterior.",
|
||||||
"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.": "{damage}, así que las reglas que contiene no se pueden mostrar ni editar: guardar lo que sí llegó sobrescribiría el resto. Recargue la página para intentarlo de nuevo. Sus reglas siguen en el servidor; aquí no se ha cambiado nada.",
|
"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.": "{damage}, así que las reglas que contiene no se pueden mostrar ni editar: guardar lo que sí llegó sobrescribiría el resto. Recargue la página para intentarlo de nuevo. Sus reglas siguen en el servidor; aquí no se ha cambiado nada.",
|
||||||
"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).": "El editor visual de reglas solo gestiona los scripts que él mismo ha creado. Puede editar el script en la pestaña {tab}, o empezar de nuevo con reglas (el script existente se conservará pero quedará desactivado).",
|
"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).": "El editor visual de reglas solo gestiona los scripts que él mismo ha creado. Puede editar el script en la pestaña {tab}, o empezar de nuevo con reglas (el script existente se conservará pero quedará desactivado).",
|
||||||
"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.": "Su script de filtrado {damage}, así que solo ha llegado en parte. Añadir una regla escribiría esa parte sobre el conjunto. Recargue la página e inténtelo de nuevo.",
|
"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.": "Su script de filtrado {damage}, así que solo ha llegado en parte. Añadir una regla escribiría esa parte sobre el conjunto. Recargue la página e inténtelo de nuevo.",
|
||||||
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Su script de filtrado no se ha podido leer ahora mismo, así que añadir una regla podría sobrescribirlo. Recargue la página e inténtelo de nuevo.",
|
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Su script de filtrado no se ha podido leer ahora mismo, así que añadir una regla podría sobrescribirlo. Recargue la página e inténtelo de nuevo.",
|
||||||
"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.": "Su script Sieve activo se escribió a mano, así que no se pueden añadir reglas automáticamente. Abra {where} para editar el script o cambiar a reglas gestionadas.",
|
"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.": "Su script Sieve activo se escribió a mano, así que no se pueden añadir reglas automáticamente. Abra {where} para editar el script o cambiar a reglas gestionadas.",
|
||||||
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Aquí solo aparecen los idiomas a los que se ha traducido ihasmail, así que la lista crece a medida que llegan las traducciones y no antes: un idioma ofrecido sin textos detrás haría que la página afirmara estar en un idioma que no es el suyo.",
|
"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.": "Aqu\u00ed solo aparecen los idiomas a los que se ha traducido {app}, as\u00ed que la lista crece a medida que llegan las traducciones y no antes: un idioma ofrecido sin textos detr\u00e1s har\u00eda que la p\u00e1gina afirmara estar en un idioma que no es el suyo.",
|
||||||
"tell us about it": "cuéntenoslo",
|
"tell us about it": "cuéntenoslo",
|
||||||
"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}.": "Esta traducción la ha generado una IA y no la ha revisado ninguna persona de habla nativa, así que está marcada como Beta hasta que alguien la dé por buena. Todo lo que suene mal merece un aviso: {report}.",
|
"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}.": "Esta traducción la ha generado una IA y no la ha revisado ninguna persona de habla nativa, así que está marcada como Beta hasta que alguien la dé por buena. Todo lo que suene mal merece un aviso: {report}.",
|
||||||
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "La versión de ihasmail es la fecha del commit a partir del cual se compiló, seguida de su procedencia: {example} se compiló a partir de un commit del 30 de agosto de 2026 que llegó mediante la pull request 129. Un commit que no llegó por esa vía lleva en su lugar su SHA corto: {sha}. La versión no dice nada sobre Stalwart a propósito; lo que esta compilación necesita del servidor está en la línea de arriba.",
|
"{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.": "La versi\u00f3n de {app} es la fecha del commit a partir del cual se compil\u00f3, seguida de su procedencia: {example} se compil\u00f3 a partir de un commit del 30 de agosto de 2026 que lleg\u00f3 mediante la pull request 129. Un commit que no lleg\u00f3 por esa v\u00eda lleva en su lugar su SHA corto: {sha}. La versi\u00f3n no dice nada sobre Stalwart a prop\u00f3sito; lo que esta compilaci\u00f3n necesita del servidor est\u00e1 en la l\u00ednea de arriba.",
|
||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Mensaje nuevo",
|
"New message": "Mensaje nuevo",
|
||||||
"Start a new message with what was shared?": "¿Empezar un mensaje nuevo con lo que se ha compartido?",
|
"Start a new message with what was shared?": "¿Empezar un mensaje nuevo con lo que se ha compartido?",
|
||||||
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Se ha compartido algo con ihasmail. No se envía nada hasta que elija Enviar. Si no acaba de compartirlo usted, descártelo.",
|
"Something was shared with {app}. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Se ha compartido algo con {app}. No se envía nada hasta que elija Enviar. Si no acaba de compartirlo usted, descártelo.",
|
||||||
"Start a message": "Empezar mensaje",
|
"Start a message": "Empezar mensaje",
|
||||||
"New mail": "Correo nuevo",
|
"New mail": "Correo nuevo",
|
||||||
"Could not do that — open ihasmail and try again": "No se pudo hacer eso: abra ihasmail e inténtelo de nuevo",
|
"Could not do that \u2014 open {app} and try again": "No se pudo hacer eso: abra {app} e int\u00e9ntelo de nuevo",
|
||||||
"Sending…": "Enviando…",
|
"Sending…": "Enviando…",
|
||||||
"Saving…": "Guardando…",
|
"Saving…": "Guardando…",
|
||||||
"Error": "Error",
|
"Error": "Error",
|
||||||
@@ -1215,6 +1217,20 @@ export const catalog: Catalog = {
|
|||||||
"Could not save filters: {error}": "No se pudieron guardar los filtros: {error}",
|
"Could not save filters: {error}": "No se pudieron guardar los filtros: {error}",
|
||||||
"Could not send the receipt: {error}": "No se pudo enviar la confirmación de lectura: {error}",
|
"Could not send the receipt: {error}": "No se pudo enviar la confirmación de lectura: {error}",
|
||||||
"Could not sign in.": "No se pudo iniciar sesión.",
|
"Could not sign in.": "No se pudo iniciar sesión.",
|
||||||
|
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "Ha iniciado sesión como {user}. Este webmail nunca ve su contraseña: guarda un token de inicio de sesión de su servidor de correo, cifrado por sesión.",
|
||||||
|
"Mail server": "Servidor de correo",
|
||||||
|
"You'll enter your password on your mail server's sign-in page.": "Introducirá su contraseña en la página de inicio de sesión de su servidor de correo.",
|
||||||
|
"You'll sign in on your mail server's own page.": "Iniciará sesión en la propia página de su servidor de correo.",
|
||||||
|
"This sign-in didn't start in this browser. Try again.": "Este inicio de sesión no empezó en este navegador. Inténtelo de nuevo.",
|
||||||
|
"The sign-in took too long. Try again.": "El inicio de sesión tardó demasiado. Inténtelo de nuevo.",
|
||||||
|
"Sign-in was cancelled.": "Se canceló el inicio de sesión.",
|
||||||
|
"The mail server didn't accept the sign-in. Try again.": "El servidor de correo no aceptó el inicio de sesión. Inténtelo de nuevo.",
|
||||||
|
"That account is on a different mail server than the address you entered. Sign in with that address.": "Esa cuenta está en un servidor de correo distinto del de la dirección que introdujo. Inicie sesión con esa dirección.",
|
||||||
|
"This mail server isn't supported.": "Este servidor de correo no es compatible.",
|
||||||
|
"Couldn't reach the mail server. Try again in a moment.": "No se pudo conectar con el servidor de correo. Inténtelo de nuevo en un momento.",
|
||||||
|
"Your password was changed. Sign in with the new one.": "Se cambió su contraseña. Inicie sesión con la nueva.",
|
||||||
|
"Your sign-in ended. Sign in again.": "Su sesión terminó. Inicie sesión de nuevo.",
|
||||||
|
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Cambiar la contraseña cierra todas sus sesiones, también esta, y vuelve a iniciar sesión con la nueva. Las contraseñas de aplicación siguen funcionando.",
|
||||||
"Could not store image: {error}": "No se pudo guardar la imagen: {error}",
|
"Could not store image: {error}": "No se pudo guardar la imagen: {error}",
|
||||||
"Could not update labels: {error}": "No se pudieron actualizar las etiquetas: {error}",
|
"Could not update labels: {error}": "No se pudieron actualizar las etiquetas: {error}",
|
||||||
"Could not update the Scheduled folder: {error}": "No se pudo actualizar la carpeta Programados: {error}",
|
"Could not update the Scheduled folder: {error}": "No se pudo actualizar la carpeta Programados: {error}",
|
||||||
@@ -1337,7 +1353,7 @@ export const catalog: Catalog = {
|
|||||||
"Undo window: {seconds}s": "Margen para deshacer: {seconds} s",
|
"Undo window: {seconds}s": "Margen para deshacer: {seconds} s",
|
||||||
"You're all caught up": "Está todo al día",
|
"You're all caught up": "Está todo al día",
|
||||||
"Your browser refused the request: {error}": "Su navegador rechazó la solicitud: {error}",
|
"Your browser refused the request: {error}": "Su navegador rechazó la solicitud: {error}",
|
||||||
"Your browser will ask whether to open mail links in ihasmail": "Su navegador le preguntará si quiere abrir los enlaces de correo en ihasmail",
|
"Your browser will ask whether to open mail links in {app}": "Su navegador le preguntará si quiere abrir los enlaces de correo en {app}",
|
||||||
"Your message mentions an attachment, but nothing is attached.": "Su mensaje menciona un archivo adjunto, pero no hay ninguno.",
|
"Your message mentions an attachment, but nothing is attached.": "Su mensaje menciona un archivo adjunto, pero no hay ninguno.",
|
||||||
"event": "evento",
|
"event": "evento",
|
||||||
"Hide password": "Ocultar la contraseña",
|
"Hide password": "Ocultar la contraseña",
|
||||||
@@ -1408,7 +1424,7 @@ export const catalog: Catalog = {
|
|||||||
"Also count these domains as inside": "Contar también estos dominios como internos",
|
"Also count these domains as inside": "Contar también estos dominios como internos",
|
||||||
"Always": "Siempre",
|
"Always": "Siempre",
|
||||||
"Always showing images from": "Mostrando siempre las imágenes de",
|
"Always showing images from": "Mostrando siempre las imágenes de",
|
||||||
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.": "Una imagen cargada desde el servidor del remitente le indica que el mensaje se abrió, cuándo y desde dónde aproximadamente. Las imágenes aprobadas las descarga el propio servidor de ihasmail y no el navegador, de modo que el remitente no se entera de nada de eso.",
|
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.": "Una imagen cargada desde el servidor del remitente le indica que el mensaje se abrió, cuándo y desde dónde aproximadamente. Las imágenes aprobadas las descarga el propio servidor de {app} y no el navegador, de modo que el remitente no se entera de nada de eso.",
|
||||||
"Applies to": "Se aplica a",
|
"Applies to": "Se aplica a",
|
||||||
"Archive and next": "Archivar y siguiente",
|
"Archive and next": "Archivar y siguiente",
|
||||||
"Archive by month": "Archivar por mes",
|
"Archive by month": "Archivar por mes",
|
||||||
@@ -1650,8 +1666,8 @@ export const catalog: Catalog = {
|
|||||||
"Fingerprint": "Huella digital",
|
"Fingerprint": "Huella digital",
|
||||||
"Hide details": "Ocultar detalles",
|
"Hide details": "Ocultar detalles",
|
||||||
"Issued by": "Emitido por",
|
"Issued by": "Emitido por",
|
||||||
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Está firmado con OpenPGP, y ihasmail no tiene forma de obtener la clave pública del remitente.",
|
"It is signed with OpenPGP, and {app} has no way to fetch the sender's public key.": "Está firmado con OpenPGP, y {app} no tiene forma de obtener la clave pública del remitente.",
|
||||||
"It uses a signature algorithm ihasmail cannot check yet.": "Usa un algoritmo de firma que ihasmail todavía no puede comprobar.",
|
"It uses a signature algorithm {app} cannot check yet.": "Usa un algoritmo de firma que {app} todavía no puede comprobar.",
|
||||||
"It was made with a certificate belonging to {name}, which does not cover this address.": "Se hizo con un certificado de {name}, que no cubre esta dirección.",
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Se hizo con un certificado de {name}, que no cubre esta dirección.",
|
||||||
"Previous fingerprint": "Huella digital anterior",
|
"Previous fingerprint": "Huella digital anterior",
|
||||||
"Signed at": "Firmado el",
|
"Signed at": "Firmado el",
|
||||||
@@ -1668,18 +1684,39 @@ export const catalog: Catalog = {
|
|||||||
"The signature is not for this sender.": "La firma no corresponde a este remitente.",
|
"The signature is not for this sender.": "La firma no corresponde a este remitente.",
|
||||||
"The signed part is missing either the message or the signature.": "A la parte firmada le falta el mensaje o la firma.",
|
"The signed part is missing either the message or the signature.": "A la parte firmada le falta el mensaje o la firma.",
|
||||||
"The signer has changed.": "El firmante ha cambiado.",
|
"The signer has changed.": "El firmante ha cambiado.",
|
||||||
"This message is signed, and ihasmail could not check the signature.": "Este mensaje está firmado, y ihasmail no ha podido comprobar la firma.",
|
"This message is signed, and {app} could not check the signature.": "Este mensaje está firmado, y {app} no ha podido comprobar la firma.",
|
||||||
"This signature does not check out.": "Esta firma no cuadra.",
|
"This signature does not check out.": "Esta firma no cuadra.",
|
||||||
"Valid until": "Válido hasta",
|
"Valid until": "Válido hasta",
|
||||||
"a different certificate": "un certificado distinto",
|
"a different certificate": "un certificado distinto",
|
||||||
"an unnamed signer": "un firmante sin nombre",
|
"an unnamed signer": "un firmante sin nombre",
|
||||||
"as claimed by the signer": "según declara el firmante",
|
"as claimed by the signer": "según declara el firmante",
|
||||||
"first seen {date}": "visto por primera vez el {date}",
|
"first seen {date}": "visto por primera vez el {date}",
|
||||||
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail le avisará si un mensaje posterior de esta dirección lo firma otra persona.",
|
"{app} will tell you if a later message from this address is signed by anybody else.": "{app} le avisará si un mensaje posterior de esta dirección lo firma otra persona.",
|
||||||
"itself, or an issuer it does not name": "sí mismo, o un emisor que no nombra",
|
"itself, or an issuer it does not name": "sí mismo, o un emisor que no nombra",
|
||||||
"no address": "ninguna dirección",
|
"no address": "ninguna dirección",
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"Legacy mail apps": "Aplicaciones de correo con protocolos heredados",
|
||||||
|
"Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.": "Desactivados para {tenant}. Solo {app} y las aplicaciones JMAP pueden iniciar sesión en sus dominios.",
|
||||||
|
"On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.": "Activados para {tenant}. Las aplicaciones de correo pueden usar IMAP, POP3 y ManageSieve en sus dominios.",
|
||||||
|
"Turn legacy protocols back on": "Volver a activar los protocolos de correo heredados",
|
||||||
|
"Turn off legacy protocols…": "Desactivar los protocolos de correo heredados…",
|
||||||
|
"No account used a legacy mail app in the last 30 days.": "Ninguna cuenta ha usado una aplicación de correo con protocolos heredados en los últimos 30 días.",
|
||||||
|
"Their mail apps will stop working the moment you turn this on:": "Sus aplicaciones de correo dejarán de funcionar en cuanto active esto:",
|
||||||
|
"Only {app} and JMAP apps will work.": "Solo funcionarán {app} y las aplicaciones JMAP.",
|
||||||
|
"Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {tenant}.": "Los protocolos de correo heredados (IMAP, POP3, ManageSieve y el envío desde aplicaciones de correo) se desactivarán para todos en {tenant}.",
|
||||||
|
"Phone and desktop mail apps will stop receiving and sending mail. That's iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.": "Las aplicaciones de correo del teléfono y del ordenador dejarán de recibir y enviar correo. Eso incluye Mail de iPhone y iPad, las aplicaciones de Gmail y Outlook, Outlook, Thunderbird y Apple Mail. Se verán errores de inicio de sesión en ellas.",
|
||||||
|
"Filters managed from a mail app (ManageSieve) will stop working. Filters set in {app} keep working.": "Los filtros gestionados desde una aplicación de correo (ManageSieve) dejarán de funcionar. Los filtros configurados en {app} siguen funcionando.",
|
||||||
|
"Incoming mail is not affected. Calendars and contacts are not affected.": "El correo entrante no se ve afectado. Los calendarios y los contactos no se ven afectados.",
|
||||||
|
"People keep full access through {app}, which can be installed as an app on phones and computers.": "Todos conservan el acceso completo a través de {app}, que se puede instalar como aplicación en teléfonos y ordenadores.",
|
||||||
|
"Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and {app} (JMAP) are not affected and cannot be turned off here.": "El envío desde aplicaciones de correo (envío SMTP) dejará de funcionar, pero sus puertos siguen abiertos: se indicará a las aplicaciones de correo que no pueden iniciar sesión. El correo entrante (SMTP) y {app} (JMAP) no se ven afectados y no se pueden desactivar aquí.",
|
||||||
|
"You can turn legacy protocols back on at any time.": "Puede volver a activar los protocolos de correo heredados en cualquier momento.",
|
||||||
|
"To confirm, type {phrase}": "Para confirmar, escriba {phrase}",
|
||||||
|
"Turn off legacy protocols": "Desactivar los protocolos de correo heredados",
|
||||||
|
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Los protocolos de correo heredados están desactivados para su organización. Solo {app} y las aplicaciones JMAP pueden iniciar sesión.",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"{n} accounts used a legacy mail app in the last 30 days.": {one: "{n} cuenta ha usado una aplicación de correo con protocolos heredados en los últimos 30 días.", other: "{n} cuentas han usado una aplicación de correo con protocolos heredados en los últimos 30 días."},
|
||||||
// ── Administration: domains ────────────────────────────────────
|
// ── Administration: domains ────────────────────────────────────
|
||||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} cuenta usa este dominio. Muévala o elimínela primero.", other: "{n} cuentas usan este dominio. Muévalas o elimínelas primero." },
|
"{n} accounts use this domain. Move or delete them first.": { one: "{n} cuenta usa este dominio. Muévala o elimínela primero.", other: "{n} cuentas usan este dominio. Muévalas o elimínelas primero." },
|
||||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "El servidor deja de aceptar correo para este dominio y se elimina su {n} clave DKIM. No se puede deshacer.", other: "El servidor deja de aceptar correo para este dominio y se eliminan sus {n} claves DKIM. No se puede deshacer." },
|
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "El servidor deja de aceptar correo para este dominio y se elimina su {n} clave DKIM. No se puede deshacer.", other: "El servidor deja de aceptar correo para este dominio y se eliminan sus {n} claves DKIM. No se puede deshacer." },
|
||||||
|
|||||||
+68
-31
@@ -144,8 +144,8 @@ export const catalog: Catalog = {
|
|||||||
"The numbers your role can see, as the server reports them.": "Les chiffres que votre rôle permet de voir, tels que le serveur les indique.",
|
"The numbers your role can see, as the server reports them.": "Les chiffres que votre rôle permet de voir, tels que le serveur les indique.",
|
||||||
"Nothing to show": "Rien à afficher",
|
"Nothing to show": "Rien à afficher",
|
||||||
"Could not be loaded": "Chargement impossible",
|
"Could not be loaded": "Chargement impossible",
|
||||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Les métriques détaillées, la file de distribution, les journaux et les réglages du serveur se trouvent dans l’administration de Stalwart.",
|
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Les métriques détaillées, la file de distribution, les journaux et les réglages du serveur se trouvent dans INBUXA Admin.",
|
||||||
"Open Stalwart admin": "Ouvrir l’administration de Stalwart",
|
"Open INBUXA Admin": "Ouvrir INBUXA Admin",
|
||||||
"Default group role": "Rôle de groupe par défaut",
|
"Default group role": "Rôle de groupe par défaut",
|
||||||
"A group needs an address.": "Un groupe a besoin d’une adresse.",
|
"A group needs an address.": "Un groupe a besoin d’une adresse.",
|
||||||
"New group": "Nouveau groupe",
|
"New group": "Nouveau groupe",
|
||||||
@@ -214,10 +214,10 @@ export const catalog: Catalog = {
|
|||||||
"New role": "Nouveau rôle",
|
"New role": "Nouveau rôle",
|
||||||
"This role carries permissions yours doesn't, so you can view it but not change it.": "Ce rôle comporte des autorisations que le vôtre n’a pas : vous pouvez le consulter, mais pas le modifier.",
|
"This role carries permissions yours doesn't, so you can view it but not change it.": "Ce rôle comporte des autorisations que le vôtre n’a pas : vous pouvez le consulter, mais pas le modifier.",
|
||||||
"Your role lets you view roles but not change them.": "Votre rôle vous permet de consulter les rôles, mais pas de les modifier.",
|
"Your role lets you view roles but not change them.": "Votre rôle vous permet de consulter les rôles, mais pas de les modifier.",
|
||||||
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart attribue ce rôle par défaut aux {kinds}. Une modification ici touche tous ceux qui l’ont de cette façon.",
|
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Le serveur de messagerie attribue ce rôle par défaut à {kinds}. Une modification ici s’applique à tous ceux qui l’ont ainsi.",
|
||||||
"Builds on": "S’appuie sur",
|
"Builds on": "S’appuie sur",
|
||||||
"Permissions": "Autorisations",
|
"Permissions": "Autorisations",
|
||||||
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart attribue ce rôle par défaut, il ne peut donc pas être supprimé. Modifiez d’abord les valeurs par défaut dans l’administration de Stalwart.",
|
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Le serveur de messagerie attribue ce rôle par défaut : il ne peut donc pas être supprimé. Modifiez d’abord les valeurs par défaut dans INBUXA Admin.",
|
||||||
"This role carries permissions yours doesn't.": "Ce rôle comporte des autorisations que le vôtre n’a pas.",
|
"This role carries permissions yours doesn't.": "Ce rôle comporte des autorisations que le vôtre n’a pas.",
|
||||||
"Create role": "Créer le rôle",
|
"Create role": "Créer le rôle",
|
||||||
"builds on this one": "s’appuie sur celui-ci",
|
"builds on this one": "s’appuie sur celui-ci",
|
||||||
@@ -244,7 +244,7 @@ export const catalog: Catalog = {
|
|||||||
"Delete role": "Supprimer le rôle",
|
"Delete role": "Supprimer le rôle",
|
||||||
"It can't be undone.": "C’est irréversible.",
|
"It can't be undone.": "C’est irréversible.",
|
||||||
"Type {name} to confirm": "Saisissez {name} pour confirmer",
|
"Type {name} to confirm": "Saisissez {name} pour confirmer",
|
||||||
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "La liste des autorisations de Stalwart n’a pas pu être chargée : les autorisations ne peuvent donc pas être modifiées ici. ({reason})",
|
"The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "La liste des autorisations du serveur de messagerie n’a pas pu être chargée : les autorisations ne peuvent donc pas être modifiées ici. ({reason})",
|
||||||
"Named sets of permissions, given to accounts, groups and tenants.": "Des ensembles nommés d’autorisations, attribués aux comptes, groupes et locataires.",
|
"Named sets of permissions, given to accounts, groups and tenants.": "Des ensembles nommés d’autorisations, attribués aux comptes, groupes et locataires.",
|
||||||
"Search roles": "Rechercher des rôles",
|
"Search roles": "Rechercher des rôles",
|
||||||
"No roles match": "Aucun rôle ne correspond",
|
"No roles match": "Aucun rôle ne correspond",
|
||||||
@@ -267,11 +267,11 @@ export const catalog: Catalog = {
|
|||||||
"{used} used": "{used} utilisés",
|
"{used} used": "{used} utilisés",
|
||||||
"Your role lets you view tenants but not change them.": "Votre rôle vous permet de consulter les locataires, mais pas de les modifier.",
|
"Your role lets you view tenants but not change them.": "Votre rôle vous permet de consulter les locataires, mais pas de les modifier.",
|
||||||
"Logo": "Logo",
|
"Logo": "Logo",
|
||||||
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Une adresse https ou une URL data d’image. Stalwart l’affiche aux personnes du locataire là où il affiche un logo.",
|
"An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Une adresse https ou une URL de données d’une image. Le serveur de messagerie l’affiche aux personnes du locataire là où il affiche un logo.",
|
||||||
"What it holds": "Contenu",
|
"What it holds": "Contenu",
|
||||||
"{n} of {limit}": "{n} sur {limit}",
|
"{n} of {limit}": "{n} sur {limit}",
|
||||||
"Limits": "Limites",
|
"Limits": "Limites",
|
||||||
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart refuse de créer au-delà d’une limite. Un champ vide signifie aucune limite.",
|
"The mail server refuses to create more than a limit allows. An empty field is no limit.": "Le serveur de messagerie refuse de créer plus que ce qu’une limite autorise. Un champ vide signifie aucune limite.",
|
||||||
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Le maximum autorisé à quiconque dans ce locataire : ses propres rôles sont réduits à ce que ceux-ci accordent. Seuls les rôles dont vous détenez vous-même les autorisations sont proposés.",
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Le maximum autorisé à quiconque dans ce locataire : ses propres rôles sont réduits à ce que ceux-ci accordent. Seuls les rôles dont vous détenez vous-même les autorisations sont proposés.",
|
||||||
"Checking what is still in this tenant…": "Vérification de ce que contient encore ce locataire…",
|
"Checking what is still in this tenant…": "Vérification de ce que contient encore ce locataire…",
|
||||||
"It still holds accounts, domains or other things. Move them out first.": "Il contient encore des comptes, des domaines ou d’autres éléments. Déplacez-les d’abord.",
|
"It still holds accounts, domains or other things. Move them out first.": "Il contient encore des comptes, des domaines ou d’autres éléments. Déplacez-les d’abord.",
|
||||||
@@ -287,7 +287,6 @@ export const catalog: Catalog = {
|
|||||||
"Still holds {things}. Move them out first.": "Contient encore {things}. Déplacez-les d’abord.",
|
"Still holds {things}. Move them out first.": "Contient encore {things}. Déplacez-les d’abord.",
|
||||||
"Delete tenant": "Supprimer le locataire",
|
"Delete tenant": "Supprimer le locataire",
|
||||||
"Separate organizations on one server, each with its own people, domains and limits.": "Des organisations distinctes sur un même serveur, chacune avec ses personnes, ses domaines et ses limites.",
|
"Separate organizations on one server, each with its own people, domains and limits.": "Des organisations distinctes sur un même serveur, chacune avec ses personnes, ses domaines et ses limites.",
|
||||||
"Tenants are a Stalwart Enterprise feature.": "Les locataires sont une fonctionnalité de Stalwart Enterprise.",
|
|
||||||
"Search tenants": "Rechercher des locataires",
|
"Search tenants": "Rechercher des locataires",
|
||||||
"No tenants match": "Aucun locataire ne correspond",
|
"No tenants match": "Aucun locataire ne correspond",
|
||||||
"No tenants yet": "Aucun locataire pour l’instant",
|
"No tenants yet": "Aucun locataire pour l’instant",
|
||||||
@@ -515,7 +514,7 @@ export const catalog: Catalog = {
|
|||||||
"Waiting on the server — goes out {when}.": "En attente sur le serveur — envoi {when}.",
|
"Waiting on the server — goes out {when}.": "En attente sur le serveur — envoi {when}.",
|
||||||
"Scheduled — click to clear the schedule": "Programmé — cliquez pour annuler la programmation",
|
"Scheduled — click to clear the schedule": "Programmé — cliquez pour annuler la programmation",
|
||||||
"Nothing scheduled": "Rien de programmé",
|
"Nothing scheduled": "Rien de programmé",
|
||||||
"The message waits on the server, so it goes out whether or not ihasmail is open.": "Le message attend sur le serveur : il part que ihasmail soit ouvert ou non.",
|
"The message waits on the server, so it goes out whether or not {app} is open.": "Le message attend sur le serveur : il part que {app} soit ouvert ou non.",
|
||||||
"This server holds a message for up to {span}.": "Ce serveur conserve un message jusqu'à {span}.",
|
"This server holds a message for up to {span}.": "Ce serveur conserve un message jusqu'à {span}.",
|
||||||
"Date and time to send": "Date et heure d'envoi",
|
"Date and time to send": "Date et heure d'envoi",
|
||||||
"Undo send window": "Délai d'annulation d'envoi",
|
"Undo send window": "Délai d'annulation d'envoi",
|
||||||
@@ -738,7 +737,7 @@ export const catalog: Catalog = {
|
|||||||
"Sections": "Sections",
|
"Sections": "Sections",
|
||||||
"General": "Général",
|
"General": "Général",
|
||||||
"Appearance": "Apparence",
|
"Appearance": "Apparence",
|
||||||
"Make ihasmail yours.": "Faites de ihasmail le vôtre.",
|
"Make {app} yours.": "Faites de {app} le v\u00f4tre.",
|
||||||
"Reading": "Lecture",
|
"Reading": "Lecture",
|
||||||
"Reading pane": "Volet de lecture",
|
"Reading pane": "Volet de lecture",
|
||||||
"Right of the list": "À droite de la liste",
|
"Right of the list": "À droite de la liste",
|
||||||
@@ -755,6 +754,7 @@ export const catalog: Catalog = {
|
|||||||
"Attachment reminder": "Rappel de pièce jointe",
|
"Attachment reminder": "Rappel de pièce jointe",
|
||||||
"Warn when the message mentions an attachment but none is attached.": "Avertir lorsque le message mentionne une pièce jointe alors qu'aucune n'est jointe.",
|
"Warn when the message mentions an attachment but none is attached.": "Avertir lorsque le message mentionne une pièce jointe alors qu'aucune n'est jointe.",
|
||||||
"Spell check while typing": "Vérification orthographique pendant la saisie",
|
"Spell check while typing": "Vérification orthographique pendant la saisie",
|
||||||
|
"Open the composer full screen": "Rédiger les messages en plein écran",
|
||||||
"Confirm before deleting": "Confirmer avant de supprimer",
|
"Confirm before deleting": "Confirmer avant de supprimer",
|
||||||
"Show message snippets": "Afficher un aperçu des messages",
|
"Show message snippets": "Afficher un aperçu des messages",
|
||||||
"Preview the first line of each message in the list.": "Afficher la première ligne de chaque message dans la liste.",
|
"Preview the first line of each message in the list.": "Afficher la première ligne de chaque message dans la liste.",
|
||||||
@@ -834,7 +834,8 @@ export const catalog: Catalog = {
|
|||||||
"Reset to defaults": "Rétablir les valeurs par défaut",
|
"Reset to defaults": "Rétablir les valeurs par défaut",
|
||||||
"Default mail app": "Application de messagerie par défaut",
|
"Default mail app": "Application de messagerie par défaut",
|
||||||
"Documentation": "Documentation",
|
"Documentation": "Documentation",
|
||||||
"About ihasmail": "À propos de ihasmail",
|
"About {app}": "À propos de {app}",
|
||||||
|
"Built on {project}": "Basé sur {project}",
|
||||||
"About": "À propos",
|
"About": "À propos",
|
||||||
"Server": "Serveur",
|
"Server": "Serveur",
|
||||||
"Server capabilities": "Fonctionnalités du serveur",
|
"Server capabilities": "Fonctionnalités du serveur",
|
||||||
@@ -935,6 +936,7 @@ export const catalog: Catalog = {
|
|||||||
"Turn off two-factor authentication": "Désactiver l'authentification à deux facteurs",
|
"Turn off two-factor authentication": "Désactiver l'authentification à deux facteurs",
|
||||||
"Your password alone will be enough to sign in again.": "Votre mot de passe seul suffira pour vous reconnecter.",
|
"Your password alone will be enough to sign in again.": "Votre mot de passe seul suffira pour vous reconnecter.",
|
||||||
"App passwords": "Mots de passe d'application",
|
"App passwords": "Mots de passe d'application",
|
||||||
|
"Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "Votre organisation n'autorise que {app} et les applications JMAP : les applications de messagerie sur téléphone et ordinateur ne peuvent donc pas se connecter à ce compte.",
|
||||||
"New app password for": "Nouveau mot de passe d'application pour",
|
"New app password for": "Nouveau mot de passe d'application pour",
|
||||||
"Your new app password": "Votre nouveau mot de passe d'application",
|
"Your new app password": "Votre nouveau mot de passe d'application",
|
||||||
"Secret": "Secret",
|
"Secret": "Secret",
|
||||||
@@ -962,8 +964,8 @@ export const catalog: Catalog = {
|
|||||||
"Notifications": "Notifications",
|
"Notifications": "Notifications",
|
||||||
"Notifications are blocked in your browser settings.": "Les notifications sont bloquées dans les paramètres de votre navigateur.",
|
"Notifications are blocked in your browser settings.": "Les notifications sont bloquées dans les paramètres de votre navigateur.",
|
||||||
"Not supported in this browser.": "Non pris en charge par ce navigateur.",
|
"Not supported in this browser.": "Non pris en charge par ce navigateur.",
|
||||||
"Desktop notifications while ihasmail is open": "Notifications système lorsque ihasmail est ouvert",
|
"Desktop notifications while {app} is open": "Notifications système lorsque {app} est ouvert",
|
||||||
"Notify me even when ihasmail is closed": "Me notifier même lorsque ihasmail est fermé",
|
"Notify me even when {app} is closed": "Me notifier même lorsque {app} est fermé",
|
||||||
"Play a sound for new mail": "Émettre un son à l'arrivée d'un message",
|
"Play a sound for new mail": "Émettre un son à l'arrivée d'un message",
|
||||||
"Test notification": "Tester la notification",
|
"Test notification": "Tester la notification",
|
||||||
"Background notifications are on": "Les notifications en arrière-plan sont activées",
|
"Background notifications are on": "Les notifications en arrière-plan sont activées",
|
||||||
@@ -1131,7 +1133,7 @@ export const catalog: Catalog = {
|
|||||||
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Une nouvelle identité doit utiliser une adresse depuis laquelle ce compte est autorisé à envoyer (alias configurés sur le serveur).",
|
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Une nouvelle identité doit utiliser une adresse depuis laquelle ce compte est autorisé à envoyer (alias configurés sur le serveur).",
|
||||||
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Non proposée lors de la rédaction. L'adresse reçoit toujours du courrier, et vous pouvez de nouveau envoyer depuis elle en la réaffichant.",
|
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Non proposée lors de la rédaction. L'adresse reçoit toujours du courrier, et vous pouvez de nouveau envoyer depuis elle en la réaffichant.",
|
||||||
"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.": "Chaque identité est une adresse d'expédition avec son propre nom, sa propre adresse de réponse et sa propre signature. L'identité par défaut est présélectionnée à la rédaction ; définissez une adresse de réponse lorsque les réponses doivent arriver ailleurs qu'à l'adresse d'expédition.",
|
"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.": "Chaque identité est une adresse d'expédition avec son propre nom, sa propre adresse de réponse et sa propre signature. L'identité par défaut est présélectionnée à la rédaction ; définissez une adresse de réponse lorsque les réponses doivent arriver ailleurs qu'à l'adresse d'expédition.",
|
||||||
"This signature is larger than the server's {limit}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.": "Cette signature dépasse la limite de {limit} octets du serveur. ihasmail conservera la version complète dans vos Fichiers et enregistrera une version texte courte sur le serveur — les autres clients verront la version en texte brut.",
|
"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.": "Cette signature d\u00e9passe la limite de {limit} octets du serveur. {app} conservera la version compl\u00e8te dans vos Fichiers et enregistrera une version texte courte sur le serveur \u2014 les autres clients verront la version en texte brut.",
|
||||||
"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.": "Des catégories façon Outlook, attribuables aux événements depuis le menu contextuel ou l'éditeur d'événement. Le nom de la catégorie est enregistré dans l'événement et se synchronise donc avec les autres clients.",
|
"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.": "Des catégories façon Outlook, attribuables aux événements depuis le menu contextuel ou l'éditeur d'événement. Le nom de la catégorie est enregistré dans l'événement et se synchronise donc avec les autres 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.": "Les messages en texte brut suivent déjà le thème. Avec cette option, les messages HTML sans couleurs propres le suivent aussi, au lieu de s'afficher sur un fond blanc. Les messages qui définissent leur propre style restent exactement tels que l'expéditeur les a conçus.",
|
"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.": "Les messages en texte brut suivent déjà le thème. Avec cette option, les messages HTML sans couleurs propres le suivent aussi, au lieu de s'afficher sur un fond blanc. Les messages qui définissent leur propre style restent exactement tels que l'expéditeur les a conçus.",
|
||||||
"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.": "Ceci est indépendant de {setting} dans Général, qui détermine l'écriture des dates, heures et nombres. Vous pouvez lire une interface anglaise avec des dates françaises, ou l'inverse.",
|
"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.": "Ceci est indépendant de {setting} dans Général, qui détermine l'écriture des dates, heures et nombres. Vous pouvez lire une interface anglaise avec des dates françaises, ou l'inverse.",
|
||||||
@@ -1139,40 +1141,40 @@ export const catalog: Catalog = {
|
|||||||
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Cet écran n'est pas tactile : rien ici ne change son comportement. Votre téléphone ou votre tablette reprendra ces réglages.",
|
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Cet écran n'est pas tactile : rien ici ne change son comportement. Votre téléphone ou votre tablette reprendra ces réglages.",
|
||||||
"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.": "Un appui long sur un message le sélectionne, un appui long sur un dossier ouvre son menu. Tirez le haut de la liste vers le bas pour relever le courrier.",
|
"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.": "Un appui long sur un message le sélectionne, un appui long sur un dossier ouvre son menu. Tirez le haut de la liste vers le bas pour relever le courrier.",
|
||||||
"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.": "Un accusé indique au demandeur que cette adresse est active et à quel moment le message a été lu, et l'expéditeur choisit où il est envoyé — il n'y a donc pas d'option automatique. Le courrier de masse, les listes de diffusion et tout ce qui est marqué comme envoyé automatiquement n'en obtiennent jamais.",
|
"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.": "Un accusé indique au demandeur que cette adresse est active et à quel moment le message a été lu, et l'expéditeur choisit où il est envoyé — il n'y a donc pas d'option automatique. Le courrier de masse, les listes de diffusion et tout ce qui est marqué comme envoyé automatiquement n'en obtiennent jamais.",
|
||||||
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.": "Ce navigateur ne peut pas enregistrer d'applications pour les liens {scheme}. Safari, en particulier, n'a pas d'interface pour cela — vous pouvez tout de même définir ihasmail par défaut depuis votre système d'exploitation en l'installant comme application.",
|
"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.": "Ce navigateur ne peut pas enregistrer d'applications pour les liens {scheme}. Safari, en particulier, n'a pas d'interface pour cela \u2014 vous pouvez tout de m\u00eame d\u00e9finir {app} par d\u00e9faut depuis votre syst\u00e8me d'exploitation en l'installant comme application.",
|
||||||
"Registering for {scheme} links requires a secure (HTTPS) connection.": "L'enregistrement pour les liens {scheme} nécessite une connexion sécurisée (HTTPS).",
|
"Registering for {scheme} links requires a secure (HTTPS) connection.": "L'enregistrement pour les liens {scheme} nécessite une connexion sécurisée (HTTPS).",
|
||||||
"Open {scheme} links — in web pages, documents and other apps — in ihasmail 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 › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).": "Ouvrir les liens {scheme} — dans les pages web, les documents et les autres applications — avec ihasmail plutôt qu'avec un client de messagerie local. Votre navigateur vous demandera de confirmer, et vous pourrez le modifier plus tard dans ses propres paramètres (Chrome : Paramètres › Confidentialité et sécurité › Paramètres des sites › Gestionnaires de protocole ; Firefox : Paramètres › Général › Applications).",
|
"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).": "Ouvrir les liens {scheme} \u2014 dans les pages web, les documents et les autres applications \u2014 avec {app} plut\u00f4t qu'avec un client de messagerie local. Votre navigateur vous demandera de confirmer, et vous pourrez le modifier plus tard dans ses propres param\u00e8tres (Chrome : Param\u00e8tres \u203a Confidentialit\u00e9 et s\u00e9curit\u00e9 \u203a Param\u00e8tres des sites \u203a Gestionnaires de protocole ; Firefox : Param\u00e8tres \u203a G\u00e9n\u00e9ral \u203a Applications).",
|
||||||
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Demandé dans ce navigateur. C'est à lui de décider si cela a pris effet — vérifiez ses paramètres si les liens de messagerie s'ouvrent toujours ailleurs.",
|
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Demandé dans ce navigateur. C'est à lui de décider si cela a pris effet — vérifiez ses paramètres si les liens de messagerie s'ouvrent toujours ailleurs.",
|
||||||
"For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.": "Pour un réglage valable dans tout le système, installez d'abord ihasmail comme application (dans Chrome : l'icône d'installation dans la barre d'adresse). Votre système pourra alors proposer ihasmail directement partout où il demande quelle application de messagerie utiliser.",
|
"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.": "Pour un réglage valable dans tout le système, installez d'abord {app} comme application (dans Chrome : l'icône d'installation dans la barre d'adresse). Votre système pourra alors proposer {app} directement partout où il demande quelle application de messagerie utiliser.",
|
||||||
"Needs a browser with the Push API and a mail server that publishes a push key.": "Nécessite un navigateur doté de l'API Push et un serveur de messagerie publiant une clé push.",
|
"Needs a browser with the Push API and a mail server that publishes a push key.": "Nécessite un navigateur doté de l'API Push et un serveur de messagerie publiant une clé push.",
|
||||||
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Votre serveur les remet directement à votre navigateur : elles arrivent donc sans onglet ihasmail ouvert, avec l'expéditeur et l'objet. Votre navigateur doit tout de même être en cours d'exécution — si vous le quittez complètement, les notifications attendent et arrivent à sa réouverture.",
|
"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.": "Votre serveur les remet directement \u00e0 votre navigateur : elles arrivent donc sans onglet {app} ouvert, avec l'exp\u00e9diteur et l'objet. Votre navigateur doit tout de m\u00eame \u00eatre en cours d'ex\u00e9cution \u2014 si vous le quittez compl\u00e8tement, les notifications attendent et arrivent \u00e0 sa r\u00e9ouverture.",
|
||||||
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Votre serveur peut réveiller ce navigateur, mais sans indiquer l'expéditeur ni l'objet. Votre navigateur doit tout de même être en cours d'exécution.",
|
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Votre serveur peut réveiller ce navigateur, mais sans indiquer l'expéditeur ni l'objet. Votre navigateur doit tout de même être en cours d'exécution.",
|
||||||
"This is what a new-mail notification looks like.": "Voici à quoi ressemble une notification de nouveau message.",
|
"This is what a new-mail notification looks like.": "Voici à quoi ressemble une notification de nouveau message.",
|
||||||
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Vous êtes connecté en tant que {user}. Votre mot de passe n'est jamais enregistré dans le navigateur ; le serveur le conserve chiffré, par session, pour dialoguer avec Stalwart.",
|
"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.": "Vous êtes connecté en tant que {user}. Votre mot de passe n’est jamais stocké dans le navigateur ; le serveur le conserve chiffré, par session, pour communiquer avec le serveur de messagerie.",
|
||||||
"App passwords are managed by your mail administrator.": "Les mots de passe d'application sont gérés par votre administrateur de messagerie.",
|
"App passwords are managed by your mail administrator.": "Les mots de passe d'application sont gérés par votre administrateur de messagerie.",
|
||||||
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Changer votre mot de passe déconnecte vos autres sessions webmail. Les mots de passe d'application continuent de fonctionner.",
|
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Changer votre mot de passe déconnecte vos autres sessions webmail. Les mots de passe d'application continuent de fonctionner.",
|
||||||
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "L'authentification à deux facteurs est activée sur ce compte. ihasmail ne sait pas encore vous connecter avec un code : la connexion sur un autre appareil nécessite donc un mot de passe d'application — ou vous pouvez désactiver l'authentification à deux facteurs ici.",
|
"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.": "L'authentification \u00e0 deux facteurs est activ\u00e9e sur ce compte. {app} ne sait pas encore vous connecter avec un code : la connexion sur un autre appareil n\u00e9cessite donc un mot de passe d'application \u2014 ou vous pouvez d\u00e9sactiver l'authentification \u00e0 deux facteurs ici.",
|
||||||
"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.": "Un mot de passe distinct pour une application ou un appareil, révocable indépendamment. Les mots de passe d'application contournent les codes à deux facteurs et fonctionnent donc dans les applications qui ne peuvent pas en demander.",
|
"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.": "Un mot de passe distinct pour une application ou un appareil, révocable indépendamment. Les mots de passe d'application contournent les codes à deux facteurs et fonctionnent donc dans les applications qui ne peuvent pas en demander.",
|
||||||
"Copy it into {name} now — it isn't shown again.": "Copiez-le dans {name} maintenant — il ne sera plus affiché.",
|
"Copy it into {name} now — it isn't shown again.": "Copiez-le dans {name} maintenant — il ne sera plus affiché.",
|
||||||
"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.": "Aucun autre utilisateur trouvé dans l'annuaire : personne de nouveau ne peut être ajouté. Les partages déjà en place sont listés ci-dessous et restent supprimables.",
|
"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.": "Aucun autre utilisateur trouvé dans l'annuaire : personne de nouveau ne peut être ajouté. Les partages déjà en place sont listés ci-dessous et restent supprimables.",
|
||||||
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart ne communique pas son numéro de version aux clients de messagerie ; ihasmail indique donc l'édition lorsque le serveur en fournit une. ihasmail requiert la version 0.16 ou ultérieure, et la connexion refuse toute version antérieure.",
|
"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 ne communique pas son num\u00e9ro de version aux clients de messagerie ; {app} indique donc l'\u00e9dition lorsque le serveur en fournit une. {app} requiert la version 0.16 ou ult\u00e9rieure, et la connexion refuse toute version ant\u00e9rieure.",
|
||||||
"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.": "Il {damage} : les règles qu'il contient ne peuvent donc être ni affichées ni modifiées — enregistrer ce qui est arrivé écraserait le reste. Rechargez la page pour réessayer. Vos règles sont toujours sur le serveur ; rien ici ne les a modifiées.",
|
"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.": "Il {damage} : les règles qu'il contient ne peuvent donc être ni affichées ni modifiées — enregistrer ce qui est arrivé écraserait le reste. Rechargez la page pour réessayer. Vos règles sont toujours sur le serveur ; rien ici ne les a modifiées.",
|
||||||
"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).": "L'éditeur visuel de règles ne gère que les scripts qu'il a créés. Vous pouvez modifier le script dans l'onglet {tab}, ou repartir de zéro avec des règles (le script existant sera conservé mais désactivé).",
|
"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).": "L'éditeur visuel de règles ne gère que les scripts qu'il a créés. Vous pouvez modifier le script dans l'onglet {tab}, ou repartir de zéro avec des règles (le script existant sera conservé mais désactivé).",
|
||||||
"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.": "Votre script de filtrage {damage} : il n'est arrivé que partiellement. Ajouter une règle écraserait l'ensemble par cette partie. Rechargez la page et réessayez.",
|
"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.": "Votre script de filtrage {damage} : il n'est arrivé que partiellement. Ajouter une règle écraserait l'ensemble par cette partie. Rechargez la page et réessayez.",
|
||||||
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Votre script de filtrage n'a pas pu être lu à l'instant ; ajouter une règle risquerait de l'écraser. Rechargez la page et réessayez.",
|
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Votre script de filtrage n'a pas pu être lu à l'instant ; ajouter une règle risquerait de l'écraser. Rechargez la page et réessayez.",
|
||||||
"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.": "Votre script Sieve actif a été écrit à la main : les règles ne peuvent donc pas être ajoutées automatiquement. Ouvrez {where} pour modifier le script ou passer aux règles gérées.",
|
"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.": "Votre script Sieve actif a été écrit à la main : les règles ne peuvent donc pas être ajoutées automatiquement. Ouvrez {where} pour modifier le script ou passer aux règles gérées.",
|
||||||
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Seules les langues dans lesquelles ihasmail a été traduit apparaissent ici : la liste s'allonge donc à mesure que les traductions arrivent, et non avant — une langue proposée sans textes derrière elle ferait prétendre à la page qu'elle est dans une langue qui n'est pas la sienne.",
|
"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.": "Seules les langues dans lesquelles {app} a \u00e9t\u00e9 traduit apparaissent ici : la liste s'allonge donc \u00e0 mesure que les traductions arrivent, et non avant \u2014 une langue propos\u00e9e sans textes derri\u00e8re elle ferait pr\u00e9tendre \u00e0 la page qu'elle est dans une langue qui n'est pas la sienne.",
|
||||||
"tell us about it": "signalez-le-nous",
|
"tell us about it": "signalez-le-nous",
|
||||||
"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}.": "Cette traduction a été générée par une IA et n'a pas été relue par une personne de langue maternelle française ; elle est donc marquée Beta jusqu'à validation. Tout ce qui sonne faux mérite d'être signalé — {report}.",
|
"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}.": "Cette traduction a été générée par une IA et n'a pas été relue par une personne de langue maternelle française ; elle est donc marquée Beta jusqu'à validation. Tout ce qui sonne faux mérite d'être signalé — {report}.",
|
||||||
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "La version de ihasmail est la date du commit à partir duquel elle a été construite, suivie de l'origine de ce commit : {example} provient d'un commit daté du 30 août 2026 arrivé via la pull request 129. Un commit qui n'est pas passé par là porte à la place son SHA court — {sha}. La version ne dit délibérément rien de Stalwart ; ce dont cette build a besoin du serveur figure à la ligne ci-dessus.",
|
"{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.": "La version de {app} est la date du commit \u00e0 partir duquel elle a \u00e9t\u00e9 construite, suivie de l'origine de ce commit : {example} provient d'un commit dat\u00e9 du 30 ao\u00fbt 2026 arriv\u00e9 via la pull request 129. Un commit qui n'est pas pass\u00e9 par l\u00e0 porte \u00e0 la place son SHA court \u2014 {sha}. La version ne dit d\u00e9lib\u00e9r\u00e9ment rien de Stalwart ; ce dont cette build a besoin du serveur figure \u00e0 la ligne ci-dessus.",
|
||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Nouveau message",
|
"New message": "Nouveau message",
|
||||||
"Start a new message with what was shared?": "Commencer un nouveau message avec le contenu partagé ?",
|
"Start a new message with what was shared?": "Commencer un nouveau message avec le contenu partagé ?",
|
||||||
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Un contenu a été partagé avec ihasmail. Rien n'est envoyé tant que vous n'avez pas choisi Envoyer. Si vous ne venez pas de le partager, abandonnez-le.",
|
"Something was shared with {app}. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Un contenu a été partagé avec {app}. Rien n'est envoyé tant que vous n'avez pas choisi Envoyer. Si vous ne venez pas de le partager, abandonnez-le.",
|
||||||
"Start a message": "Commencer un message",
|
"Start a message": "Commencer un message",
|
||||||
"New mail": "Nouveau courrier",
|
"New mail": "Nouveau courrier",
|
||||||
"Could not do that — open ihasmail and try again": "Impossible : ouvrez ihasmail et réessayez",
|
"Could not do that \u2014 open {app} and try again": "Impossible : ouvrez {app} et r\u00e9essayez",
|
||||||
"Sending…": "Envoi…",
|
"Sending…": "Envoi…",
|
||||||
"Saving…": "Enregistrement…",
|
"Saving…": "Enregistrement…",
|
||||||
"Error": "Erreur",
|
"Error": "Erreur",
|
||||||
@@ -1220,6 +1222,20 @@ export const catalog: Catalog = {
|
|||||||
"Could not save filters: {error}": "Impossible d’enregistrer les filtres : {error}",
|
"Could not save filters: {error}": "Impossible d’enregistrer les filtres : {error}",
|
||||||
"Could not send the receipt: {error}": "Impossible d’envoyer l’accusé de lecture : {error}",
|
"Could not send the receipt: {error}": "Impossible d’envoyer l’accusé de lecture : {error}",
|
||||||
"Could not sign in.": "Connexion impossible.",
|
"Could not sign in.": "Connexion impossible.",
|
||||||
|
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "Vous êtes connecté en tant que {user}. Ce webmail ne voit jamais votre mot de passe : il conserve un jeton de connexion de votre serveur de messagerie, chiffré par session.",
|
||||||
|
"Mail server": "Serveur de messagerie",
|
||||||
|
"You'll enter your password on your mail server's sign-in page.": "Vous saisirez votre mot de passe sur la page de connexion de votre serveur de messagerie.",
|
||||||
|
"You'll sign in on your mail server's own page.": "Vous vous connecterez sur la page de votre serveur de messagerie.",
|
||||||
|
"This sign-in didn't start in this browser. Try again.": "Cette connexion n’a pas commencé dans ce navigateur. Réessayez.",
|
||||||
|
"The sign-in took too long. Try again.": "La connexion a pris trop de temps. Réessayez.",
|
||||||
|
"Sign-in was cancelled.": "La connexion a été annulée.",
|
||||||
|
"The mail server didn't accept the sign-in. Try again.": "Le serveur de messagerie n’a pas accepté la connexion. Réessayez.",
|
||||||
|
"That account is on a different mail server than the address you entered. Sign in with that address.": "Ce compte se trouve sur un autre serveur de messagerie que l’adresse saisie. Connectez-vous avec cette adresse.",
|
||||||
|
"This mail server isn't supported.": "Ce serveur de messagerie n’est pas pris en charge.",
|
||||||
|
"Couldn't reach the mail server. Try again in a moment.": "Impossible de joindre le serveur de messagerie. Réessayez dans un instant.",
|
||||||
|
"Your password was changed. Sign in with the new one.": "Votre mot de passe a été changé. Connectez-vous avec le nouveau.",
|
||||||
|
"Your sign-in ended. Sign in again.": "Votre connexion a pris fin. Connectez-vous à nouveau.",
|
||||||
|
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Changer votre mot de passe vous déconnecte partout, ici compris, et vous vous reconnectez avec le nouveau. Les mots de passe d'application continuent de fonctionner.",
|
||||||
"Could not store image: {error}": "Impossible d’enregistrer l’image : {error}",
|
"Could not store image: {error}": "Impossible d’enregistrer l’image : {error}",
|
||||||
"Could not update labels: {error}": "Impossible de mettre à jour les libellés : {error}",
|
"Could not update labels: {error}": "Impossible de mettre à jour les libellés : {error}",
|
||||||
"Could not update the Scheduled folder: {error}": "Impossible de mettre à jour le dossier Programmés : {error}",
|
"Could not update the Scheduled folder: {error}": "Impossible de mettre à jour le dossier Programmés : {error}",
|
||||||
@@ -1342,7 +1358,7 @@ export const catalog: Catalog = {
|
|||||||
"Undo window: {seconds}s": "Délai d’annulation : {seconds} s",
|
"Undo window: {seconds}s": "Délai d’annulation : {seconds} s",
|
||||||
"You're all caught up": "Vous êtes à jour",
|
"You're all caught up": "Vous êtes à jour",
|
||||||
"Your browser refused the request: {error}": "Votre navigateur a refusé la demande : {error}",
|
"Your browser refused the request: {error}": "Votre navigateur a refusé la demande : {error}",
|
||||||
"Your browser will ask whether to open mail links in ihasmail": "Votre navigateur vous demandera s’il faut ouvrir les liens de courrier dans ihasmail",
|
"Your browser will ask whether to open mail links in {app}": "Votre navigateur vous demandera s’il faut ouvrir les liens de courrier dans {app}",
|
||||||
"Your message mentions an attachment, but nothing is attached.": "Votre message mentionne une pièce jointe, mais rien n’est joint.",
|
"Your message mentions an attachment, but nothing is attached.": "Votre message mentionne une pièce jointe, mais rien n’est joint.",
|
||||||
"event": "événement",
|
"event": "événement",
|
||||||
"Hide password": "Masquer le mot de passe",
|
"Hide password": "Masquer le mot de passe",
|
||||||
@@ -1413,7 +1429,7 @@ export const catalog: Catalog = {
|
|||||||
"Also count these domains as inside": "Considérer aussi ces domaines comme internes",
|
"Also count these domains as inside": "Considérer aussi ces domaines comme internes",
|
||||||
"Always": "Toujours",
|
"Always": "Toujours",
|
||||||
"Always showing images from": "Images toujours affichées depuis",
|
"Always showing images from": "Images toujours affichées depuis",
|
||||||
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.": "Une image chargée depuis le serveur de l'expéditeur lui indique que le message a été ouvert, quand et approximativement d'où. Les images approuvées sont récupérées par le serveur d'ihasmail et non par le navigateur, de sorte que l'expéditeur n'apprend rien de tout cela.",
|
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.": "Une image chargée depuis le serveur de l'expéditeur lui indique que le message a été ouvert, quand et approximativement d'où. Les images approuvées sont récupérées par le serveur d'{app} et non par le navigateur, de sorte que l'expéditeur n'apprend rien de tout cela.",
|
||||||
"Applies to": "S'applique à",
|
"Applies to": "S'applique à",
|
||||||
"Archive and next": "Archiver et suivant",
|
"Archive and next": "Archiver et suivant",
|
||||||
"Archive by month": "Archiver par mois",
|
"Archive by month": "Archiver par mois",
|
||||||
@@ -1655,8 +1671,8 @@ export const catalog: Catalog = {
|
|||||||
"Fingerprint": "Empreinte",
|
"Fingerprint": "Empreinte",
|
||||||
"Hide details": "Masquer les détails",
|
"Hide details": "Masquer les détails",
|
||||||
"Issued by": "Délivré par",
|
"Issued by": "Délivré par",
|
||||||
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Il est signé avec OpenPGP, et ihasmail n'a aucun moyen de récupérer la clé publique de l'expéditeur.",
|
"It is signed with OpenPGP, and {app} has no way to fetch the sender's public key.": "Il est signé avec OpenPGP, et {app} n'a aucun moyen de récupérer la clé publique de l'expéditeur.",
|
||||||
"It uses a signature algorithm ihasmail cannot check yet.": "Il utilise un algorithme de signature qu'ihasmail ne sait pas encore vérifier.",
|
"It uses a signature algorithm {app} cannot check yet.": "Il utilise un algorithme de signature qu'{app} ne sait pas encore vérifier.",
|
||||||
"It was made with a certificate belonging to {name}, which does not cover this address.": "Elle a été faite avec un certificat appartenant à {name}, qui ne couvre pas cette adresse.",
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Elle a été faite avec un certificat appartenant à {name}, qui ne couvre pas cette adresse.",
|
||||||
"Previous fingerprint": "Empreinte précédente",
|
"Previous fingerprint": "Empreinte précédente",
|
||||||
"Signed at": "Signé le",
|
"Signed at": "Signé le",
|
||||||
@@ -1673,18 +1689,39 @@ export const catalog: Catalog = {
|
|||||||
"The signature is not for this sender.": "La signature ne correspond pas à cet expéditeur.",
|
"The signature is not for this sender.": "La signature ne correspond pas à cet expéditeur.",
|
||||||
"The signed part is missing either the message or the signature.": "Il manque à la partie signée soit le message, soit la signature.",
|
"The signed part is missing either the message or the signature.": "Il manque à la partie signée soit le message, soit la signature.",
|
||||||
"The signer has changed.": "Le signataire a changé.",
|
"The signer has changed.": "Le signataire a changé.",
|
||||||
"This message is signed, and ihasmail could not check the signature.": "Ce message est signé, et ihasmail n'a pas pu vérifier la signature.",
|
"This message is signed, and {app} could not check the signature.": "Ce message est signé, et {app} n'a pas pu vérifier la signature.",
|
||||||
"This signature does not check out.": "Cette signature ne tient pas.",
|
"This signature does not check out.": "Cette signature ne tient pas.",
|
||||||
"Valid until": "Valable jusqu'au",
|
"Valid until": "Valable jusqu'au",
|
||||||
"a different certificate": "un certificat différent",
|
"a different certificate": "un certificat différent",
|
||||||
"an unnamed signer": "un signataire sans nom",
|
"an unnamed signer": "un signataire sans nom",
|
||||||
"as claimed by the signer": "selon le signataire",
|
"as claimed by the signer": "selon le signataire",
|
||||||
"first seen {date}": "vu pour la première fois le {date}",
|
"first seen {date}": "vu pour la première fois le {date}",
|
||||||
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail vous préviendra si un message ultérieur de cette adresse est signé par quelqu'un d'autre.",
|
"{app} will tell you if a later message from this address is signed by anybody else.": "{app} vous préviendra si un message ultérieur de cette adresse est signé par quelqu'un d'autre.",
|
||||||
"itself, or an issuer it does not name": "lui-même, ou un émetteur qu'il ne nomme pas",
|
"itself, or an issuer it does not name": "lui-même, ou un émetteur qu'il ne nomme pas",
|
||||||
"no address": "aucune adresse",
|
"no address": "aucune adresse",
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"Legacy mail apps": "Applications de messagerie à protocoles historiques",
|
||||||
|
"Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.": "Désactivés pour {tenant}. Seuls {app} et les applications JMAP peuvent se connecter à ses domaines.",
|
||||||
|
"On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.": "Activés pour {tenant}. Les applications de messagerie peuvent utiliser IMAP, POP3 et ManageSieve sur ses domaines.",
|
||||||
|
"Turn legacy protocols back on": "Réactiver les protocoles de messagerie historiques",
|
||||||
|
"Turn off legacy protocols…": "Désactiver les protocoles de messagerie historiques…",
|
||||||
|
"No account used a legacy mail app in the last 30 days.": "Aucun compte n'a utilisé d'application de messagerie à protocoles historiques ces 30 derniers jours.",
|
||||||
|
"Their mail apps will stop working the moment you turn this on:": "Leurs applications de messagerie cesseront de fonctionner dès que vous activerez ceci :",
|
||||||
|
"Only {app} and JMAP apps will work.": "Seuls {app} et les applications JMAP fonctionneront.",
|
||||||
|
"Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {tenant}.": "Les protocoles de messagerie historiques (IMAP, POP3, ManageSieve et l'envoi depuis les applications de messagerie) seront désactivés pour tout le monde dans {tenant}.",
|
||||||
|
"Phone and desktop mail apps will stop receiving and sending mail. That's iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.": "Les applications de messagerie sur téléphone et ordinateur ne pourront plus recevoir ni envoyer de courrier : Mail sur iPhone et iPad, les applications Gmail et Outlook, Outlook, Thunderbird et Apple Mail. Des erreurs de connexion s'y afficheront.",
|
||||||
|
"Filters managed from a mail app (ManageSieve) will stop working. Filters set in {app} keep working.": "Les filtres gérés depuis une application de messagerie (ManageSieve) cesseront de fonctionner. Les filtres configurés dans {app} continuent de fonctionner.",
|
||||||
|
"Incoming mail is not affected. Calendars and contacts are not affected.": "Le courrier entrant n'est pas concerné. Les agendas et les contacts ne sont pas concernés.",
|
||||||
|
"People keep full access through {app}, which can be installed as an app on phones and computers.": "Chacun garde un accès complet via {app}, qui peut s'installer comme application sur téléphone et ordinateur.",
|
||||||
|
"Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and {app} (JMAP) are not affected and cannot be turned off here.": "L'envoi depuis les applications de messagerie (soumission SMTP) cessera de fonctionner, mais ses ports restent ouverts : les applications de messagerie seront informées qu'elles ne peuvent pas se connecter. Le courrier entrant (SMTP) et {app} (JMAP) ne sont pas concernés et ne peuvent pas être désactivés ici.",
|
||||||
|
"You can turn legacy protocols back on at any time.": "Vous pouvez réactiver les protocoles de messagerie historiques à tout moment.",
|
||||||
|
"To confirm, type {phrase}": "Pour confirmer, saisissez {phrase}",
|
||||||
|
"Turn off legacy protocols": "Désactiver les protocoles de messagerie historiques",
|
||||||
|
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Les protocoles de messagerie historiques sont désactivés pour votre organisation. Seuls {app} et les applications JMAP peuvent se connecter.",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"{n} accounts used a legacy mail app in the last 30 days.": {one: "{n} compte a utilisé une application de messagerie à protocoles historiques ces 30 derniers jours.", other: "{n} comptes ont utilisé une application de messagerie à protocoles historiques ces 30 derniers jours."},
|
||||||
// ── Administration: domains ────────────────────────────────────
|
// ── Administration: domains ────────────────────────────────────
|
||||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} compte utilise ce domaine. Déplacez-le ou supprimez-le d’abord.", other: "{n} comptes utilisent ce domaine. Déplacez-les ou supprimez-les d’abord." },
|
"{n} accounts use this domain. Move or delete them first.": { one: "{n} compte utilise ce domaine. Déplacez-le ou supprimez-le d’abord.", other: "{n} comptes utilisent ce domaine. Déplacez-les ou supprimez-les d’abord." },
|
||||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Le serveur n’accepte plus de messages pour ce domaine, et sa {n} clé DKIM est supprimée. C’est irréversible.", other: "Le serveur n’accepte plus de messages pour ce domaine, et ses {n} clés DKIM sont supprimées. C’est irréversible." },
|
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Le serveur n’accepte plus de messages pour ce domaine, et sa {n} clé DKIM est supprimée. C’est irréversible.", other: "Le serveur n’accepte plus de messages pour ce domaine, et ses {n} clés DKIM sont supprimées. C’est irréversible." },
|
||||||
|
|||||||
+68
-31
@@ -138,8 +138,8 @@ export const catalog: Catalog = {
|
|||||||
"The numbers your role can see, as the server reports them.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。",
|
"The numbers your role can see, as the server reports them.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。",
|
||||||
"Nothing to show": "表示するものはありません",
|
"Nothing to show": "表示するものはありません",
|
||||||
"Could not be loaded": "読み込めませんでした",
|
"Could not be loaded": "読み込めませんでした",
|
||||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "詳しいメトリクス、配送キュー、ログ、サーバー設定は Stalwart 自体の管理画面にあります。",
|
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "詳細なメトリクス、配信キュー、ログ、サーバー設定は INBUXA Admin にあります。",
|
||||||
"Open Stalwart admin": "Stalwart の管理画面を開く",
|
"Open INBUXA Admin": "INBUXA Admin を開く",
|
||||||
"Default group role": "グループの既定ロール",
|
"Default group role": "グループの既定ロール",
|
||||||
"A group needs an address.": "グループにはアドレスが必要です。",
|
"A group needs an address.": "グループにはアドレスが必要です。",
|
||||||
"New group": "新しいグループ",
|
"New group": "新しいグループ",
|
||||||
@@ -208,10 +208,10 @@ export const catalog: Catalog = {
|
|||||||
"New role": "新しいロール",
|
"New role": "新しいロール",
|
||||||
"This role carries permissions yours doesn't, so you can view it but not change it.": "このロールにはあなたのロールにない権限があるため、閲覧はできますが変更はできません。",
|
"This role carries permissions yours doesn't, so you can view it but not change it.": "このロールにはあなたのロールにない権限があるため、閲覧はできますが変更はできません。",
|
||||||
"Your role lets you view roles but not change them.": "あなたのロールでは、ロールの閲覧はできますが変更はできません。",
|
"Your role lets you view roles but not change them.": "あなたのロールでは、ロールの閲覧はできますが変更はできません。",
|
||||||
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart はこのロールを既定で {kinds} に付与しています。ここでの変更は、その方法でこのロールを持つ全員に及びます。",
|
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "メールサーバーはこのロールを既定で {kinds} に付与します。ここでの変更は、この方法でロールを持つ全員に反映されます。",
|
||||||
"Builds on": "継承元",
|
"Builds on": "継承元",
|
||||||
"Permissions": "権限",
|
"Permissions": "権限",
|
||||||
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart が既定で付与するロールのため、削除できません。先に Stalwart 自体の管理画面で既定値を変更してください。",
|
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "メールサーバーがこのロールを既定で付与するため、削除できません。先に INBUXA Admin で既定値を変更してください。",
|
||||||
"This role carries permissions yours doesn't.": "このロールにはあなたのロールにない権限があります。",
|
"This role carries permissions yours doesn't.": "このロールにはあなたのロールにない権限があります。",
|
||||||
"Create role": "ロールを作成",
|
"Create role": "ロールを作成",
|
||||||
"builds on this one": "このロールを継承しています",
|
"builds on this one": "このロールを継承しています",
|
||||||
@@ -238,7 +238,7 @@ export const catalog: Catalog = {
|
|||||||
"Delete role": "ロールを削除",
|
"Delete role": "ロールを削除",
|
||||||
"It can't be undone.": "元に戻せません。",
|
"It can't be undone.": "元に戻せません。",
|
||||||
"Type {name} to confirm": "確認のため {name} と入力してください",
|
"Type {name} to confirm": "確認のため {name} と入力してください",
|
||||||
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Stalwart の権限一覧を読み込めなかったため、ここでは権限を変更できません。({reason})",
|
"The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "メールサーバーの権限一覧を読み込めなかったため、ここでは権限を変更できません。({reason})",
|
||||||
"Named sets of permissions, given to accounts, groups and tenants.": "アカウント、グループ、テナントに付与する、名前付きの権限のまとまりです。",
|
"Named sets of permissions, given to accounts, groups and tenants.": "アカウント、グループ、テナントに付与する、名前付きの権限のまとまりです。",
|
||||||
"Search roles": "ロールを検索",
|
"Search roles": "ロールを検索",
|
||||||
"No roles match": "一致するロールはありません",
|
"No roles match": "一致するロールはありません",
|
||||||
@@ -261,11 +261,11 @@ export const catalog: Catalog = {
|
|||||||
"{used} used": "{used} 使用中",
|
"{used} used": "{used} 使用中",
|
||||||
"Your role lets you view tenants but not change them.": "あなたのロールでは、テナントの閲覧はできますが変更はできません。",
|
"Your role lets you view tenants but not change them.": "あなたのロールでは、テナントの閲覧はできますが変更はできません。",
|
||||||
"Logo": "ロゴ",
|
"Logo": "ロゴ",
|
||||||
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "https のアドレス、または画像の data URL です。Stalwart はロゴを表示する場所で、テナントの利用者に表示します。",
|
"An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "画像の https アドレスまたはデータ URL。メールサーバーは、ロゴを表示する場所でテナントのユーザーにこれを表示します。",
|
||||||
"What it holds": "含まれるもの",
|
"What it holds": "含まれるもの",
|
||||||
"{n} of {limit}": "{limit} 件中 {n} 件",
|
"{n} of {limit}": "{limit} 件中 {n} 件",
|
||||||
"Limits": "上限",
|
"Limits": "上限",
|
||||||
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart は上限を超える作成を拒否します。空欄は上限なしです。",
|
"The mail server refuses to create more than a limit allows. An empty field is no limit.": "メールサーバーは上限を超える作成を拒否します。空欄は上限なしを意味します。",
|
||||||
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "このテナント内の誰にでも許可できる最大の範囲です。各自のロールは、これらが付与する範囲に絞られます。表示されるのは、あなた自身が権限を持つロールだけです。",
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "このテナント内の誰にでも許可できる最大の範囲です。各自のロールは、これらが付与する範囲に絞られます。表示されるのは、あなた自身が権限を持つロールだけです。",
|
||||||
"Checking what is still in this tenant…": "このテナントに残っているものを確認しています…",
|
"Checking what is still in this tenant…": "このテナントに残っているものを確認しています…",
|
||||||
"It still holds accounts, domains or other things. Move them out first.": "まだアカウント、ドメインなどが含まれています。先に移動してください。",
|
"It still holds accounts, domains or other things. Move them out first.": "まだアカウント、ドメインなどが含まれています。先に移動してください。",
|
||||||
@@ -281,7 +281,6 @@ export const catalog: Catalog = {
|
|||||||
"Still holds {things}. Move them out first.": "まだ {things} が含まれています。先に移動してください。",
|
"Still holds {things}. Move them out first.": "まだ {things} が含まれています。先に移動してください。",
|
||||||
"Delete tenant": "テナントを削除",
|
"Delete tenant": "テナントを削除",
|
||||||
"Separate organizations on one server, each with its own people, domains and limits.": "1 台のサーバー上の別々の組織で、それぞれに利用者、ドメイン、上限があります。",
|
"Separate organizations on one server, each with its own people, domains and limits.": "1 台のサーバー上の別々の組織で、それぞれに利用者、ドメイン、上限があります。",
|
||||||
"Tenants are a Stalwart Enterprise feature.": "テナントは Stalwart Enterprise の機能です。",
|
|
||||||
"Search tenants": "テナントを検索",
|
"Search tenants": "テナントを検索",
|
||||||
"No tenants match": "一致するテナントはありません",
|
"No tenants match": "一致するテナントはありません",
|
||||||
"No tenants yet": "まだテナントがありません",
|
"No tenants yet": "まだテナントがありません",
|
||||||
@@ -509,7 +508,7 @@ export const catalog: Catalog = {
|
|||||||
"Waiting on the server — goes out {when}.": "サーバーで待機中です。{when} に送信されます。",
|
"Waiting on the server — goes out {when}.": "サーバーで待機中です。{when} に送信されます。",
|
||||||
"Scheduled — click to clear the schedule": "予約済み — クリックすると予約を解除します",
|
"Scheduled — click to clear the schedule": "予約済み — クリックすると予約を解除します",
|
||||||
"Nothing scheduled": "予約されたメールはありません",
|
"Nothing scheduled": "予約されたメールはありません",
|
||||||
"The message waits on the server, so it goes out whether or not ihasmail is open.": "メールはサーバーで待機するため、ihasmail を開いていなくても送信されます。",
|
"The message waits on the server, so it goes out whether or not {app} is open.": "メールはサーバーで待機するため、{app} を開いていなくても送信されます。",
|
||||||
"This server holds a message for up to {span}.": "このサーバーがメールを保持できるのは最長 {span} です。",
|
"This server holds a message for up to {span}.": "このサーバーがメールを保持できるのは最長 {span} です。",
|
||||||
"Date and time to send": "送信する日時",
|
"Date and time to send": "送信する日時",
|
||||||
"Undo send window": "送信取り消しの猶予時間",
|
"Undo send window": "送信取り消しの猶予時間",
|
||||||
@@ -732,7 +731,7 @@ export const catalog: Catalog = {
|
|||||||
"Sections": "セクション",
|
"Sections": "セクション",
|
||||||
"General": "一般",
|
"General": "一般",
|
||||||
"Appearance": "外観",
|
"Appearance": "外観",
|
||||||
"Make ihasmail yours.": "ihasmail を自分好みに整えましょう。",
|
"Make {app} yours.": "{app} \u3092\u81ea\u5206\u597d\u307f\u306b\u6574\u3048\u307e\u3057\u3087\u3046\u3002",
|
||||||
"Reading": "閲覧",
|
"Reading": "閲覧",
|
||||||
"Reading pane": "プレビューウィンドウ",
|
"Reading pane": "プレビューウィンドウ",
|
||||||
"Right of the list": "一覧の右",
|
"Right of the list": "一覧の右",
|
||||||
@@ -749,6 +748,7 @@ export const catalog: Catalog = {
|
|||||||
"Attachment reminder": "添付忘れの確認",
|
"Attachment reminder": "添付忘れの確認",
|
||||||
"Warn when the message mentions an attachment but none is attached.": "本文で添付に触れているのにファイルが添付されていないとき警告します。",
|
"Warn when the message mentions an attachment but none is attached.": "本文で添付に触れているのにファイルが添付されていないとき警告します。",
|
||||||
"Spell check while typing": "入力中にスペルチェックする",
|
"Spell check while typing": "入力中にスペルチェックする",
|
||||||
|
"Open the composer full screen": "メールを全画面で作成",
|
||||||
"Confirm before deleting": "削除前に確認する",
|
"Confirm before deleting": "削除前に確認する",
|
||||||
"Show message snippets": "本文の抜粋を表示する",
|
"Show message snippets": "本文の抜粋を表示する",
|
||||||
"Preview the first line of each message in the list.": "一覧に各メールの 1 行目を表示します。",
|
"Preview the first line of each message in the list.": "一覧に各メールの 1 行目を表示します。",
|
||||||
@@ -828,7 +828,8 @@ export const catalog: Catalog = {
|
|||||||
"Reset to defaults": "既定に戻す",
|
"Reset to defaults": "既定に戻す",
|
||||||
"Default mail app": "既定のメールアプリ",
|
"Default mail app": "既定のメールアプリ",
|
||||||
"Documentation": "ドキュメント",
|
"Documentation": "ドキュメント",
|
||||||
"About ihasmail": "ihasmail について",
|
"About {app}": "{app} について",
|
||||||
|
"Built on {project}": "{project} をベースに構築",
|
||||||
"About": "情報",
|
"About": "情報",
|
||||||
"Server": "サーバー",
|
"Server": "サーバー",
|
||||||
"Server capabilities": "サーバーの機能",
|
"Server capabilities": "サーバーの機能",
|
||||||
@@ -929,6 +930,7 @@ export const catalog: Catalog = {
|
|||||||
"Turn off two-factor authentication": "2 段階認証をオフにする",
|
"Turn off two-factor authentication": "2 段階認証をオフにする",
|
||||||
"Your password alone will be enough to sign in again.": "以後はパスワードだけでサインインできるようになります。",
|
"Your password alone will be enough to sign in again.": "以後はパスワードだけでサインインできるようになります。",
|
||||||
"App passwords": "アプリパスワード",
|
"App passwords": "アプリパスワード",
|
||||||
|
"Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "組織では {app} と JMAP アプリのみが許可されているため、スマートフォンやパソコンのメールアプリはこのアカウントに接続できません。",
|
||||||
"New app password for": "アプリパスワードの用途",
|
"New app password for": "アプリパスワードの用途",
|
||||||
"Your new app password": "新しいアプリパスワード",
|
"Your new app password": "新しいアプリパスワード",
|
||||||
"Secret": "シークレット",
|
"Secret": "シークレット",
|
||||||
@@ -957,8 +959,8 @@ export const catalog: Catalog = {
|
|||||||
"Notifications": "通知",
|
"Notifications": "通知",
|
||||||
"Notifications are blocked in your browser settings.": "ブラウザーの設定で通知がブロックされています。",
|
"Notifications are blocked in your browser settings.": "ブラウザーの設定で通知がブロックされています。",
|
||||||
"Not supported in this browser.": "このブラウザーでは利用できません。",
|
"Not supported in this browser.": "このブラウザーでは利用できません。",
|
||||||
"Desktop notifications while ihasmail is open": "ihasmail を開いている間のデスクトップ通知",
|
"Desktop notifications while {app} is open": "{app} を開いている間のデスクトップ通知",
|
||||||
"Notify me even when ihasmail is closed": "ihasmail を閉じているときも通知する",
|
"Notify me even when {app} is closed": "{app} を閉じているときも通知する",
|
||||||
"Play a sound for new mail": "新着メールで音を鳴らす",
|
"Play a sound for new mail": "新着メールで音を鳴らす",
|
||||||
"Test notification": "通知をテスト",
|
"Test notification": "通知をテスト",
|
||||||
"Background notifications are on": "バックグラウンド通知はオンです",
|
"Background notifications are on": "バックグラウンド通知はオンです",
|
||||||
@@ -1072,7 +1074,7 @@ export const catalog: Catalog = {
|
|||||||
// ── Settings prose ─────────────────────────────────────────────────
|
// ── Settings prose ─────────────────────────────────────────────────
|
||||||
"tell us about it": "お知らせください",
|
"tell us about it": "お知らせください",
|
||||||
"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}.": "この翻訳は AI が生成したもので、母語話者による確認をまだ受けていません。そのため、話者による確認が済むまで Beta と表示しています。おかしいと感じた箇所は、ぜひ{report}。",
|
"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}.": "この翻訳は AI が生成したもので、母語話者による確認をまだ受けていません。そのため、話者による確認が済むまで Beta と表示しています。おかしいと感じた箇所は、ぜひ{report}。",
|
||||||
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "ここに並ぶのは ihasmail の翻訳がある言語だけです。そのため、この一覧は翻訳が届いてから増えていきます。訳文のない言語を選べるようにすると、実際とは違う言語のページだと名乗ることになってしまいます。",
|
"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.": "\u3053\u3053\u306b\u4e26\u3076\u306e\u306f {app} \u306e\u7ffb\u8a33\u304c\u3042\u308b\u8a00\u8a9e\u3060\u3051\u3067\u3059\u3002\u305d\u306e\u305f\u3081\u3001\u3053\u306e\u4e00\u89a7\u306f\u7ffb\u8a33\u304c\u5c4a\u3044\u3066\u304b\u3089\u5897\u3048\u3066\u3044\u304d\u307e\u3059\u3002\u8a33\u6587\u306e\u306a\u3044\u8a00\u8a9e\u3092\u9078\u3079\u308b\u3088\u3046\u306b\u3059\u308b\u3068\u3001\u5b9f\u969b\u3068\u306f\u9055\u3046\u8a00\u8a9e\u306e\u30da\u30fc\u30b8\u3060\u3068\u540d\u4e57\u308b\u3053\u3068\u306b\u306a\u3063\u3066\u3057\u307e\u3044\u307e\u3059\u3002",
|
||||||
"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.": "これは「一般」の{setting}とは別の設定です。あちらは日付・時刻・数値の書き方を決めます。英語の画面にドイツ語式の日付を組み合わせることも、その逆もできます。",
|
"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.": "これは「一般」の{setting}とは別の設定です。あちらは日付・時刻・数値の書き方を決めます。英語の画面にドイツ語式の日付を組み合わせることも、その逆もできます。",
|
||||||
"Defaults for the calendar views and new events.": "カレンダーの表示と新しい予定の既定値です。",
|
"Defaults for the calendar views and new events.": "カレンダーの表示と新しい予定の既定値です。",
|
||||||
"Replies will go to this address instead of the From address": "返信は差出人アドレスではなく、このアドレスに届きます",
|
"Replies will go to this address instead of the From address": "返信は差出人アドレスではなく、このアドレスに届きます",
|
||||||
@@ -1080,31 +1082,31 @@ export const catalog: Catalog = {
|
|||||||
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "新しい差出人には、このアカウントが送信を許可されているアドレス(サーバーで設定されたエイリアス)を使う必要があります。",
|
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "新しい差出人には、このアカウントが送信を許可されているアドレス(サーバーで設定されたエイリアス)を使う必要があります。",
|
||||||
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "作成時には表示されません。メールの受信は続き、再び表示すればこの差出人で送信することもできます。",
|
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "作成時には表示されません。メールの受信は続き、再び表示すればこの差出人で送信することもできます。",
|
||||||
"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.": "差出人とは、それぞれ名前・返信先・署名を持つ送信用アドレスのことです。作成時には既定の差出人があらかじめ選ばれます。返信を差出人アドレス以外へ届けたい場合は、返信先を設定してください。",
|
"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.": "差出人とは、それぞれ名前・返信先・署名を持つ送信用アドレスのことです。作成時には既定の差出人があらかじめ選ばれます。返信を差出人アドレス以外へ届けたい場合は、返信先を設定してください。",
|
||||||
"This signature is larger than the server's {limit}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.": "この署名はサーバーの上限 {limit} バイトを超えています。ihasmail は完全版を「ファイル」に保存し、サーバーには短いテキスト版を置きます。他のメールクライアントにはテキスト版が表示されます。",
|
"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.": "\u3053\u306e\u7f72\u540d\u306f\u30b5\u30fc\u30d0\u30fc\u306e\u4e0a\u9650 {limit} \u30d0\u30a4\u30c8\u3092\u8d85\u3048\u3066\u3044\u307e\u3059\u3002{app} \u306f\u5b8c\u5168\u7248\u3092\u300c\u30d5\u30a1\u30a4\u30eb\u300d\u306b\u4fdd\u5b58\u3057\u3001\u30b5\u30fc\u30d0\u30fc\u306b\u306f\u77ed\u3044\u30c6\u30ad\u30b9\u30c8\u7248\u3092\u7f6e\u304d\u307e\u3059\u3002\u4ed6\u306e\u30e1\u30fc\u30eb\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u306b\u306f\u30c6\u30ad\u30b9\u30c8\u7248\u304c\u8868\u793a\u3055\u308c\u307e\u3059\u3002",
|
||||||
"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.": "Outlook 形式の分類です。右クリックメニューや予定の編集画面から予定に割り当てられます。分類名は予定に保存されるため、他のクライアントにも同期されます。",
|
"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.": "Outlook 形式の分類です。右クリックメニューや予定の編集画面から予定に割り当てられます。分類名は予定に保存されるため、他のクライアントにも同期されます。",
|
||||||
"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.": "プレーンテキストのメールは、もともとテーマに従います。これをオンにすると、独自の配色を持たない HTML メールもテーマに従い、白いカードの上に置かれなくなります。自分でスタイルを指定しているメールは、差出人が作ったとおりに表示されます。",
|
"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.": "プレーンテキストのメールは、もともとテーマに従います。これをオンにすると、独自の配色を持たない HTML メールもテーマに従い、白いカードの上に置かれなくなります。自分でスタイルを指定しているメールは、差出人が作ったとおりに表示されます。",
|
||||||
"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.": "タッチ画面では、メールを横にドラッグすると操作できます。各方向に 1 つの操作を割り当てるか、何も割り当てないかを選べます。この設定はアカウントに従うため、スマートフォンとタブレットで揃います。マウスはこの設定を無視し、これまでどおりメールをフォルダーへドラッグします。",
|
"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.": "タッチ画面では、メールを横にドラッグすると操作できます。各方向に 1 つの操作を割り当てるか、何も割り当てないかを選べます。この設定はアカウントに従うため、スマートフォンとタブレットで揃います。マウスはこの設定を無視し、これまでどおりメールをフォルダーへドラッグします。",
|
||||||
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "この画面にはタッチ機能がないため、ここでの設定は動作に影響しません。スマートフォンやタブレットに反映されます。",
|
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "この画面にはタッチ機能がないため、ここでの設定は動作に影響しません。スマートフォンやタブレットに反映されます。",
|
||||||
"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.": "メールを長押しすると選択、フォルダーを長押しするとメニューが開きます。メール一覧の上端を下に引くと、新着メールを確認できます。",
|
"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.": "メールを長押しすると選択、フォルダーを長押しするとメニューが開きます。メール一覧の上端を下に引くと、新着メールを確認できます。",
|
||||||
"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.": "開封確認を返すと、要求した相手にこのアドレスが使われていることと、いつ読んだかが伝わります。しかも送り先を決めるのは差出人です。そのため自動で返す選択肢はありません。一括配信のメール、メーリングリスト、自動送信と記されたメールには、そもそも確認を返す選択肢を表示しません。",
|
"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.": "開封確認を返すと、要求した相手にこのアドレスが使われていることと、いつ読んだかが伝わります。しかも送り先を決めるのは差出人です。そのため自動で返す選択肢はありません。一括配信のメール、メーリングリスト、自動送信と記されたメールには、そもそも確認を返す選択肢を表示しません。",
|
||||||
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.": "このブラウザーは {scheme} リンクのアプリを登録できません。とくに Safari にはその API がありません。アプリとしてインストールすれば、OS の側で ihasmail を既定にすることはできます。",
|
"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.": "\u3053\u306e\u30d6\u30e9\u30a6\u30b6\u30fc\u306f {scheme} \u30ea\u30f3\u30af\u306e\u30a2\u30d7\u30ea\u3092\u767b\u9332\u3067\u304d\u307e\u305b\u3093\u3002\u3068\u304f\u306b Safari \u306b\u306f\u305d\u306e API \u304c\u3042\u308a\u307e\u305b\u3093\u3002\u30a2\u30d7\u30ea\u3068\u3057\u3066\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3059\u308c\u3070\u3001OS \u306e\u5074\u3067 {app} \u3092\u65e2\u5b9a\u306b\u3059\u308b\u3053\u3068\u306f\u3067\u304d\u307e\u3059\u3002",
|
||||||
"Registering for {scheme} links requires a secure (HTTPS) connection.": "{scheme} リンクの登録には安全な接続(HTTPS)が必要です。",
|
"Registering for {scheme} links requires a secure (HTTPS) connection.": "{scheme} リンクの登録には安全な接続(HTTPS)が必要です。",
|
||||||
"Open {scheme} links — in web pages, documents and other apps — in ihasmail 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 › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).": "ウェブページ・文書・他のアプリにある {scheme} リンクを、デスクトップのメールクライアントではなく ihasmail で開きます。ブラウザーが確認を求め、あとからブラウザー自身の設定で変更できます(Chrome: 設定 › プライバシーとセキュリティ › サイトの設定 › プロトコル ハンドラ、Firefox: 設定 › 一般 › プログラム)。",
|
"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).": "\u30a6\u30a7\u30d6\u30da\u30fc\u30b8\u30fb\u6587\u66f8\u30fb\u4ed6\u306e\u30a2\u30d7\u30ea\u306b\u3042\u308b {scheme} \u30ea\u30f3\u30af\u3092\u3001\u30c7\u30b9\u30af\u30c8\u30c3\u30d7\u306e\u30e1\u30fc\u30eb\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u3067\u306f\u306a\u304f {app} \u3067\u958b\u304d\u307e\u3059\u3002\u30d6\u30e9\u30a6\u30b6\u30fc\u304c\u78ba\u8a8d\u3092\u6c42\u3081\u3001\u3042\u3068\u304b\u3089\u30d6\u30e9\u30a6\u30b6\u30fc\u81ea\u8eab\u306e\u8a2d\u5b9a\u3067\u5909\u66f4\u3067\u304d\u307e\u3059\uff08Chrome: \u8a2d\u5b9a \u203a \u30d7\u30e9\u30a4\u30d0\u30b7\u30fc\u3068\u30bb\u30ad\u30e5\u30ea\u30c6\u30a3 \u203a \u30b5\u30a4\u30c8\u306e\u8a2d\u5b9a \u203a \u30d7\u30ed\u30c8\u30b3\u30eb \u30cf\u30f3\u30c9\u30e9\u3001Firefox: \u8a2d\u5b9a \u203a \u4e00\u822c \u203a \u30d7\u30ed\u30b0\u30e9\u30e0\uff09\u3002",
|
||||||
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "このブラウザーで登録を要求しました。実際に有効になるかどうかはブラウザー次第です。メールのリンクが別のアプリで開く場合は、ブラウザーの設定をご確認ください。",
|
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "このブラウザーで登録を要求しました。実際に有効になるかどうかはブラウザー次第です。メールのリンクが別のアプリで開く場合は、ブラウザーの設定をご確認ください。",
|
||||||
"For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.": "システム全体の既定にするには、まず ihasmail をアプリとしてインストールしてください(Chrome ではアドレスバーのインストールアイコン)。以後、OS がメールアプリを尋ねる場面で ihasmail を直接選べるようになります。",
|
"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.": "システム全体の既定にするには、まず {app} をアプリとしてインストールしてください(Chrome ではアドレスバーのインストールアイコン)。以後、OS がメールアプリを尋ねる場面で {app} を直接選べるようになります。",
|
||||||
"Needs a browser with the Push API and a mail server that publishes a push key.": "Push API に対応したブラウザーと、プッシュ用の鍵を公開しているメールサーバーが必要です。",
|
"Needs a browser with the Push API and a mail server that publishes a push key.": "Push API に対応したブラウザーと、プッシュ用の鍵を公開しているメールサーバーが必要です。",
|
||||||
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "メールサーバーが通知をブラウザーへ直接届けるため、ihasmail のタブを開いていなくても、差出人と件名つきで届きます。ただしブラウザーは起動している必要があります。完全に終了すると、通知は次に起動したときにまとめて届きます。",
|
"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.": "\u30e1\u30fc\u30eb\u30b5\u30fc\u30d0\u30fc\u304c\u901a\u77e5\u3092\u30d6\u30e9\u30a6\u30b6\u30fc\u3078\u76f4\u63a5\u5c4a\u3051\u308b\u305f\u3081\u3001{app} \u306e\u30bf\u30d6\u3092\u958b\u3044\u3066\u3044\u306a\u304f\u3066\u3082\u3001\u5dee\u51fa\u4eba\u3068\u4ef6\u540d\u3064\u304d\u3067\u5c4a\u304d\u307e\u3059\u3002\u305f\u3060\u3057\u30d6\u30e9\u30a6\u30b6\u30fc\u306f\u8d77\u52d5\u3057\u3066\u3044\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\u5b8c\u5168\u306b\u7d42\u4e86\u3059\u308b\u3068\u3001\u901a\u77e5\u306f\u6b21\u306b\u8d77\u52d5\u3057\u305f\u3068\u304d\u306b\u307e\u3068\u3081\u3066\u5c4a\u304d\u307e\u3059\u3002",
|
||||||
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "メールサーバーはこのブラウザーを呼び起こせますが、差出人や件名は含めません。ブラウザーは起動している必要があります。",
|
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "メールサーバーはこのブラウザーを呼び起こせますが、差出人や件名は含めません。ブラウザーは起動している必要があります。",
|
||||||
"This is what a new-mail notification looks like.": "新着メールの通知はこのように表示されます。",
|
"This is what a new-mail notification looks like.": "新着メールの通知はこのように表示されます。",
|
||||||
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "{user} としてサインインしています。パスワードがブラウザーに保存されることはありません。サーバーが Stalwart との通信のために、セッションごとに暗号化して保持します。",
|
"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.": "{user} としてサインインしています。パスワードがブラウザーに保存されることはありません。メールサーバーとの通信用に、サーバーがセッションごとに暗号化して保持します。",
|
||||||
"App passwords are managed by your mail administrator.": "アプリパスワードはメール管理者が管理しています。",
|
"App passwords are managed by your mail administrator.": "アプリパスワードはメール管理者が管理しています。",
|
||||||
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "パスワードを変更すると、他のウェブメールのセッションはサインアウトされます。アプリパスワードはそのまま使えます。",
|
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "パスワードを変更すると、他のウェブメールのセッションはサインアウトされます。アプリパスワードはそのまま使えます。",
|
||||||
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "このアカウントでは 2 段階認証が有効です。ihasmail はまだ確認コードでのサインインに対応していないため、他のデバイスからサインインするにはアプリパスワードが必要です。ここで 2 段階認証をオフにすることもできます。",
|
"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.": "\u3053\u306e\u30a2\u30ab\u30a6\u30f3\u30c8\u3067\u306f 2 \u6bb5\u968e\u8a8d\u8a3c\u304c\u6709\u52b9\u3067\u3059\u3002{app} \u306f\u307e\u3060\u78ba\u8a8d\u30b3\u30fc\u30c9\u3067\u306e\u30b5\u30a4\u30f3\u30a4\u30f3\u306b\u5bfe\u5fdc\u3057\u3066\u3044\u306a\u3044\u305f\u3081\u3001\u4ed6\u306e\u30c7\u30d0\u30a4\u30b9\u304b\u3089\u30b5\u30a4\u30f3\u30a4\u30f3\u3059\u308b\u306b\u306f\u30a2\u30d7\u30ea\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u5fc5\u8981\u3067\u3059\u3002\u3053\u3053\u3067 2 \u6bb5\u968e\u8a8d\u8a3c\u3092\u30aa\u30d5\u306b\u3059\u308b\u3053\u3068\u3082\u3067\u304d\u307e\u3059\u3002",
|
||||||
"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.": "メールアプリやデバイスごとに用意する別のパスワードで、単独で無効化できます。アプリパスワードは 2 段階認証の確認コードを省くため、コードを入力できないアプリでも使えます。",
|
"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.": "メールアプリやデバイスごとに用意する別のパスワードで、単独で無効化できます。アプリパスワードは 2 段階認証の確認コードを省くため、コードを入力できないアプリでも使えます。",
|
||||||
"Copy it into {name} now — it isn't shown again.": "いま {name} にコピーしてください。二度と表示されません。",
|
"Copy it into {name} now — it isn't shown again.": "いま {name} にコピーしてください。二度と表示されません。",
|
||||||
"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.": "ディレクトリに他のユーザーが見つからないため、新しく追加することはできません。すでに設定されている共有は下に表示され、解除はできます。",
|
"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.": "ディレクトリに他のユーザーが見つからないため、新しく追加することはできません。すでに設定されている共有は下に表示され、解除はできます。",
|
||||||
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart はメールクライアントにバージョン番号を公開しないため、ihasmail はサーバーが示すエディションだけを表示します。ihasmail には 0.16 以降が必要で、それより古いサーバーへのサインインは拒否されます。",
|
"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 \u306f\u30e1\u30fc\u30eb\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u306b\u30d0\u30fc\u30b8\u30e7\u30f3\u756a\u53f7\u3092\u516c\u958b\u3057\u306a\u3044\u305f\u3081\u3001{app} \u306f\u30b5\u30fc\u30d0\u30fc\u304c\u793a\u3059\u30a8\u30c7\u30a3\u30b7\u30e7\u30f3\u3060\u3051\u3092\u8868\u793a\u3057\u307e\u3059\u3002{app} \u306b\u306f 0.16 \u4ee5\u964d\u304c\u5fc5\u8981\u3067\u3001\u305d\u308c\u3088\u308a\u53e4\u3044\u30b5\u30fc\u30d0\u30fc\u3078\u306e\u30b5\u30a4\u30f3\u30a4\u30f3\u306f\u62d2\u5426\u3055\u308c\u307e\u3059\u3002",
|
||||||
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "ihasmail 自身のバージョンは、ビルド元となったコミットの日付と、そのコミットの出どころを並べたものです。{example} は 2026 年 8 月 30 日付のコミットから作られ、そのコミットはプルリクエスト 129 を通って届きました。プルリクエストを経ていないコミットは、代わりに短い SHA が付きます — {sha}。バージョンには Stalwart に関する情報をあえて含めていません。このビルドがサーバーに求めるものは、上の行に示されています。",
|
"{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.": "{app} \u81ea\u8eab\u306e\u30d0\u30fc\u30b8\u30e7\u30f3\u306f\u3001\u30d3\u30eb\u30c9\u5143\u3068\u306a\u3063\u305f\u30b3\u30df\u30c3\u30c8\u306e\u65e5\u4ed8\u3068\u3001\u305d\u306e\u30b3\u30df\u30c3\u30c8\u306e\u51fa\u3069\u3053\u308d\u3092\u4e26\u3079\u305f\u3082\u306e\u3067\u3059\u3002{example} \u306f 2026 \u5e74 8 \u6708 30 \u65e5\u4ed8\u306e\u30b3\u30df\u30c3\u30c8\u304b\u3089\u4f5c\u3089\u308c\u3001\u305d\u306e\u30b3\u30df\u30c3\u30c8\u306f\u30d7\u30eb\u30ea\u30af\u30a8\u30b9\u30c8 129 \u3092\u901a\u3063\u3066\u5c4a\u304d\u307e\u3057\u305f\u3002\u30d7\u30eb\u30ea\u30af\u30a8\u30b9\u30c8\u3092\u7d4c\u3066\u3044\u306a\u3044\u30b3\u30df\u30c3\u30c8\u306f\u3001\u4ee3\u308f\u308a\u306b\u77ed\u3044 SHA \u304c\u4ed8\u304d\u307e\u3059 \u2014 {sha}\u3002\u30d0\u30fc\u30b8\u30e7\u30f3\u306b\u306f Stalwart \u306b\u95a2\u3059\u308b\u60c5\u5831\u3092\u3042\u3048\u3066\u542b\u3081\u3066\u3044\u307e\u305b\u3093\u3002\u3053\u306e\u30d3\u30eb\u30c9\u304c\u30b5\u30fc\u30d0\u30fc\u306b\u6c42\u3081\u308b\u3082\u306e\u306f\u3001\u4e0a\u306e\u884c\u306b\u793a\u3055\u308c\u3066\u3044\u307e\u3059\u3002",
|
||||||
|
|
||||||
// ── Constant labels ────────────────────────────────────────────────
|
// ── Constant labels ────────────────────────────────────────────────
|
||||||
"Add": "追加",
|
"Add": "追加",
|
||||||
@@ -1172,10 +1174,10 @@ export const catalog: Catalog = {
|
|||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "新規メール",
|
"New message": "新規メール",
|
||||||
"Start a new message with what was shared?": "共有された内容で新規メールを作成しますか?",
|
"Start a new message with what was shared?": "共有された内容で新規メールを作成しますか?",
|
||||||
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "ihasmail に何かが共有されました。「送信」を選ぶまで何も送信されません。共有した覚えがない場合は破棄してください。",
|
"Something was shared with {app}. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "{app} に何かが共有されました。「送信」を選ぶまで何も送信されません。共有した覚えがない場合は破棄してください。",
|
||||||
"Start a message": "メールを作成",
|
"Start a message": "メールを作成",
|
||||||
"New mail": "新着メール",
|
"New mail": "新着メール",
|
||||||
"Could not do that — open ihasmail and try again": "実行できませんでした - ihasmail を開いてやり直してください",
|
"Could not do that \u2014 open {app} and try again": "\u5b9f\u884c\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f - {app} \u3092\u958b\u3044\u3066\u3084\u308a\u76f4\u3057\u3066\u304f\u3060\u3055\u3044",
|
||||||
"Sending…": "送信中…",
|
"Sending…": "送信中…",
|
||||||
"Saving…": "保存中…",
|
"Saving…": "保存中…",
|
||||||
"Error": "エラー",
|
"Error": "エラー",
|
||||||
@@ -1223,6 +1225,20 @@ export const catalog: Catalog = {
|
|||||||
"Could not save filters: {error}": "フィルターを保存できませんでした: {error}",
|
"Could not save filters: {error}": "フィルターを保存できませんでした: {error}",
|
||||||
"Could not send the receipt: {error}": "開封確認を送信できませんでした: {error}",
|
"Could not send the receipt: {error}": "開封確認を送信できませんでした: {error}",
|
||||||
"Could not sign in.": "サインインできませんでした。",
|
"Could not sign in.": "サインインできませんでした。",
|
||||||
|
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "{user} としてサインインしています。このウェブメールがパスワードを見ることはありません。メールサーバーから受け取ったサインイン用トークンを、セッションごとに暗号化して保持します。",
|
||||||
|
"Mail server": "メールサーバー",
|
||||||
|
"You'll enter your password on your mail server's sign-in page.": "パスワードはメールサーバーのサインインページで入力します。",
|
||||||
|
"You'll sign in on your mail server's own page.": "メールサーバー自身のページでサインインします。",
|
||||||
|
"This sign-in didn't start in this browser. Try again.": "このサインインはこのブラウザーで開始されたものではありません。もう一度お試しください。",
|
||||||
|
"The sign-in took too long. Try again.": "サインインに時間がかかりすぎました。もう一度お試しください。",
|
||||||
|
"Sign-in was cancelled.": "サインインはキャンセルされました。",
|
||||||
|
"The mail server didn't accept the sign-in. Try again.": "メールサーバーがサインインを受け付けませんでした。もう一度お試しください。",
|
||||||
|
"That account is on a different mail server than the address you entered. Sign in with that address.": "そのアカウントは、入力したアドレスとは別のメールサーバーにあります。そのアドレスでサインインしてください。",
|
||||||
|
"This mail server isn't supported.": "このメールサーバーには対応していません。",
|
||||||
|
"Couldn't reach the mail server. Try again in a moment.": "メールサーバーに接続できませんでした。しばらくしてからもう一度お試しください。",
|
||||||
|
"Your password was changed. Sign in with the new one.": "パスワードが変更されました。新しいパスワードでサインインしてください。",
|
||||||
|
"Your sign-in ended. Sign in again.": "サインインが終了しました。もう一度サインインしてください。",
|
||||||
|
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "パスワードを変更すると、この画面を含むすべての場所からサインアウトされ、新しいパスワードで再度サインインします。アプリパスワードはそのまま使えます。",
|
||||||
"Could not store image: {error}": "画像を保存できませんでした: {error}",
|
"Could not store image: {error}": "画像を保存できませんでした: {error}",
|
||||||
"Could not update labels: {error}": "ラベルを更新できませんでした: {error}",
|
"Could not update labels: {error}": "ラベルを更新できませんでした: {error}",
|
||||||
"Could not update the Scheduled folder: {error}": "「送信予約」フォルダーを更新できませんでした: {error}",
|
"Could not update the Scheduled folder: {error}": "「送信予約」フォルダーを更新できませんでした: {error}",
|
||||||
@@ -1345,7 +1361,7 @@ export const catalog: Catalog = {
|
|||||||
"Undo window: {seconds}s": "取り消せる時間: {seconds} 秒",
|
"Undo window: {seconds}s": "取り消せる時間: {seconds} 秒",
|
||||||
"You're all caught up": "未読はありません",
|
"You're all caught up": "未読はありません",
|
||||||
"Your browser refused the request: {error}": "ブラウザーが要求を拒否しました: {error}",
|
"Your browser refused the request: {error}": "ブラウザーが要求を拒否しました: {error}",
|
||||||
"Your browser will ask whether to open mail links in ihasmail": "メールのリンクを ihasmail で開くかどうか、ブラウザーが確認します",
|
"Your browser will ask whether to open mail links in {app}": "メールのリンクを {app} で開くかどうか、ブラウザーが確認します",
|
||||||
"Your message mentions an attachment, but nothing is attached.": "本文で添付ファイルに触れていますが、何も添付されていません。",
|
"Your message mentions an attachment, but nothing is attached.": "本文で添付ファイルに触れていますが、何も添付されていません。",
|
||||||
"event": "予定",
|
"event": "予定",
|
||||||
"Hide password": "パスワードを隠す",
|
"Hide password": "パスワードを隠す",
|
||||||
@@ -1416,7 +1432,7 @@ export const catalog: Catalog = {
|
|||||||
"Also count these domains as inside": "次のドメインも社内として扱う",
|
"Also count these domains as inside": "次のドメインも社内として扱う",
|
||||||
"Always": "常に",
|
"Always": "常に",
|
||||||
"Always showing images from": "常に画像を表示する差出人",
|
"Always showing images from": "常に画像を表示する差出人",
|
||||||
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.": "差出人のサーバーから読み込まれた画像は、メールが開かれたこと、その時刻、おおよその場所を差出人に伝えます。許可した画像はブラウザーではなく ihasmail のサーバーが取得するため、差出人にはそのいずれも伝わりません。",
|
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.": "差出人のサーバーから読み込まれた画像は、メールが開かれたこと、その時刻、おおよその場所を差出人に伝えます。許可した画像はブラウザーではなく {app} のサーバーが取得するため、差出人にはそのいずれも伝わりません。",
|
||||||
"Applies to": "適用先",
|
"Applies to": "適用先",
|
||||||
"Archive and next": "アーカイブして次へ",
|
"Archive and next": "アーカイブして次へ",
|
||||||
"Archive by month": "月ごとにアーカイブ",
|
"Archive by month": "月ごとにアーカイブ",
|
||||||
@@ -1658,8 +1674,8 @@ export const catalog: Catalog = {
|
|||||||
"Fingerprint": "フィンガープリント",
|
"Fingerprint": "フィンガープリント",
|
||||||
"Hide details": "詳細を隠す",
|
"Hide details": "詳細を隠す",
|
||||||
"Issued by": "発行者",
|
"Issued by": "発行者",
|
||||||
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "OpenPGP で署名されており、ihasmail には送信者の公開鍵を取得する手段がありません。",
|
"It is signed with OpenPGP, and {app} has no way to fetch the sender's public key.": "OpenPGP で署名されており、{app} には送信者の公開鍵を取得する手段がありません。",
|
||||||
"It uses a signature algorithm ihasmail cannot check yet.": "ihasmail がまだ検証できない署名アルゴリズムが使われています。",
|
"It uses a signature algorithm {app} cannot check yet.": "{app} がまだ検証できない署名アルゴリズムが使われています。",
|
||||||
"It was made with a certificate belonging to {name}, which does not cover this address.": "{name} の証明書で署名されており、この証明書はこのアドレスを対象にしていません。",
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "{name} の証明書で署名されており、この証明書はこのアドレスを対象にしていません。",
|
||||||
"Previous fingerprint": "以前のフィンガープリント",
|
"Previous fingerprint": "以前のフィンガープリント",
|
||||||
"Signed at": "署名日時",
|
"Signed at": "署名日時",
|
||||||
@@ -1676,18 +1692,39 @@ export const catalog: Catalog = {
|
|||||||
"The signature is not for this sender.": "この署名はこの送信者のものではありません。",
|
"The signature is not for this sender.": "この署名はこの送信者のものではありません。",
|
||||||
"The signed part is missing either the message or the signature.": "署名された部分に、本文か署名のどちらかが欠けています。",
|
"The signed part is missing either the message or the signature.": "署名された部分に、本文か署名のどちらかが欠けています。",
|
||||||
"The signer has changed.": "署名者が変わりました。",
|
"The signer has changed.": "署名者が変わりました。",
|
||||||
"This message is signed, and ihasmail could not check the signature.": "このメールには署名がありますが、ihasmail は署名を検証できませんでした。",
|
"This message is signed, and {app} could not check the signature.": "このメールには署名がありますが、{app} は署名を検証できませんでした。",
|
||||||
"This signature does not check out.": "この署名は正しくありません。",
|
"This signature does not check out.": "この署名は正しくありません。",
|
||||||
"Valid until": "有効期限",
|
"Valid until": "有効期限",
|
||||||
"a different certificate": "別の証明書",
|
"a different certificate": "別の証明書",
|
||||||
"an unnamed signer": "名前のない署名者",
|
"an unnamed signer": "名前のない署名者",
|
||||||
"as claimed by the signer": "署名者の申告による",
|
"as claimed by the signer": "署名者の申告による",
|
||||||
"first seen {date}": "初回は {date}",
|
"first seen {date}": "初回は {date}",
|
||||||
"ihasmail will tell you if a later message from this address is signed by anybody else.": "このアドレスからの以降のメールが別の人の署名だった場合、ihasmail がお知らせします。",
|
"{app} will tell you if a later message from this address is signed by anybody else.": "このアドレスからの以降のメールが別の人の署名だった場合、{app} がお知らせします。",
|
||||||
"itself, or an issuer it does not name": "自分自身、または名前のない発行者",
|
"itself, or an issuer it does not name": "自分自身、または名前のない発行者",
|
||||||
"no address": "アドレスなし",
|
"no address": "アドレスなし",
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"Legacy mail apps": "従来のプロトコルを使うメールアプリ",
|
||||||
|
"Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.": "{tenant} ではオフです。そのドメインにサインインできるのは {app} と JMAP アプリのみです。",
|
||||||
|
"On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.": "{tenant} ではオンです。そのドメインではメールアプリが IMAP、POP3、ManageSieve を利用できます。",
|
||||||
|
"Turn legacy protocols back on": "従来のメールプロトコルを再びオンにする",
|
||||||
|
"Turn off legacy protocols…": "従来のメールプロトコルをオフにする…",
|
||||||
|
"No account used a legacy mail app in the last 30 days.": "過去 30 日間に従来のプロトコルでメールアプリを使用したアカウントはありません。",
|
||||||
|
"Their mail apps will stop working the moment you turn this on:": "これをオンにした時点で、これらのメールアプリは動作しなくなります:",
|
||||||
|
"Only {app} and JMAP apps will work.": "{app} と JMAP アプリのみが動作します。",
|
||||||
|
"Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {tenant}.": "{tenant} のすべてのユーザーに対して、従来のメールプロトコル(IMAP、POP3、ManageSieve、メールアプリからの送信)がオフになります。",
|
||||||
|
"Phone and desktop mail apps will stop receiving and sending mail. That's iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.": "スマートフォンやパソコンのメールアプリでメールの送受信ができなくなります。iPhone と iPad のメール、Gmail と Outlook のアプリ、Outlook、Thunderbird、Apple Mail が対象です。これらのアプリにはサインインエラーが表示されます。",
|
||||||
|
"Filters managed from a mail app (ManageSieve) will stop working. Filters set in {app} keep working.": "メールアプリから管理しているフィルタ(ManageSieve)は動作しなくなります。{app} で設定したフィルタは引き続き動作します。",
|
||||||
|
"Incoming mail is not affected. Calendars and contacts are not affected.": "受信メールには影響しません。カレンダーと連絡先にも影響しません。",
|
||||||
|
"People keep full access through {app}, which can be installed as an app on phones and computers.": "{app} からは引き続きすべて利用できます。{app} はスマートフォンやパソコンにアプリとしてインストールできます。",
|
||||||
|
"Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and {app} (JMAP) are not affected and cannot be turned off here.": "メールアプリからの送信(SMTP submission)は動作しなくなりますが、ポートは開いたままです。メールアプリにはサインインできないことが通知されます。受信メール(SMTP)と {app}(JMAP)には影響せず、ここでオフにすることはできません。",
|
||||||
|
"You can turn legacy protocols back on at any time.": "従来のメールプロトコルはいつでも再びオンにできます。",
|
||||||
|
"To confirm, type {phrase}": "確認のため {phrase} と入力してください",
|
||||||
|
"Turn off legacy protocols": "従来のメールプロトコルをオフにする",
|
||||||
|
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "組織では従来のメールプロトコルがオフになっています。サインインできるのは {app} と JMAP アプリのみです。",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"{n} accounts used a legacy mail app in the last 30 days.": {other: "過去 30 日間に {n} 件のアカウントが従来のプロトコルでメールアプリを使用しました。"},
|
||||||
// ── Administration: domains ────────────────────────────────────
|
// ── Administration: domains ────────────────────────────────────
|
||||||
"{n} accounts use this domain. Move or delete them first.": { other: "{n} 件のアカウントがこのドメインを使用しています。先に移動または削除してください。" },
|
"{n} accounts use this domain. Move or delete them first.": { other: "{n} 件のアカウントがこのドメインを使用しています。先に移動または削除してください。" },
|
||||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { other: "サーバーはこのドメイン宛てのメールを受け付けなくなり、{n} 個の DKIM 鍵も削除されます。元に戻すことはできません。" },
|
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { other: "サーバーはこのドメイン宛てのメールを受け付けなくなり、{n} 個の DKIM 鍵も削除されます。元に戻すことはできません。" },
|
||||||
|
|||||||
+112
-76
@@ -22,7 +22,6 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
* which is ordinary good Dutch UI and sidesteps it entirely.
|
* which is ordinary good Dutch UI and sidesteps it entirely.
|
||||||
*
|
*
|
||||||
* Terminology, fixed once so it cannot drift:
|
* Terminology, fixed once so it cannot drift:
|
||||||
* --- Used capitals on all words for unity ---
|
|
||||||
* Inbox Postvak IN Archive (verb) Archiveren
|
* Inbox Postvak IN Archive (verb) Archiveren
|
||||||
* Drafts Concepten Delete Verwijderen
|
* Drafts Concepten Delete Verwijderen
|
||||||
* Sent Verzonden Move to Verplaatsen naar
|
* Sent Verzonden Move to Verplaatsen naar
|
||||||
@@ -135,8 +134,8 @@ export const catalog: Catalog = {
|
|||||||
"The numbers your role can see, as the server reports them.": "De cijfers die uw rol mag zien, zoals de server ze meldt.",
|
"The numbers your role can see, as the server reports them.": "De cijfers die uw rol mag zien, zoals de server ze meldt.",
|
||||||
"Nothing to show": "Niets om te tonen",
|
"Nothing to show": "Niets om te tonen",
|
||||||
"Could not be loaded": "Kon niet worden geladen",
|
"Could not be loaded": "Kon niet worden geladen",
|
||||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in de beheeromgeving van Stalwart.",
|
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Gedetailleerde statistieken, de bezorgwachtrij, logboeken en serverinstellingen vindt u in INBUXA Admin.",
|
||||||
"Open Stalwart admin": "Stalwart-beheer openen",
|
"Open INBUXA Admin": "INBUXA Admin openen",
|
||||||
"Default group role": "Standaardrol voor groepen",
|
"Default group role": "Standaardrol voor groepen",
|
||||||
"A group needs an address.": "Een groep heeft een adres nodig.",
|
"A group needs an address.": "Een groep heeft een adres nodig.",
|
||||||
"New group": "Nieuwe groep",
|
"New group": "Nieuwe groep",
|
||||||
@@ -205,10 +204,10 @@ export const catalog: Catalog = {
|
|||||||
"New role": "Nieuwe rol",
|
"New role": "Nieuwe rol",
|
||||||
"This role carries permissions yours doesn't, so you can view it but not change it.": "Deze rol heeft rechten die uw rol niet heeft, dus u kunt hem bekijken maar niet wijzigen.",
|
"This role carries permissions yours doesn't, so you can view it but not change it.": "Deze rol heeft rechten die uw rol niet heeft, dus u kunt hem bekijken maar niet wijzigen.",
|
||||||
"Your role lets you view roles but not change them.": "Met uw rol kunt u rollen bekijken, maar niet wijzigen.",
|
"Your role lets you view roles but not change them.": "Met uw rol kunt u rollen bekijken, maar niet wijzigen.",
|
||||||
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart geeft deze rol standaard aan {kinds}. Een wijziging hier geldt voor iedereen die hem zo heeft.",
|
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "De mailserver geeft deze rol standaard aan {kinds}. Een wijziging hier geldt voor iedereen die hem zo heeft.",
|
||||||
"Builds on": "Bouwt voort op",
|
"Builds on": "Bouwt voort op",
|
||||||
"Permissions": "Rechten",
|
"Permissions": "Rechten",
|
||||||
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart geeft standaard deze rol, dus hij kan niet worden verwijderd. Wijzig eerst de standaardwaarden in de beheeromgeving van Stalwart.",
|
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "De mailserver geeft standaard deze rol, dus hij kan niet worden verwijderd. Wijzig eerst de standaardwaarden in INBUXA Admin.",
|
||||||
"This role carries permissions yours doesn't.": "Deze rol heeft rechten die uw rol niet heeft.",
|
"This role carries permissions yours doesn't.": "Deze rol heeft rechten die uw rol niet heeft.",
|
||||||
"Create role": "Rol aanmaken",
|
"Create role": "Rol aanmaken",
|
||||||
"builds on this one": "bouwt voort op deze",
|
"builds on this one": "bouwt voort op deze",
|
||||||
@@ -236,7 +235,7 @@ export const catalog: Catalog = {
|
|||||||
"Delete role": "Rol verwijderen",
|
"Delete role": "Rol verwijderen",
|
||||||
"It can't be undone.": "Dit kan niet ongedaan worden gemaakt.",
|
"It can't be undone.": "Dit kan niet ongedaan worden gemaakt.",
|
||||||
"Type {name} to confirm": "Typ {name} om te bevestigen",
|
"Type {name} to confirm": "Typ {name} om te bevestigen",
|
||||||
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "De lijst met rechten van Stalwart kon niet worden geladen, dus rechten kunnen hier niet worden gewijzigd. ({reason})",
|
"The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "De lijst met rechten van de mailserver kon niet worden geladen, dus rechten kunnen hier niet worden gewijzigd. ({reason})",
|
||||||
"Named sets of permissions, given to accounts, groups and tenants.": "Benoemde sets rechten, toegekend aan accounts, groepen en tenants.",
|
"Named sets of permissions, given to accounts, groups and tenants.": "Benoemde sets rechten, toegekend aan accounts, groepen en tenants.",
|
||||||
"Search roles": "Rollen zoeken",
|
"Search roles": "Rollen zoeken",
|
||||||
"No roles match": "Geen rollen gevonden",
|
"No roles match": "Geen rollen gevonden",
|
||||||
@@ -259,11 +258,11 @@ export const catalog: Catalog = {
|
|||||||
"{used} used": "{used} gebruikt",
|
"{used} used": "{used} gebruikt",
|
||||||
"Your role lets you view tenants but not change them.": "Met uw rol kunt u tenants bekijken, maar niet wijzigen.",
|
"Your role lets you view tenants but not change them.": "Met uw rol kunt u tenants bekijken, maar niet wijzigen.",
|
||||||
"Logo": "Logo",
|
"Logo": "Logo",
|
||||||
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Een https-adres of een data-URL van een afbeelding. Stalwart toont het aan de mensen van de tenant waar het een logo toont.",
|
"An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Een https-adres of een data-URL van een afbeelding. De mailserver toont het aan de mensen van de tenant waar hij een logo toont.",
|
||||||
"What it holds": "Inhoud",
|
"What it holds": "Inhoud",
|
||||||
"{n} of {limit}": "{n} van {limit}",
|
"{n} of {limit}": "{n} van {limit}",
|
||||||
"Limits": "Limieten",
|
"Limits": "Limieten",
|
||||||
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart weigert meer aan te maken dan een limiet toestaat. Een leeg veld betekent geen limiet.",
|
"The mail server refuses to create more than a limit allows. An empty field is no limit.": "De mailserver weigert meer aan te maken dan een limiet toestaat. Een leeg veld betekent geen limiet.",
|
||||||
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Het meeste dat iemand in deze tenant kan worden toegestaan: hun eigen rollen worden beperkt tot wat deze toekennen. Alleen rollen waarvan u zelf de rechten hebt worden aangeboden.",
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Het meeste dat iemand in deze tenant kan worden toegestaan: hun eigen rollen worden beperkt tot wat deze toekennen. Alleen rollen waarvan u zelf de rechten hebt worden aangeboden.",
|
||||||
"Checking what is still in this tenant…": "Nagaan wat er nog in deze tenant zit…",
|
"Checking what is still in this tenant…": "Nagaan wat er nog in deze tenant zit…",
|
||||||
"It still holds accounts, domains or other things. Move them out first.": "Er zitten nog accounts, domeinen of andere dingen in. Verplaats die eerst.",
|
"It still holds accounts, domains or other things. Move them out first.": "Er zitten nog accounts, domeinen of andere dingen in. Verplaats die eerst.",
|
||||||
@@ -279,7 +278,6 @@ export const catalog: Catalog = {
|
|||||||
"Still holds {things}. Move them out first.": "Bevat nog {things}. Verplaats die eerst.",
|
"Still holds {things}. Move them out first.": "Bevat nog {things}. Verplaats die eerst.",
|
||||||
"Delete tenant": "Tenant verwijderen",
|
"Delete tenant": "Tenant verwijderen",
|
||||||
"Separate organizations on one server, each with its own people, domains and limits.": "Afzonderlijke organisaties op één server, elk met eigen mensen, domeinen en limieten.",
|
"Separate organizations on one server, each with its own people, domains and limits.": "Afzonderlijke organisaties op één server, elk met eigen mensen, domeinen en limieten.",
|
||||||
"Tenants are a Stalwart Enterprise feature.": "Tenants zijn een functie van Stalwart Enterprise.",
|
|
||||||
"Search tenants": "Tenants zoeken",
|
"Search tenants": "Tenants zoeken",
|
||||||
"No tenants match": "Geen tenants gevonden",
|
"No tenants match": "Geen tenants gevonden",
|
||||||
"No tenants yet": "Nog geen tenants",
|
"No tenants yet": "Nog geen tenants",
|
||||||
@@ -507,7 +505,7 @@ export const catalog: Catalog = {
|
|||||||
"Waiting on the server — goes out {when}.": "Wacht op de server — wordt {when} verzonden.",
|
"Waiting on the server — goes out {when}.": "Wacht op de server — wordt {when} verzonden.",
|
||||||
"Scheduled — click to clear the schedule": "Gepland — klik om de planning te wissen",
|
"Scheduled — click to clear the schedule": "Gepland — klik om de planning te wissen",
|
||||||
"Nothing scheduled": "Niets gepland",
|
"Nothing scheduled": "Niets gepland",
|
||||||
"The message waits on the server, so it goes out whether or not ihasmail is open.": "Het bericht wacht op de server en wordt verzonden, of ihasmail nu open is of niet.",
|
"The message waits on the server, so it goes out whether or not {app} is open.": "Het bericht wacht op de server en wordt verzonden, of {app} nu open is of niet.",
|
||||||
"This server holds a message for up to {span}.": "Deze server houdt een bericht tot {span} vast.",
|
"This server holds a message for up to {span}.": "Deze server houdt een bericht tot {span} vast.",
|
||||||
"Date and time to send": "Datum en tijd van verzenden",
|
"Date and time to send": "Datum en tijd van verzenden",
|
||||||
"Undo send window": "Termijn om verzenden ongedaan te maken",
|
"Undo send window": "Termijn om verzenden ongedaan te maken",
|
||||||
@@ -731,7 +729,7 @@ export const catalog: Catalog = {
|
|||||||
"Sections": "Onderdelen",
|
"Sections": "Onderdelen",
|
||||||
"General": "Algemeen",
|
"General": "Algemeen",
|
||||||
"Appearance": "Weergave",
|
"Appearance": "Weergave",
|
||||||
"Make ihasmail yours.": "Maak ihasmail van uzelf.",
|
"Make {app} yours.": "Maak {app} van uzelf.",
|
||||||
"Reading": "Lezen",
|
"Reading": "Lezen",
|
||||||
"Reading pane": "Leesvenster",
|
"Reading pane": "Leesvenster",
|
||||||
"Right of the list": "Rechts van de lijst",
|
"Right of the list": "Rechts van de lijst",
|
||||||
@@ -748,6 +746,7 @@ export const catalog: Catalog = {
|
|||||||
"Attachment reminder": "Bijlageherinnering",
|
"Attachment reminder": "Bijlageherinnering",
|
||||||
"Warn when the message mentions an attachment but none is attached.": "Waarschuwen wanneer het bericht een bijlage noemt maar er geen is bijgevoegd.",
|
"Warn when the message mentions an attachment but none is attached.": "Waarschuwen wanneer het bericht een bijlage noemt maar er geen is bijgevoegd.",
|
||||||
"Spell check while typing": "Spellingcontrole tijdens het typen",
|
"Spell check while typing": "Spellingcontrole tijdens het typen",
|
||||||
|
"Open the composer full screen": "Berichten opstellen op volledig scherm",
|
||||||
"Confirm before deleting": "Bevestigen voor verwijderen",
|
"Confirm before deleting": "Bevestigen voor verwijderen",
|
||||||
"Show message snippets": "Berichtfragmenten tonen",
|
"Show message snippets": "Berichtfragmenten tonen",
|
||||||
"Preview the first line of each message in the list.": "De eerste regel van elk bericht in de lijst tonen.",
|
"Preview the first line of each message in the list.": "De eerste regel van elk bericht in de lijst tonen.",
|
||||||
@@ -827,7 +826,8 @@ export const catalog: Catalog = {
|
|||||||
"Reset to defaults": "Standaardwaarden herstellen",
|
"Reset to defaults": "Standaardwaarden herstellen",
|
||||||
"Default mail app": "Standaard e-mailprogramma",
|
"Default mail app": "Standaard e-mailprogramma",
|
||||||
"Documentation": "Documentatie",
|
"Documentation": "Documentatie",
|
||||||
"About ihasmail": "Over ihasmail",
|
"About {app}": "Over {app}",
|
||||||
|
"Built on {project}": "Gebouwd op {project}",
|
||||||
"About": "Over",
|
"About": "Over",
|
||||||
"Server": "Server",
|
"Server": "Server",
|
||||||
"Server capabilities": "Servermogelijkheden",
|
"Server capabilities": "Servermogelijkheden",
|
||||||
@@ -928,6 +928,7 @@ export const catalog: Catalog = {
|
|||||||
"Turn off two-factor authentication": "Tweefactorauthenticatie uitschakelen",
|
"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.": "Uw wachtwoord alleen volstaat dan weer om in te loggen.",
|
||||||
"App passwords": "App-wachtwoorden",
|
"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",
|
"New app password for": "Nieuw app-wachtwoord voor",
|
||||||
"Your new app password": "Uw nieuwe app-wachtwoord",
|
"Your new app password": "Uw nieuwe app-wachtwoord",
|
||||||
"Secret": "Geheim",
|
"Secret": "Geheim",
|
||||||
@@ -955,8 +956,8 @@ export const catalog: Catalog = {
|
|||||||
"Notifications": "Meldingen",
|
"Notifications": "Meldingen",
|
||||||
"Notifications are blocked in your browser settings.": "Meldingen zijn geblokkeerd in uw browserinstellingen.",
|
"Notifications are blocked in your browser settings.": "Meldingen zijn geblokkeerd in uw browserinstellingen.",
|
||||||
"Not supported in this browser.": "Niet ondersteund in deze browser.",
|
"Not supported in this browser.": "Niet ondersteund in deze browser.",
|
||||||
"Desktop notifications while ihasmail is open": "Systeemmeldingen terwijl ihasmail open is",
|
"Desktop notifications while {app} is open": "Systeemmeldingen terwijl {app} open is",
|
||||||
"Notify me even when ihasmail is closed": "Ook melden wanneer ihasmail gesloten 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 post",
|
||||||
"Test notification": "Testmelding",
|
"Test notification": "Testmelding",
|
||||||
"Background notifications are on": "Achtergrondmeldingen staan aan",
|
"Background notifications are on": "Achtergrondmeldingen staan aan",
|
||||||
@@ -1124,7 +1125,7 @@ export const catalog: Catalog = {
|
|||||||
"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).",
|
"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.",
|
"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.",
|
||||||
"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.",
|
"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. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.": "Deze handtekening is groter dan de limiet van {limit} bytes van de server. ihasmail bewaart de volledige versie in uw Bestanden en zet een korte tekstversie op de server — andere e-mailprogramma's zien de platte-tekstversie.",
|
"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.",
|
"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.",
|
||||||
"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.",
|
"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.",
|
"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.",
|
||||||
@@ -1132,40 +1133,40 @@ export const catalog: Catalog = {
|
|||||||
"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.",
|
"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.",
|
"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.",
|
"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 — you can still make ihasmail 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 — u kunt ihasmail nog steeds als standaard instellen via uw besturingssysteem als u het als app installeert.",
|
"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.",
|
||||||
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Registreren voor {scheme}-links vereist een beveiligde (HTTPS-)verbinding.",
|
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Registreren voor {scheme}-links vereist een beveiligde (HTTPS-)verbinding.",
|
||||||
"Open {scheme} links — in web pages, documents and other apps — in ihasmail 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 › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).": "{scheme}-links — op webpagina's, in documenten en in andere programma's — openen in ihasmail 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 › Privacy en beveiliging › Site-instellingen › Protocol-handlers; Firefox: Instellingen › Algemeen › Programma's).",
|
"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-maillinks nog elders openen.",
|
"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 ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.": "Installeer ihasmail eerst als app voor een systeembrede standaard (in Chrome: het installatiepictogram in de adresbalk). Uw besturingssysteem kan ihasmail dan overal direct aanbieden waar het vraagt welk e-mailprogramma gebruikt moet worden.",
|
"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.",
|
"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 ihasmail tab open, naming the sender and subject. Your browser still has to be running — 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 ihasmail-tabblad, met afzender en onderwerp erbij. Uw browser moet wel draaien — sluit u hem helemaal af, dan wachten de meldingen en komen ze binnen zodra u hem weer opent.",
|
"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.",
|
"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.",
|
"This is what a new-mail notification looks like.": "Zo ziet een melding van nieuwe post 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 Stalwart.": "U bent ingelogd als {user}. Uw wachtwoord wordt nooit in de browser opgeslagen; de server bewaart het versleuteld per sessie om met Stalwart te communiceren.",
|
"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.",
|
"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 uitgelogd. App-wachtwoorden blijven werken.",
|
||||||
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Voor dit account staat tweefactorauthenticatie aan. ihasmail kan u nog niet met een code inloggen, dus inloggen op een ander apparaat vereist een app-wachtwoord — of u schakelt tweefactorauthenticatie hier uit.",
|
"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-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.",
|
||||||
"Copy it into {name} now — it isn't shown again.": "Neem het nu over in {name} — het wordt niet opnieuw getoond.",
|
"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.",
|
"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 ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart geeft zijn versienummer niet door aan e-mailprogramma's, dus ihasmail noemt de editie als de server die opgeeft. ihasmail vereist 0.16 of nieuwer; inloggen weigert alles wat ouder is.",
|
"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.",
|
"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).",
|
"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 {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 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.",
|
"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 ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — 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 ihasmail is vertaald; de lijst groeit dus mee met de vertalingen en niet erop vooruit — een taal die wordt aangeboden zonder teksten erachter zou de pagina laten beweren dat ze in een taal is die ze niet is.",
|
"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.",
|
||||||
"tell us about it": "laat het ons weten",
|
"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}.",
|
"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}.",
|
||||||
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "De eigen versie van ihasmail 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 — {sha}. De versie zegt bewust niets over Stalwart; wat deze build van de server nodig heeft, staat op de regel hierboven.",
|
"{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.",
|
||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Nieuw bericht",
|
"New message": "Nieuw bericht",
|
||||||
"Start a new message with what was shared?": "Een nieuw bericht beginnen met wat is gedeeld?",
|
"Start a new message with what was shared?": "Een nieuw bericht beginnen met wat er is gedeeld?",
|
||||||
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Er is iets met ihasmail gedeeld. Er wordt niets verzonden totdat u Verzenden kiest. Hebt u dit niet zelf zojuist gedeeld, gooi het dan weg.",
|
"Something was shared with {app}. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Er is iets met {app} gedeeld. Er wordt niets verzonden totdat u Verzenden kiest. Hebt u dit niet zelf zojuist gedeeld, gooi het dan weg.",
|
||||||
"Start a message": "Bericht beginnen",
|
"Start a message": "Bericht beginnen",
|
||||||
"New mail": "Nieuwe e-mail",
|
"New mail": "Nieuwe e-mail",
|
||||||
"Could not do that — open ihasmail and try again": "Dat lukte niet — open ihasmail en probeer het opnieuw",
|
"Could not do that \u2014 open {app} and try again": "Dat lukte niet \u2014 open {app} en probeer het opnieuw",
|
||||||
"Sending…": "Bezig met verzenden…",
|
"Sending…": "Bezig met verzenden…",
|
||||||
"Saving…": "Bezig met opslaan…",
|
"Saving…": "Bezig met opslaan…",
|
||||||
"Error": "Fout",
|
"Error": "Fout",
|
||||||
@@ -1180,8 +1181,8 @@ export const catalog: Catalog = {
|
|||||||
"Add a contact or import a vCard file.": "Voeg een contact toe of importeer een vCard-bestand.",
|
"Add a contact or import a vCard file.": "Voeg een contact toe of importeer een vCard-bestand.",
|
||||||
"Added to your calendar": "Toegevoegd aan uw agenda",
|
"Added to your calendar": "Toegevoegd aan uw agenda",
|
||||||
"All events in this calendar will be deleted.": "Alle afspraken in deze agenda worden verwijderd.",
|
"All events in this calendar will be deleted.": "Alle afspraken in deze agenda worden verwijderd.",
|
||||||
"All occurrences": "Alle herhalingen",
|
"All occurrences": "Alle gebeurtenissen",
|
||||||
"An occurrence cannot be moved to another calendar on its own": "Eén herhaling kan niet los naar een andere agenda worden verplaatst",
|
"An occurrence cannot be moved to another calendar on its own": "Een gebeurtenis kan niet zelfstandig naar een andere agenda worden verplaatst",
|
||||||
"Anything signed in with this password stops working immediately.": "Alles wat met dit wachtwoord is aangemeld, werkt meteen niet meer.",
|
"Anything signed in with this password stops working immediately.": "Alles wat met dit wachtwoord is aangemeld, werkt meteen niet meer.",
|
||||||
"App password revoked": "App-wachtwoord ingetrokken",
|
"App password revoked": "App-wachtwoord ingetrokken",
|
||||||
"Applies to every date in the series.": "Geldt voor elke datum in de reeks.",
|
"Applies to every date in the series.": "Geldt voor elke datum in de reeks.",
|
||||||
@@ -1213,6 +1214,20 @@ export const catalog: Catalog = {
|
|||||||
"Could not save filters: {error}": "Filters opslaan mislukt: {error}",
|
"Could not save filters: {error}": "Filters opslaan mislukt: {error}",
|
||||||
"Could not send the receipt: {error}": "De leesbevestiging kon niet worden verzonden: {error}",
|
"Could not send the receipt: {error}": "De leesbevestiging kon niet worden verzonden: {error}",
|
||||||
"Could not sign in.": "Aanmelden mislukt.",
|
"Could not sign in.": "Aanmelden mislukt.",
|
||||||
|
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "U bent ingelogd als {user}. Deze webmail ziet uw wachtwoord nooit: hij bewaart een aanmeldtoken van uw mailserver, versleuteld per sessie.",
|
||||||
|
"Mail server": "Mailserver",
|
||||||
|
"You'll enter your password on your mail server's sign-in page.": "U voert uw wachtwoord in op de aanmeldpagina van uw mailserver.",
|
||||||
|
"You'll sign in on your mail server's own page.": "U meldt zich aan op de eigen pagina van uw mailserver.",
|
||||||
|
"This sign-in didn't start in this browser. Try again.": "Deze aanmelding is niet in deze browser begonnen. Probeer het opnieuw.",
|
||||||
|
"The sign-in took too long. Try again.": "Het aanmelden duurde te lang. Probeer het opnieuw.",
|
||||||
|
"Sign-in was cancelled.": "Het aanmelden is geannuleerd.",
|
||||||
|
"The mail server didn't accept the sign-in. Try again.": "De mailserver heeft de aanmelding niet geaccepteerd. Probeer het opnieuw.",
|
||||||
|
"That account is on a different mail server than the address you entered. Sign in with that address.": "Dat account staat op een andere mailserver dan het adres dat u invoerde. Meld u aan met dat adres.",
|
||||||
|
"This mail server isn't supported.": "Deze mailserver wordt niet ondersteund.",
|
||||||
|
"Couldn't reach the mail server. Try again in a moment.": "Kan de mailserver niet bereiken. Probeer het zo meteen opnieuw.",
|
||||||
|
"Your password was changed. Sign in with the new one.": "Uw wachtwoord is gewijzigd. Meld u aan met het nieuwe.",
|
||||||
|
"Your sign-in ended. Sign in again.": "Uw aanmelding is beëindigd. Meld u opnieuw aan.",
|
||||||
|
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Als u uw wachtwoord wijzigt, wordt u overal afgemeld, ook hier, en meldt u zich opnieuw aan met het nieuwe. App-wachtwoorden blijven werken.",
|
||||||
"Could not store image: {error}": "De afbeelding kon niet worden opgeslagen: {error}",
|
"Could not store image: {error}": "De afbeelding kon niet worden opgeslagen: {error}",
|
||||||
"Could not update labels: {error}": "Labels bijwerken mislukt: {error}",
|
"Could not update labels: {error}": "Labels bijwerken mislukt: {error}",
|
||||||
"Could not update the Scheduled folder: {error}": "De map Gepland kon niet worden bijgewerkt: {error}",
|
"Could not update the Scheduled folder: {error}": "De map Gepland kon niet worden bijgewerkt: {error}",
|
||||||
@@ -1223,7 +1238,7 @@ export const catalog: Catalog = {
|
|||||||
"Deactivate": "Deactiveren",
|
"Deactivate": "Deactiveren",
|
||||||
"Delete failed: {error}": "Verwijderen mislukt: {error}",
|
"Delete failed: {error}": "Verwijderen mislukt: {error}",
|
||||||
"Delete forever": "Definitief verwijderen",
|
"Delete forever": "Definitief verwijderen",
|
||||||
"Delete script “{name}”?": "Script ‘{name}’ verwijderen?",
|
"Delete script “{name}”?": "Script “{name}” verwijderen?",
|
||||||
"Delete this event?": "Deze afspraak verwijderen?",
|
"Delete this event?": "Deze afspraak verwijderen?",
|
||||||
"Delete this identity?": "Deze afzender verwijderen?",
|
"Delete this identity?": "Deze afzender verwijderen?",
|
||||||
"Delete {name}?": "{name} verwijderen?",
|
"Delete {name}?": "{name} verwijderen?",
|
||||||
@@ -1237,12 +1252,12 @@ export const catalog: Catalog = {
|
|||||||
"Edit identity": "Afzender bewerken",
|
"Edit identity": "Afzender bewerken",
|
||||||
"Edit template": "Sjabloon bewerken",
|
"Edit template": "Sjabloon bewerken",
|
||||||
"End must be after start": "Het einde moet na het begin liggen",
|
"End must be after start": "Het einde moet na het begin liggen",
|
||||||
"Event duplicated": "Afspraak gedupliceerd",
|
"Event duplicated": "Afspraak dubbel aangemaakt",
|
||||||
"Everything inside it goes too.": "Alles wat erin zit gaat mee.",
|
"Everything inside it goes too.": "Alles wat erin zit gaat mee.",
|
||||||
"Filter created": "Filter aangemaakt",
|
"Filter created": "Filter aangemaakt",
|
||||||
"Filter created — it will run on new mail": "Filter aangemaakt — het draait op nieuwe berichten",
|
"Filter created — it will run on new mail": "Filter aangemaakt — het werkt op nieuwe berichten",
|
||||||
"Filter saved": "Filter opgeslagen",
|
"Filter saved": "Filter opgeslagen",
|
||||||
"Filter saved — it will run on new mail": "Filter opgeslagen — het draait op nieuwe berichten",
|
"Filter saved — it will run on new mail": "Filter opgeslagen — het werkt op nieuwe berichten",
|
||||||
"Filter saved, but applying it failed: {error}": "Filter opgeslagen, maar toepassen is mislukt: {error}",
|
"Filter saved, but applying it failed: {error}": "Filter opgeslagen, maar toepassen is mislukt: {error}",
|
||||||
"Filters saved": "Filters opgeslagen",
|
"Filters saved": "Filters opgeslagen",
|
||||||
"Folder changed, but its filter rules could not be updated: {error}": "Map gewijzigd, maar de filterregels konden niet worden bijgewerkt: {error}",
|
"Folder changed, but its filter rules could not be updated: {error}": "Map gewijzigd, maar de filterregels konden niet worden bijgewerkt: {error}",
|
||||||
@@ -1261,12 +1276,12 @@ export const catalog: Catalog = {
|
|||||||
"Images in signatures need the Files feature, which this account doesn't have.": "Afbeeldingen in handtekeningen vereisen de functie Bestanden, die dit account niet heeft.",
|
"Images in signatures need the Files feature, which this account doesn't have.": "Afbeeldingen in handtekeningen vereisen de functie Bestanden, die dit account niet heeft.",
|
||||||
"Invalid address: {address}": "Ongeldig adres: {address}",
|
"Invalid address: {address}": "Ongeldig adres: {address}",
|
||||||
"Invalid username or password.": "Gebruikersnaam of wachtwoord is onjuist.",
|
"Invalid username or password.": "Gebruikersnaam of wachtwoord is onjuist.",
|
||||||
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "Het hoort bij een wijziging die op deze en alle latere herhalingen is toegepast, en die de server alleen in zijn geheel bewerkt. In plaats daarvan op de hele reeks toepassen?",
|
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "Het hoort bij een wijziging die op deze en alle latere gebeurtenissen is toegepast, en die de server alleen in zijn geheel bewerkt. In plaats daarvan op de hele reeks toepassen?",
|
||||||
"Label name": "Labelnaam",
|
"Label name": "Labelnaam",
|
||||||
"Larger than {size} MB limit": "Groter dan de limiet van {size} MB",
|
"Larger than {size} MB limit": "Groter dan de limiet van {size} MB",
|
||||||
"Message sent": "Bericht verzonden",
|
"Message sent": "Bericht verzonden",
|
||||||
"Move failed: {error}": "Verplaatsen mislukt: {error}",
|
"Move failed: {error}": "Verplaatsen mislukt: {error}",
|
||||||
"Move “{name}”": "‘{name}’ verplaatsen",
|
"Move “{name}”": "“{name}” verplaatsen",
|
||||||
"Moved": "Verplaatst",
|
"Moved": "Verplaatst",
|
||||||
"Network error. Please check your connection.": "Netwerkfout. Controleer uw verbinding.",
|
"Network error. Please check your connection.": "Netwerkfout. Controleer uw verbinding.",
|
||||||
"New all-day event on {date}": "Nieuwe hele dag durende afspraak op {date}",
|
"New all-day event on {date}": "Nieuwe hele dag durende afspraak op {date}",
|
||||||
@@ -1279,11 +1294,11 @@ export const catalog: Catalog = {
|
|||||||
"Drafts": "Concepten",
|
"Drafts": "Concepten",
|
||||||
"Sent": "Verzonden",
|
"Sent": "Verzonden",
|
||||||
"The server does not allow this role to be changed.": "De server staat niet toe deze rol te wijzigen.",
|
"The server does not allow this role to be changed.": "De server staat niet toe deze rol te wijzigen.",
|
||||||
"Folder role updated": "Maprol bijgewerkt",
|
"Folder role updated": "Map rol bijgewerkt",
|
||||||
"No contacts yet": "Nog geen contacten",
|
"No contacts yet": "Nog geen contacten",
|
||||||
"No longer shared": "Niet langer gedeeld",
|
"No longer shared": "Niet langer gedeeld",
|
||||||
"No matches": "Geen overeenkomsten",
|
"No matches": "Geen overeenkomsten",
|
||||||
"No new mail in your inbox.": "Geen nieuwe berichten in uw postvak IN.",
|
"No new mail in your inbox.": "Geen nieuwe berichten in uw Postvak IN.",
|
||||||
"No results": "Geen resultaten",
|
"No results": "Geen resultaten",
|
||||||
"Nothing here": "Hier is niets",
|
"Nothing here": "Hier is niets",
|
||||||
"Only Deleted Items and Junk Mail can be emptied.": "Alleen de prullenbak en ongewenste e-mail kunnen worden geleegd.",
|
"Only Deleted Items and Junk Mail can be emptied.": "Alleen de prullenbak en ongewenste e-mail kunnen worden geleegd.",
|
||||||
@@ -1299,7 +1314,7 @@ export const catalog: Catalog = {
|
|||||||
"Replied": "Beantwoord",
|
"Replied": "Beantwoord",
|
||||||
"Report spam (!)": "Als spam melden (!)",
|
"Report spam (!)": "Als spam melden (!)",
|
||||||
"Response sent": "Antwoord verzonden",
|
"Response sent": "Antwoord verzonden",
|
||||||
"Revoke “{name}”?": "‘{name}’ intrekken?",
|
"Revoke “{name}”?": "“{name}” intrekken?",
|
||||||
"Script has errors": "Het script bevat fouten",
|
"Script has errors": "Het script bevat fouten",
|
||||||
"Script is valid": "Het script is geldig",
|
"Script is valid": "Het script is geldig",
|
||||||
"Script name is required": "Een scriptnaam is verplicht",
|
"Script name is required": "Een scriptnaam is verplicht",
|
||||||
@@ -1310,15 +1325,15 @@ export const catalog: Catalog = {
|
|||||||
"Send failed: {error}": "Verzenden mislukt: {error}",
|
"Send failed: {error}": "Verzenden mislukt: {error}",
|
||||||
"Send invites": "Uitnodigingen verzenden",
|
"Send invites": "Uitnodigingen verzenden",
|
||||||
"Send scheduled for {when}": "Verzenden gepland voor {when}",
|
"Send scheduled for {when}": "Verzenden gepland voor {when}",
|
||||||
"Send without a subject?": "Verzenden zonder onderwerp?",
|
"Send without a subject?": "Verzenden zonder een onderwerp?",
|
||||||
"Share “{name}”": "‘{name}’ delen",
|
"Share “{name}”": "“{name}” delen",
|
||||||
"Sharing updated": "Delen bijgewerkt",
|
"Sharing updated": "Delen bijgewerkt",
|
||||||
"Show": "Tonen",
|
"Show": "Tonen",
|
||||||
"Show quoted text": "Geciteerde tekst tonen",
|
"Show quoted text": "Geciteerde tekst tonen",
|
||||||
"Show this in the compose picker": "Aanbieden bij het opstellen",
|
"Show this in the compose picker": "Aanbieden bij het opstellen",
|
||||||
"Sign out other sessions?": "Andere sessies afmelden?",
|
"Sign out other sessions?": "Andere sessies afmelden?",
|
||||||
"Sign out others": "Andere afmelden",
|
"Sign out others": "Anderen afmelden",
|
||||||
"Stop sharing “{name}”?": "Delen van ‘{name}’ stoppen?",
|
"Stop sharing “{name}”?": "Delen van “{name}” stoppen?",
|
||||||
"Switch to {theme}": "Overschakelen naar {theme}",
|
"Switch to {theme}": "Overschakelen naar {theme}",
|
||||||
"Template": "Sjabloon",
|
"Template": "Sjabloon",
|
||||||
"Template name": "Sjabloonnaam",
|
"Template name": "Sjabloonnaam",
|
||||||
@@ -1327,7 +1342,7 @@ export const catalog: Catalog = {
|
|||||||
"The new passwords don't match": "De nieuwe wachtwoorden komen niet overeen",
|
"The new passwords don't match": "De nieuwe wachtwoorden komen niet overeen",
|
||||||
"The server scheduled this for {when}, not the time requested.": "De server heeft dit gepland voor {when}, niet voor de gevraagde tijd.",
|
"The server scheduled this for {when}, not the time requested.": "De server heeft dit gepland voor {when}, niet voor de gevraagde tijd.",
|
||||||
"This date cannot be changed on its own": "Deze datum kan niet los worden gewijzigd",
|
"This date cannot be changed on its own": "Deze datum kan niet los worden gewijzigd",
|
||||||
"This occurrence": "Deze herhaling",
|
"This occurrence": "Dit geval",
|
||||||
"Too many attempts. Please wait a few minutes and try again.": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw.",
|
"Too many attempts. Please wait a few minutes and try again.": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw.",
|
||||||
"Try another search.": "Probeer een andere zoekopdracht.",
|
"Try another search.": "Probeer een andere zoekopdracht.",
|
||||||
"Try different keywords or filters.": "Probeer andere zoekwoorden of filters.",
|
"Try different keywords or filters.": "Probeer andere zoekwoorden of filters.",
|
||||||
@@ -1335,13 +1350,13 @@ export const catalog: Catalog = {
|
|||||||
"Undo window: {seconds}s": "Tijd om ongedaan te maken: {seconds} s",
|
"Undo window: {seconds}s": "Tijd om ongedaan te maken: {seconds} s",
|
||||||
"You're all caught up": "U bent helemaal bij",
|
"You're all caught up": "U bent helemaal bij",
|
||||||
"Your browser refused the request: {error}": "Uw browser heeft het verzoek geweigerd: {error}",
|
"Your browser refused the request: {error}": "Uw browser heeft het verzoek geweigerd: {error}",
|
||||||
"Your browser will ask whether to open mail links in ihasmail": "Uw browser vraagt of e-maillinks in ihasmail moeten worden geopend",
|
"Your browser will ask whether to open mail links in {app}": "Uw browser vraagt of e-mail links in {app} moeten worden geopend",
|
||||||
"Your message mentions an attachment, but nothing is attached.": "Uw bericht noemt een bijlage, maar er is niets bijgevoegd.",
|
"Your message mentions an attachment, but nothing is attached.": "Uw bericht noemt een bijlage, maar er is niets bijgevoegd.",
|
||||||
"event": "afspraak",
|
"event": "afspraak",
|
||||||
"Hide password": "Wachtwoord verbergen",
|
"Hide password": "Wachtwoord verbergen",
|
||||||
"Show password": "Wachtwoord tonen",
|
"Show password": "Wachtwoord tonen",
|
||||||
"Settings reset to defaults": "Instellingen teruggezet naar de standaardwaarden",
|
"Settings reset to defaults": "Instellingen teruggezet naar de standaardwaarden",
|
||||||
"Removed. Mail links will open in whatever your browser falls back to.": "Verwijderd. E-maillinks worden geopend in waar uw browser op terugvalt.",
|
"Removed. Mail links will open in whatever your browser falls back to.": "Verwijderd. E-mail links worden geopend in waar uw browser op terugvalt.",
|
||||||
"Edit rule": "Regel bewerken",
|
"Edit rule": "Regel bewerken",
|
||||||
"Address copied": "Adres gekopieerd",
|
"Address copied": "Adres gekopieerd",
|
||||||
"Search or create label": "Label zoeken of aanmaken",
|
"Search or create label": "Label zoeken of aanmaken",
|
||||||
@@ -1351,7 +1366,7 @@ export const catalog: Catalog = {
|
|||||||
"Unsubscribe message prepared — just hit Send": "Afmeldbericht klaargezet — u hoeft alleen op Verzenden te klikken",
|
"Unsubscribe message prepared — just hit Send": "Afmeldbericht klaargezet — u hoeft alleen op Verzenden te klikken",
|
||||||
"Maximize": "Maximaliseren",
|
"Maximize": "Maximaliseren",
|
||||||
"Full screen": "Volledig scherm",
|
"Full screen": "Volledig scherm",
|
||||||
"Resize panes": "Vensterdelen verslepen",
|
"Resize panes": "Grootte van vensterdelen wijzigen",
|
||||||
"Resize message list": "Grootte van de berichtenlijst wijzigen",
|
"Resize message list": "Grootte van de berichtenlijst wijzigen",
|
||||||
"Resize contact list": "Grootte van de contactenlijst wijzigen",
|
"Resize contact list": "Grootte van de contactenlijst wijzigen",
|
||||||
"Resize sidebar": "Grootte van de zijbalk wijzigen",
|
"Resize sidebar": "Grootte van de zijbalk wijzigen",
|
||||||
@@ -1387,7 +1402,7 @@ export const catalog: Catalog = {
|
|||||||
// had the same gap. The keyboard bindings among them register their
|
// had the same gap. The keyboard bindings among them register their
|
||||||
// group and description in English at the call site and are translated
|
// group and description in English at the call site and are translated
|
||||||
// at render.
|
// at render.
|
||||||
" and {count} more": " en nog {count}",
|
" and {count} more": " en nog {count} meer",
|
||||||
"10 people or more": "10 personen of meer",
|
"10 people or more": "10 personen of meer",
|
||||||
"20 people or more": "20 personen of meer",
|
"20 people or more": "20 personen of meer",
|
||||||
"5 people or more": "5 personen of meer",
|
"5 people or more": "5 personen of meer",
|
||||||
@@ -1400,13 +1415,13 @@ export const catalog: Catalog = {
|
|||||||
"Added from a message, and removable here — previously the only way to undo one was to find another message from the same sender.": "Toegevoegd vanuit een bericht en hier te verwijderen: voorheen kon dit alleen ongedaan worden gemaakt door een ander bericht van dezelfde afzender op te zoeken.",
|
"Added from a message, and removable here — previously the only way to undo one was to find another message from the same sender.": "Toegevoegd vanuit een bericht en hier te verwijderen: voorheen kon dit alleen ongedaan worden gemaakt door een ander bericht van dezelfde afzender op te zoeken.",
|
||||||
"Added here, or from the dialog when a link is opened. A domain also covers its subdomains.": "Hier toegevoegd, of vanuit het venster bij het openen van een link. Een domein omvat ook de subdomeinen.",
|
"Added here, or from the dialog when a link is opened. A domain also covers its subdomains.": "Hier toegevoegd, of vanuit het venster bij het openen van een link. Een domein omvat ook de subdomeinen.",
|
||||||
"Agenda view": "Agendaweergave",
|
"Agenda view": "Agendaweergave",
|
||||||
"All three start switched off. A client that begins by interrupting is one people learn to click through, and a warning clicked through without reading costs the same attention and buys nothing.": "Alle drie staan aanvankelijk uit. Een client die begint met onderbreken is er een die mensen leren weg te klikken, en een waarschuwing die ongelezen wordt weggeklikt kost dezelfde aandacht en levert niets op.",
|
"All three start switched off. A client that begins by interrupting is one people learn to click through, and a warning clicked through without reading costs the same attention and buys nothing.": "Alle drie staan aanvankelijk uit. Een melding die begint met onderbreken, is er een waar mensen al snel gedachteloos doorheen klikken, en een waarschuwing waar je zonder te lezen doorheen klikt, kost net zoveel aandacht en levert niets op.",
|
||||||
"All {n} in {folder} are selected.": "Alle {n} in {folder} zijn geselecteerd.",
|
"All {n} in {folder} are selected.": "Alle {n} in {folder} zijn geselecteerd.",
|
||||||
"All {n} on this page are selected.": "Alle {n} op deze pagina zijn geselecteerd.",
|
"All {n} on this page are selected.": "Alle {n} op deze pagina zijn geselecteerd.",
|
||||||
"Also count these domains as inside": "Deze domeinen ook als intern beschouwen",
|
"Also count these domains as inside": "Deze domeinen ook als intern beschouwen",
|
||||||
"Always": "Altijd",
|
"Always": "Altijd",
|
||||||
"Always showing images from": "Afbeeldingen altijd tonen van",
|
"Always showing images from": "Altijd afbeeldingen tonen van",
|
||||||
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.": "Een afbeelding die van de server van de afzender wordt geladen, vertelt die afzender dat het bericht is geopend, wanneer en ongeveer waarvandaan. Goedgekeurde afbeeldingen worden opgehaald door de server van ihasmail zelf en niet door de browser, dus de afzender komt daar niets van te weten.",
|
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.": "Een afbeelding die van de server van de afzender wordt geladen, vertelt die afzender dat het bericht is geopend, wanneer en ongeveer waarvandaan. Goedgekeurde afbeeldingen worden opgehaald door de server van {app} zelf en niet door de browser, dus de afzender komt daar niets van te weten.",
|
||||||
"Applies to": "Geldt voor",
|
"Applies to": "Geldt voor",
|
||||||
"Archive and next": "Archiveren en volgende",
|
"Archive and next": "Archiveren en volgende",
|
||||||
"Archive by month": "Archiveren per maand",
|
"Archive by month": "Archiveren per maand",
|
||||||
@@ -1433,17 +1448,17 @@ export const catalog: Catalog = {
|
|||||||
"Could not import this file: {error}": "Kon dit bestand niet importeren: {error}",
|
"Could not import this file: {error}": "Kon dit bestand niet importeren: {error}",
|
||||||
"Could not load this file.": "Kon dit bestand niet laden.",
|
"Could not load this file.": "Kon dit bestand niet laden.",
|
||||||
"Could not read this calendar: {reason}": "Kon deze agenda niet lezen: {reason}",
|
"Could not read this calendar: {reason}": "Kon deze agenda niet lezen: {reason}",
|
||||||
"Could not read winmail.dat. The original is still attached below.": "Kon winmail.dat niet lezen. Het origineel zit hieronder nog als bijlage.",
|
"Could not read winmail.dat. The original is still attached below.": "Kon winmail.dat niet lezen. Het origineel is nog steeds toegoevegd als bijlage.",
|
||||||
"Counts people rather than headers, so one address in To and nine in Cc is a message to ten. Catches a reply-all onto a long thread.": "Telt personen in plaats van kopregels, dus één adres in Aan en negen in Cc is een bericht aan tien. Vangt een allen-beantwoorden op een lang gesprek af.",
|
"Counts people rather than headers, so one address in To and nine in Cc is a message to ten. Catches a reply-all onto a long thread.": "Telt personen in plaats van kopregels, dus één adres in Aan en negen in Cc is een bericht aan tien. Vangt een allen-beantwoorden op een lang gesprek af.",
|
||||||
"Date received": "Ontvangstdatum",
|
"Date received": "Ontvangstdatum",
|
||||||
"Date sent": "Verzenddatum",
|
"Date sent": "Verzenddatum",
|
||||||
"Day view": "Dagweergave",
|
"Day view": "Dagweergave",
|
||||||
"Palettes named after another project are that project's work, used under its own license; the shades between their published colors are derived, and every one is checked for contrast. The accent color below still applies over any of them.": "Paletten die naar een ander project zijn genoemd, zijn het werk van dat project en worden gebruikt onder de eigen licentie daarvan; de tinten tussen de gepubliceerde kleuren zijn afgeleid en elk daarvan wordt op contrast gecontroleerd. De accentkleur hieronder geldt nog steeds over elk ervan.",
|
"Palettes named after another project are that project's work, used under its own license; the shades between their published colors are derived, and every one is checked for contrast. The accent color below still applies over any of them.": "Paletten die naar een ander project zijn genoemd, zijn het werk van dat project en worden gebruikt onder de eigen licentie daarvan; de tinten tussen de gepubliceerde kleuren zijn afgeleid en elk daarvan wordt op contrast gecontroleerd. De accentkleur hieronder wordt nog steeds toegepast op elk palet.",
|
||||||
"Earlier": "Eerder",
|
"Earlier": "Eerder",
|
||||||
"Every folder": "Elke map",
|
"Every folder": "Elke map",
|
||||||
"Everyone addressed will receive this.": "Iedereen die is geadresseerd ontvangt dit.",
|
"Everyone addressed will receive this.": "Elke geadresseerde ontvangt dit.",
|
||||||
"File contents": "Bestandsinhoud",
|
"File contents": "Bestandsinhoud",
|
||||||
"Filled in when the template is inserted, so you can edit the result before sending. One that cannot be answered yet — a recipient's name on a message you have not addressed — is left in the body as written, rather than becoming a blank.": "Wordt ingevuld wanneer de sjabloon wordt ingevoegd, zodat u het resultaat vóór verzending kunt bewerken. Een veld dat nog niet kan worden ingevuld — de naam van een ontvanger op een bericht dat u nog niet hebt geadresseerd — blijft in de tekst staan zoals het geschreven is, in plaats van een leegte te worden.",
|
"Filled in when the template is inserted, so you can edit the result before sending. One that cannot be answered yet — a recipient's name on a message you have not addressed — is left in the body as written, rather than becoming a blank.": "Wordt ingevuld wanneer de sjabloon wordt ingevoegd, zodat u het resultaat vóór verzending kunt bewerken. Een veld dat nog niet kan worden ingevuld — de naam van een ontvanger op een bericht dat u nog niet hebt geadresseerd — blijft in de tekst staan zoals het geschreven is, in plaats van niets te tonen.",
|
||||||
"Forward as attachment": "Doorsturen als bijlage",
|
"Forward as attachment": "Doorsturen als bijlage",
|
||||||
"From the birthdays on your contacts. Nothing is stored.": "Uit de verjaardagen van uw contacten. Er wordt niets opgeslagen.",
|
"From the birthdays on your contacts. Nothing is stored.": "Uit de verjaardagen van uw contacten. Er wordt niets opgeslagen.",
|
||||||
"Go to Calendar": "Ga naar Agenda",
|
"Go to Calendar": "Ga naar Agenda",
|
||||||
@@ -1486,7 +1501,7 @@ export const catalog: Catalog = {
|
|||||||
"Open links to these domains without asking": "Links naar deze domeinen openen zonder te vragen",
|
"Open links to these domains without asking": "Links naar deze domeinen openen zonder te vragen",
|
||||||
"Open, and stop asking about {domain}": "Openen en niet meer vragen over {domain}",
|
"Open, and stop asking about {domain}": "Openen en niet meer vragen over {domain}",
|
||||||
"Opening…": "Bezig met openen…",
|
"Opening…": "Bezig met openen…",
|
||||||
"Ordered by the server over the whole folder, not just the messages loaded so far. Ties always fall back to newest first, so the order never shuffles between two looks at the same folder.": "Gesorteerd door de server over de hele map, niet alleen over de tot nu toe geladen berichten. Bij gelijke waarden geldt altijd nieuwste eerst, zodat de volgorde nooit verschuift tussen twee blikken op dezelfde map.",
|
"Ordered by the server over the whole folder, not just the messages loaded so far. Ties always fall back to newest first, so the order never shuffles between two looks at the same folder.": "Gesorteerd door de server over de hele map, niet alleen over de tot nu toe geladen berichten. Bij gelijke waarden wordt altijd teruggevallen op nieuwste eerst, zodat de volgorde nooit verandert tussen twee keer bekijken van dezelfde map.",
|
||||||
"Placeholders": "Tijdelijke aanduidingen",
|
"Placeholders": "Tijdelijke aanduidingen",
|
||||||
"Previous conversation": "Vorig gesprek",
|
"Previous conversation": "Vorig gesprek",
|
||||||
"Previous period": "Vorige periode",
|
"Previous period": "Vorige periode",
|
||||||
@@ -1531,10 +1546,10 @@ export const catalog: Catalog = {
|
|||||||
"Then nothing": "Daarna niets",
|
"Then nothing": "Daarna niets",
|
||||||
"There is no preview for this kind of file.": "Voor dit soort bestand is er geen voorbeeld.",
|
"There is no preview for this kind of file.": "Voor dit soort bestand is er geen voorbeeld.",
|
||||||
"There is nothing in it to export": "Er zit niets in om te exporteren",
|
"There is nothing in it to export": "Er zit niets in om te exporteren",
|
||||||
"This file is not UTF-8 text, so editing it here would corrupt it — download it instead.": "Dit bestand is geen UTF-8-tekst, dus het hier bewerken zou het beschadigen: download het in plaats daarvan.",
|
"This file is not UTF-8 text, so editing it here would corrupt it — download it instead.": "Dit bestand is geen UTF-8-tekst, dus het hier bewerken zou het beschadigen: download het in plaats van openen.",
|
||||||
"This file is too big to show here ({size}) — download it to read it.": "Dit bestand is te groot om hier te tonen ({size}): download het om het te lezen.",
|
"This file is too big to show here ({size}) — download it to read it.": "Dit bestand is te groot om hier te tonen ({size}): download het om het te lezen.",
|
||||||
"This goes to {recipients}{rest}.": "Dit gaat naar {recipients}{rest}.",
|
"This goes to {recipients}{rest}.": "Dit gaat naar {recipients}{rest}.",
|
||||||
"This link does not go where it says": "Deze link gaat niet waarheen hij zegt",
|
"This link does not go where it says": "Deze link gaat niet naar de aangegeven bestemming",
|
||||||
"This message packs its attachments into a winmail.dat, which most clients cannot open.": "Dit bericht verpakt zijn bijlagen in een winmail.dat, die de meeste clients niet kunnen openen.",
|
"This message packs its attachments into a winmail.dat, which most clients cannot open.": "Dit bericht verpakt zijn bijlagen in een winmail.dat, die de meeste clients niet kunnen openen.",
|
||||||
"Throw away your changes?": "Uw wijzigingen weggooien?",
|
"Throw away your changes?": "Uw wijzigingen weggooien?",
|
||||||
"Today, in your date format": "Vandaag, in uw datumnotatie",
|
"Today, in your date format": "Vandaag, in uw datumnotatie",
|
||||||
@@ -1561,11 +1576,11 @@ export const catalog: Catalog = {
|
|||||||
"Your filter rules have changes that have not been saved.": "Uw filterregels bevatten wijzigingen die niet zijn opgeslagen.",
|
"Your filter rules have changes that have not been saved.": "Uw filterregels bevatten wijzigingen die niet zijn opgeslagen.",
|
||||||
"Your own identity domains are always inside and do not need listing. A domain here also covers its subdomains.": "De domeinen van uw eigen identiteiten zijn altijd intern en hoeven niet te worden vermeld. Een domein hier omvat ook zijn subdomeinen.",
|
"Your own identity domains are always inside and do not need listing. A domain here also covers its subdomains.": "De domeinen van uw eigen identiteiten zijn altijd intern en hoeven niet te worden vermeld. Een domein hier omvat ook zijn subdomeinen.",
|
||||||
"Your own:": "Uw eigen:",
|
"Your own:": "Uw eigen:",
|
||||||
"dark mode": "de donkere modus",
|
"dark mode": "donkere modus",
|
||||||
"file": "bestand",
|
"file": "bestand",
|
||||||
"light mode": "de lichte modus",
|
"light mode": "lichte modus",
|
||||||
"scored {score} against a threshold of {threshold}": "scoorde {score} bij een drempel van {threshold}",
|
"scored {score} against a threshold of {threshold}": "scoorde {score} bij een drempel van {threshold}",
|
||||||
"scored {score}, with no threshold stated": "scoorde {score}, zonder vermelde drempel",
|
"scored {score}, with no threshold stated": "scoorde {score}, zonder een vermelde drempel",
|
||||||
"this view": "deze weergave",
|
"this view": "deze weergave",
|
||||||
"{count} conversations moved to {folder}": "{count} gesprekken verplaatst naar {folder}",
|
"{count} conversations moved to {folder}": "{count} gesprekken verplaatst naar {folder}",
|
||||||
"{count} folders": "{count} mappen",
|
"{count} folders": "{count} mappen",
|
||||||
@@ -1587,10 +1602,10 @@ export const catalog: Catalog = {
|
|||||||
"Remove star": "Ster verwijderen",
|
"Remove star": "Ster verwijderen",
|
||||||
"Requested, to {address}. Never sent automatically.": "Gevraagd, aan {address}. Wordt nooit automatisch verzonden.",
|
"Requested, to {address}. Never sent automatically.": "Gevraagd, aan {address}. Wordt nooit automatisch verzonden.",
|
||||||
"The sender did not request a read receipt.": "De afzender heeft geen leesbevestiging gevraagd.",
|
"The sender did not request a read receipt.": "De afzender heeft geen leesbevestiging gevraagd.",
|
||||||
"This is bulk or list mail; read receipts for it only confirm the address is live.": "Dit is bulk- of lijstpost; een leesbevestiging bevestigt daarvoor alleen dat het adres actief is.",
|
"This is bulk or list mail; read receipts for it only confirm the address is live.": "Dit is bulk- of lijst e-mail; een leesbevestiging bevestigt daarvoor alleen dat het adres actief is.",
|
||||||
"This message has not been received, so there is nothing to report.": "Dit bericht is niet ontvangen, dus er valt niets te melden.",
|
"This message has not been received, so there is nothing to report.": "Dit bericht is niet ontvangen, dus er valt niets te melden.",
|
||||||
"This message was sent automatically, so no read receipt is offered.": "Dit bericht is automatisch verzonden, dus er wordt geen leesbevestiging aangeboden.",
|
"This message was sent automatically, so no read receipt is offered.": "Dit bericht is automatisch verzonden, dus er wordt geen leesbevestiging aangeboden.",
|
||||||
"This server will not hold a message longer than {span}.": "Deze server houdt een bericht niet langer dan {span} vast.",
|
"This server will not hold a message longer than {span}.": "Deze server bewaart een bericht niet langer dan {span}.",
|
||||||
"Upload failed": "Uploaden mislukt",
|
"Upload failed": "Uploaden mislukt",
|
||||||
// ── Third pass ──────────────────────────────────────────────────────
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
// Sentences that lib/ and store/ were building in English, and the two
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
@@ -1614,7 +1629,7 @@ export const catalog: Catalog = {
|
|||||||
"fourth": "vierde",
|
"fourth": "vierde",
|
||||||
"keep it": "behouden",
|
"keep it": "behouden",
|
||||||
"last": "laatste",
|
"last": "laatste",
|
||||||
"mark it read": "als gelezen markeren",
|
"mark it read": "markeren als gelezen",
|
||||||
"move to {folder}": "verplaatsen naar {folder}",
|
"move to {folder}": "verplaatsen naar {folder}",
|
||||||
"reject it": "weigeren",
|
"reject it": "weigeren",
|
||||||
"remove {flag}": "{flag} verwijderen",
|
"remove {flag}": "{flag} verwijderen",
|
||||||
@@ -1638,7 +1653,7 @@ export const catalog: Catalog = {
|
|||||||
"It was not deleted": "Het is niet verwijderd",
|
"It was not deleted": "Het is niet verwijderd",
|
||||||
"Empty address book": "Dit adresboek leegmaken",
|
"Empty address book": "Dit adresboek leegmaken",
|
||||||
"There is nothing in it to delete": "Er staat niets in om te verwijderen",
|
"There is nothing in it to delete": "Er staat niets in om te verwijderen",
|
||||||
"Empty “{name}”?": "„{name}” leegmaken?",
|
"Empty “{name}”?": "“{name}” leegmaken?",
|
||||||
"Delete them": "Verwijderen",
|
"Delete them": "Verwijderen",
|
||||||
"Nothing was deleted": "Er is niets verwijderd",
|
"Nothing was deleted": "Er is niets verwijderd",
|
||||||
// ── Checking an S/MIME signature, and what may be said about it ──
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
@@ -1648,9 +1663,9 @@ export const catalog: Catalog = {
|
|||||||
"Fingerprint": "Vingerafdruk",
|
"Fingerprint": "Vingerafdruk",
|
||||||
"Hide details": "Details verbergen",
|
"Hide details": "Details verbergen",
|
||||||
"Issued by": "Uitgegeven door",
|
"Issued by": "Uitgegeven door",
|
||||||
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Het is ondertekend met OpenPGP, en ihasmail kan de openbare sleutel van de afzender niet ophalen.",
|
"It is signed with OpenPGP, and {app} has no way to fetch the sender's public key.": "Het is ondertekend met OpenPGP, en {app} kan de openbare sleutel van de afzender niet ophalen.",
|
||||||
"It uses a signature algorithm ihasmail cannot check yet.": "Het gebruikt een ondertekeningsalgoritme dat ihasmail nog niet kan controleren.",
|
"It uses a signature algorithm {app} cannot check yet.": "Het gebruikt een ondertekeningsalgoritme dat {app} nog niet kan controleren.",
|
||||||
"It was made with a certificate belonging to {name}, which does not cover this address.": "Hij is gemaakt met een certificaat van {name}, dat dit adres niet dekt.",
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Het is gemaakt met een certificaat dat toebehoort aan {name}, maar dit certificaat is niet geldig voor dit adres.",
|
||||||
"Previous fingerprint": "Vorige vingerafdruk",
|
"Previous fingerprint": "Vorige vingerafdruk",
|
||||||
"Signed at": "Ondertekend op",
|
"Signed at": "Ondertekend op",
|
||||||
"Signed by {name} — the same signer as before.": "Ondertekend door {name} — dezelfde ondertekenaar als eerder.",
|
"Signed by {name} — the same signer as before.": "Ondertekend door {name} — dezelfde ondertekenaar als eerder.",
|
||||||
@@ -1666,21 +1681,42 @@ export const catalog: Catalog = {
|
|||||||
"The signature is not for this sender.": "De handtekening is niet van deze afzender.",
|
"The signature is not for this sender.": "De handtekening is niet van deze afzender.",
|
||||||
"The signed part is missing either the message or the signature.": "In het ondertekende deel ontbreekt het bericht of de handtekening.",
|
"The signed part is missing either the message or the signature.": "In het ondertekende deel ontbreekt het bericht of de handtekening.",
|
||||||
"The signer has changed.": "De ondertekenaar is veranderd.",
|
"The signer has changed.": "De ondertekenaar is veranderd.",
|
||||||
"This message is signed, and ihasmail could not check the signature.": "Dit bericht is ondertekend, en ihasmail kon de handtekening niet controleren.",
|
"This message is signed, and {app} could not check the signature.": "Dit bericht is ondertekend, en {app} kon de handtekening niet controleren.",
|
||||||
"This signature does not check out.": "Deze handtekening klopt niet.",
|
"This signature does not check out.": "Deze handtekening klopt niet.",
|
||||||
"Valid until": "Geldig tot",
|
"Valid until": "Geldig tot",
|
||||||
"a different certificate": "een ander certificaat",
|
"a different certificate": "een ander certificaat",
|
||||||
"an unnamed signer": "een naamloze ondertekenaar",
|
"an unnamed signer": "een naamloze ondertekenaar",
|
||||||
"as claimed by the signer": "volgens de ondertekenaar",
|
"as claimed by the signer": "volgens de ondertekenaar",
|
||||||
"first seen {date}": "voor het eerst gezien op {date}",
|
"first seen {date}": "voor het eerst gezien op {date}",
|
||||||
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail laat het weten als een later bericht van dit adres door iemand anders is ondertekend.",
|
"{app} will tell you if a later message from this address is signed by anybody else.": "{app} laat het weten als een later bericht van dit adres door iemand anders is ondertekend.",
|
||||||
"itself, or an issuer it does not name": "zichzelf, of een uitgever die het niet noemt",
|
"itself, or an issuer it does not name": "zichzelf, of een uitgever die niet wordt genoemd",
|
||||||
"no address": "geen adres",
|
"no address": "geen adres",
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"Legacy mail apps": "Mail-apps met verouderde protocollen",
|
||||||
|
"Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.": "Uit voor {tenant}. Alleen {app} en JMAP-apps kunnen inloggen op de domeinen ervan.",
|
||||||
|
"On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.": "Aan voor {tenant}. Mail-apps kunnen IMAP, POP3 en ManageSieve gebruiken op de domeinen ervan.",
|
||||||
|
"Turn legacy protocols back on": "Verouderde mailprotocollen weer inschakelen",
|
||||||
|
"Turn off legacy protocols…": "Verouderde mailprotocollen uitschakelen…",
|
||||||
|
"No account used a legacy mail app in the last 30 days.": "Geen enkel account heeft de afgelopen 30 dagen een mail-app met verouderde protocollen gebruikt.",
|
||||||
|
"Their mail apps will stop working the moment you turn this on:": "Hun mail-apps werken niet meer zodra u dit inschakelt:",
|
||||||
|
"Only {app} and JMAP apps will work.": "Alleen {app} en JMAP-apps werken dan nog.",
|
||||||
|
"Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {tenant}.": "Verouderde mailprotocollen (IMAP, POP3, ManageSieve en verzenden vanuit mail-apps) worden uitgeschakeld voor iedereen in {tenant}.",
|
||||||
|
"Phone and desktop mail apps will stop receiving and sending mail. That's iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.": "Mail-apps op telefoon en computer kunnen geen mail meer ontvangen en verzenden. Het gaat om Mail op iPhone en iPad, de Gmail- en Outlook-apps, Outlook, Thunderbird en Apple Mail. Gebruikers zien daarin inlogfouten.",
|
||||||
|
"Filters managed from a mail app (ManageSieve) will stop working. Filters set in {app} keep working.": "Filters die vanuit een mail-app worden beheerd (ManageSieve) werken niet meer. Filters die in {app} zijn ingesteld blijven werken.",
|
||||||
|
"Incoming mail is not affected. Calendars and contacts are not affected.": "Inkomende mail wordt niet beïnvloed. Agenda's en contacten worden niet beïnvloed.",
|
||||||
|
"People keep full access through {app}, which can be installed as an app on phones and computers.": "Iedereen houdt volledige toegang via {app}, dat als app op telefoons en computers kan worden geïnstalleerd.",
|
||||||
|
"Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and {app} (JMAP) are not affected and cannot be turned off here.": "Verzenden vanuit mail-apps (SMTP-submission) werkt niet meer, maar de poorten blijven open: mail-apps krijgen te horen dat ze niet kunnen inloggen. Inkomende mail (SMTP) en {app} (JMAP) worden niet beïnvloed en kunnen hier niet worden uitgeschakeld.",
|
||||||
|
"You can turn legacy protocols back on at any time.": "U kunt verouderde mailprotocollen op elk moment weer inschakelen.",
|
||||||
|
"To confirm, type {phrase}": "Typ ter bevestiging {phrase}",
|
||||||
|
"Turn off legacy protocols": "Verouderde mailprotocollen uitschakelen",
|
||||||
|
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Verouderde mailprotocollen zijn uitgeschakeld voor uw organisatie. Alleen {app} en JMAP-apps kunnen inloggen.",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"{n} accounts used a legacy mail app in the last 30 days.": {one: "{n} account heeft de afgelopen 30 dagen een mail-app met verouderde protocollen gebruikt.", other: "{n} accounts hebben de afgelopen 30 dagen een mail-app met verouderde protocollen gebruikt."},
|
||||||
// ── Administration: domains ────────────────────────────────────
|
// ── Administration: domains ────────────────────────────────────
|
||||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} account gebruikt dit domein. Verplaats of verwijder het eerst.", other: "{n} accounts gebruiken dit domein. Verplaats of verwijder ze eerst." },
|
"{n} accounts use this domain. Move or delete them first.": { one: "{n} account gebruikt dit domein. Verplaats of verwijder deze eerst.", other: "{n} accounts gebruiken dit domein. Verplaats of verwijder ze eerst." },
|
||||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "De server accepteert geen e-mail meer voor dit domein en de {n} DKIM-sleutel wordt verwijderd. Dit kan niet ongedaan worden gemaakt.", other: "De server accepteert geen e-mail meer voor dit domein en de {n} DKIM-sleutels worden verwijderd. Dit kan niet ongedaan worden gemaakt." },
|
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "De server accepteert geen e-mails meer voor dit domein en de {n} DKIM-sleutel wordt verwijderd. Dit kan niet ongedaan worden gemaakt.", other: "De server accepteert geen e-mails meer voor dit domein en de {n} DKIM-sleutels worden verwijderd. Dit kan niet ongedaan worden gemaakt." },
|
||||||
"{n} domains": { one: "{n} domein", other: "{n} domeinen" },
|
"{n} domains": { one: "{n} domein", other: "{n} domeinen" },
|
||||||
"{n} groups": { one: "{n} groep", other: "{n} groepen" },
|
"{n} groups": { one: "{n} groep", other: "{n} groepen" },
|
||||||
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Het {n} lid wordt eerst uit de groep gehaald en verliest wat ermee gedeeld was. De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt.", other: "De {n} leden worden eerst uit de groep gehaald en verliezen wat ermee gedeeld was. De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt." },
|
"Its {n} members are taken out of the group first, and lose what was shared with it. The group's own mail is removed in the background, and it can't be undone.": { one: "Het {n} lid wordt eerst uit de groep gehaald en verliest wat ermee gedeeld was. De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt.", other: "De {n} leden worden eerst uit de groep gehaald en verliezen wat ermee gedeeld was. De e-mail van de groep wordt op de achtergrond verwijderd, en dit kan niet ongedaan worden gemaakt." },
|
||||||
@@ -1689,7 +1725,7 @@ export const catalog: Catalog = {
|
|||||||
"Grants {n} permissions": { one: "Kent {n} recht toe", other: "Kent {n} rechten toe" },
|
"Grants {n} permissions": { one: "Kent {n} recht toe", other: "Kent {n} rechten toe" },
|
||||||
"{n} roles": { one: "{n} rol", other: "{n} rollen" },
|
"{n} roles": { one: "{n} rol", other: "{n} rollen" },
|
||||||
"{n} tenants": { one: "{n} tenant", other: "{n} tenants" },
|
"{n} tenants": { one: "{n} tenant", other: "{n} tenants" },
|
||||||
"{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} account van deze tenant staat nog op {domain}. Verplaats of verwijder het voordat u het domein eruit haalt.", other: "{n} accounts van deze tenant staan nog op {domain}. Verplaats of verwijder ze voordat u het domein eruit haalt." },
|
"{n} accounts in this tenant are still on {domain}. Move them or delete them before taking the domain out.": { one: "{n} account van deze tenant staat nog op {domain}. Verplaats of verwijder deze voordat u het domein eruit haalt.", other: "{n} accounts van deze tenant staan nog op {domain}. Verplaats of verwijder ze voordat u het domein eruit haalt." },
|
||||||
"{n} DKIM keys": { one: "{n} DKIM-sleutel", other: "{n} DKIM-sleutels" },
|
"{n} DKIM keys": { one: "{n} DKIM-sleutel", other: "{n} DKIM-sleutels" },
|
||||||
"{n} other items": { one: "{n} ander item", other: "{n} andere items" },
|
"{n} other items": { one: "{n} ander item", other: "{n} andere items" },
|
||||||
// ── Administration ────────────────────────────────────────────────
|
// ── Administration ────────────────────────────────────────────────
|
||||||
@@ -1713,13 +1749,13 @@ export const catalog: Catalog = {
|
|||||||
"Every {n} years": { one: "Elk jaar", other: "Elke {n} jaar" },
|
"Every {n} years": { one: "Elk jaar", other: "Elke {n} jaar" },
|
||||||
"{rule}, {n} times": { one: "{rule}, {n} keer", other: "{rule}, {n} keer" },
|
"{rule}, {n} times": { one: "{rule}, {n} keer", other: "{rule}, {n} keer" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
"Move {n} messages to Trash?": { one: "{n} bericht naar de Prullenbak verplaatsen?", other: "{n} berichten naar de Prullenbak verplaatsen?" },
|
"Move {n} messages to Trash?": { one: "{n} bericht naar de prullenbak verplaatsen?", other: "{n} berichten naar de prullenbak verplaatsen?" },
|
||||||
"{n} days": { one: "{n} dag", other: "{n} dagen" },
|
"{n} days": { one: "{n} dag", other: "{n} dagen" },
|
||||||
"{n} hours": { one: "{n} uur", other: "{n} uur" },
|
"{n} hours": { one: "{n} uur", other: "{n} uur" },
|
||||||
"Updated {n} contacts, nothing new": { one: "{n} contact bijgewerkt, niets nieuws", other: "{n} contacten bijgewerkt, niets nieuws" },
|
"Updated {n} contacts, nothing new": { one: "{n} contact bijgewerkt, niets nieuws", other: "{n} contacten bijgewerkt, niets nieuws" },
|
||||||
"{n} updated": { one: "{n} bijgewerkt", other: "{n} bijgewerkt" },
|
"{n} updated": { one: "{n} bijgewerkt", other: "{n} bijgewerkt" },
|
||||||
"Updated {n} contacts you already had": { one: "Bestaand contact bijgewerkt", other: "{n} bestaande contacten bijgewerkt" },
|
"Updated {n} contacts you already had": { one: "Bestaand contact bijgewerkt", other: "{n} bestaande contacten bijgewerkt" },
|
||||||
"{n} of them look like contacts you already had": { one: "{n} daarvan lijkt op een contact dat u al had", other: "{n} daarvan lijken op contacten die u al had" },
|
"{n} of them look like contacts you already had": { one: "{n} daarvan lijkt op een bestaand contact", other: "{n} daarvan lijken op een bestaand contact" },
|
||||||
"Your administrator changed {n} settings": { one: "Uw beheerder heeft {n} instelling gewijzigd", other: "Uw beheerder heeft {n} instellingen gewijzigd" },
|
"Your administrator changed {n} settings": { one: "Uw beheerder heeft {n} instelling gewijzigd", other: "Uw beheerder heeft {n} instellingen gewijzigd" },
|
||||||
"Exported {n} events": { one: "{n} afspraak geëxporteerd", other: "{n} afspraken geëxporteerd" },
|
"Exported {n} events": { one: "{n} afspraak geëxporteerd", other: "{n} afspraken geëxporteerd" },
|
||||||
"Imported {n} events": { one: "{n} afspraak geïmporteerd", other: "{n} afspraken geïmporteerd" },
|
"Imported {n} events": { one: "{n} afspraak geïmporteerd", other: "{n} afspraken geïmporteerd" },
|
||||||
|
|||||||
+68
-31
@@ -142,8 +142,8 @@ export const catalog: Catalog = {
|
|||||||
"The numbers your role can see, as the server reports them.": "Os números que sua função pode ver, como o servidor os informa.",
|
"The numbers your role can see, as the server reports them.": "Os números que sua função pode ver, como o servidor os informa.",
|
||||||
"Nothing to show": "Nada para mostrar",
|
"Nothing to show": "Nada para mostrar",
|
||||||
"Could not be loaded": "Não foi possível carregar",
|
"Could not be loaded": "Não foi possível carregar",
|
||||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Métricas detalhadas, a fila de entrega, os logs e as configurações do servidor ficam na administração do próprio Stalwart.",
|
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Métricas detalhadas, a fila de entrega, os registros e as configurações do servidor ficam no INBUXA Admin.",
|
||||||
"Open Stalwart admin": "Abrir a administração do Stalwart",
|
"Open INBUXA Admin": "Abrir o INBUXA Admin",
|
||||||
"Default group role": "Função padrão de grupo",
|
"Default group role": "Função padrão de grupo",
|
||||||
"A group needs an address.": "Um grupo precisa de um endereço.",
|
"A group needs an address.": "Um grupo precisa de um endereço.",
|
||||||
"New group": "Novo grupo",
|
"New group": "Novo grupo",
|
||||||
@@ -212,10 +212,10 @@ export const catalog: Catalog = {
|
|||||||
"New role": "Nova função",
|
"New role": "Nova função",
|
||||||
"This role carries permissions yours doesn't, so you can view it but not change it.": "Esta função tem permissões que a sua não tem, então você pode vê-la, mas não alterá-la.",
|
"This role carries permissions yours doesn't, so you can view it but not change it.": "Esta função tem permissões que a sua não tem, então você pode vê-la, mas não alterá-la.",
|
||||||
"Your role lets you view roles but not change them.": "Sua função permite ver as funções, mas não alterá-las.",
|
"Your role lets you view roles but not change them.": "Sua função permite ver as funções, mas não alterá-las.",
|
||||||
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "O Stalwart atribui esta função por padrão a {kinds}. Uma alteração aqui afeta todos que a têm dessa forma.",
|
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "O servidor de e-mail atribui esta função por padrão a {kinds}. Uma alteração aqui vale para todos que a têm dessa forma.",
|
||||||
"Builds on": "Baseia-se em",
|
"Builds on": "Baseia-se em",
|
||||||
"Permissions": "Permissões",
|
"Permissions": "Permissões",
|
||||||
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "O Stalwart atribui esta função por padrão, então ela não pode ser excluída. Altere primeiro os padrões na administração do próprio Stalwart.",
|
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "O servidor de e-mail atribui esta função por padrão, então ela não pode ser excluída. Altere primeiro os padrões no INBUXA Admin.",
|
||||||
"This role carries permissions yours doesn't.": "Esta função tem permissões que a sua não tem.",
|
"This role carries permissions yours doesn't.": "Esta função tem permissões que a sua não tem.",
|
||||||
"Create role": "Criar função",
|
"Create role": "Criar função",
|
||||||
"builds on this one": "baseia-se nesta",
|
"builds on this one": "baseia-se nesta",
|
||||||
@@ -242,7 +242,7 @@ export const catalog: Catalog = {
|
|||||||
"Delete role": "Excluir função",
|
"Delete role": "Excluir função",
|
||||||
"It can't be undone.": "Isso não pode ser desfeito.",
|
"It can't be undone.": "Isso não pode ser desfeito.",
|
||||||
"Type {name} to confirm": "Digite {name} para confirmar",
|
"Type {name} to confirm": "Digite {name} para confirmar",
|
||||||
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Não foi possível carregar a lista de permissões do Stalwart, então as permissões não podem ser alteradas aqui. ({reason})",
|
"The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Não foi possível carregar a lista de permissões do servidor de e-mail, então as permissões não podem ser alteradas aqui. ({reason})",
|
||||||
"Named sets of permissions, given to accounts, groups and tenants.": "Conjuntos nomeados de permissões, atribuídos a contas, grupos e locatários.",
|
"Named sets of permissions, given to accounts, groups and tenants.": "Conjuntos nomeados de permissões, atribuídos a contas, grupos e locatários.",
|
||||||
"Search roles": "Pesquisar funções",
|
"Search roles": "Pesquisar funções",
|
||||||
"No roles match": "Nenhuma função corresponde",
|
"No roles match": "Nenhuma função corresponde",
|
||||||
@@ -265,11 +265,11 @@ export const catalog: Catalog = {
|
|||||||
"{used} used": "{used} usados",
|
"{used} used": "{used} usados",
|
||||||
"Your role lets you view tenants but not change them.": "Sua função permite ver os locatários, mas não alterá-los.",
|
"Your role lets you view tenants but not change them.": "Sua função permite ver os locatários, mas não alterá-los.",
|
||||||
"Logo": "Logotipo",
|
"Logo": "Logotipo",
|
||||||
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Um endereço https ou uma URL data de uma imagem. O Stalwart a mostra às pessoas do locatário onde mostra um logotipo.",
|
"An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Um endereço https ou uma URL de dados de uma imagem. O servidor de e-mail a mostra às pessoas do locatário onde mostra um logotipo.",
|
||||||
"What it holds": "O que contém",
|
"What it holds": "O que contém",
|
||||||
"{n} of {limit}": "{n} de {limit}",
|
"{n} of {limit}": "{n} de {limit}",
|
||||||
"Limits": "Limites",
|
"Limits": "Limites",
|
||||||
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "O Stalwart se recusa a criar mais do que um limite permite. Um campo vazio significa sem limite.",
|
"The mail server refuses to create more than a limit allows. An empty field is no limit.": "O servidor de e-mail se recusa a criar mais do que um limite permite. Um campo vazio significa sem limite.",
|
||||||
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "O máximo que alguém neste locatário pode ter: as funções próprias são reduzidas ao que estas concedem. Só são oferecidas funções cujas permissões você mesmo tem.",
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "O máximo que alguém neste locatário pode ter: as funções próprias são reduzidas ao que estas concedem. Só são oferecidas funções cujas permissões você mesmo tem.",
|
||||||
"Checking what is still in this tenant…": "Verificando o que ainda há neste locatário…",
|
"Checking what is still in this tenant…": "Verificando o que ainda há neste locatário…",
|
||||||
"It still holds accounts, domains or other things. Move them out first.": "Ele ainda tem contas, domínios ou outros itens. Mova-os primeiro.",
|
"It still holds accounts, domains or other things. Move them out first.": "Ele ainda tem contas, domínios ou outros itens. Mova-os primeiro.",
|
||||||
@@ -285,7 +285,6 @@ export const catalog: Catalog = {
|
|||||||
"Still holds {things}. Move them out first.": "Ainda tem {things}. Mova-os primeiro.",
|
"Still holds {things}. Move them out first.": "Ainda tem {things}. Mova-os primeiro.",
|
||||||
"Delete tenant": "Excluir locatário",
|
"Delete tenant": "Excluir locatário",
|
||||||
"Separate organizations on one server, each with its own people, domains and limits.": "Organizações separadas em um mesmo servidor, cada uma com suas próprias pessoas, domínios e limites.",
|
"Separate organizations on one server, each with its own people, domains and limits.": "Organizações separadas em um mesmo servidor, cada uma com suas próprias pessoas, domínios e limites.",
|
||||||
"Tenants are a Stalwart Enterprise feature.": "Locatários são um recurso do Stalwart Enterprise.",
|
|
||||||
"Search tenants": "Pesquisar locatários",
|
"Search tenants": "Pesquisar locatários",
|
||||||
"No tenants match": "Nenhum locatário corresponde",
|
"No tenants match": "Nenhum locatário corresponde",
|
||||||
"No tenants yet": "Nenhum locatário ainda",
|
"No tenants yet": "Nenhum locatário ainda",
|
||||||
@@ -513,7 +512,7 @@ export const catalog: Catalog = {
|
|||||||
"Waiting on the server — goes out {when}.": "Aguardando no servidor — será enviada {when}.",
|
"Waiting on the server — goes out {when}.": "Aguardando no servidor — será enviada {when}.",
|
||||||
"Scheduled — click to clear the schedule": "Programada — clique para cancelar a programação",
|
"Scheduled — click to clear the schedule": "Programada — clique para cancelar a programação",
|
||||||
"Nothing scheduled": "Nada programado",
|
"Nothing scheduled": "Nada programado",
|
||||||
"The message waits on the server, so it goes out whether or not ihasmail is open.": "A mensagem aguarda no servidor, então ela é enviada com o ihasmail aberto ou não.",
|
"The message waits on the server, so it goes out whether or not {app} is open.": "A mensagem aguarda no servidor, então ela é enviada com o {app} aberto ou não.",
|
||||||
"This server holds a message for up to {span}.": "Este servidor retém uma mensagem por até {span}.",
|
"This server holds a message for up to {span}.": "Este servidor retém uma mensagem por até {span}.",
|
||||||
"Date and time to send": "Data e hora do envio",
|
"Date and time to send": "Data e hora do envio",
|
||||||
"Undo send window": "Prazo para desfazer o envio",
|
"Undo send window": "Prazo para desfazer o envio",
|
||||||
@@ -735,7 +734,7 @@ export const catalog: Catalog = {
|
|||||||
"Sections": "Seções",
|
"Sections": "Seções",
|
||||||
"General": "Geral",
|
"General": "Geral",
|
||||||
"Appearance": "Aparência",
|
"Appearance": "Aparência",
|
||||||
"Make ihasmail yours.": "Deixe o ihasmail do seu jeito.",
|
"Make {app} yours.": "Deixe o {app} do seu jeito.",
|
||||||
"Reading": "Leitura",
|
"Reading": "Leitura",
|
||||||
"Reading pane": "Painel de leitura",
|
"Reading pane": "Painel de leitura",
|
||||||
"Right of the list": "À direita da lista",
|
"Right of the list": "À direita da lista",
|
||||||
@@ -752,6 +751,7 @@ export const catalog: Catalog = {
|
|||||||
"Attachment reminder": "Aviso de anexo",
|
"Attachment reminder": "Aviso de anexo",
|
||||||
"Warn when the message mentions an attachment but none is attached.": "Avisar quando a mensagem mencionar um anexo mas nenhum estiver anexado.",
|
"Warn when the message mentions an attachment but none is attached.": "Avisar quando a mensagem mencionar um anexo mas nenhum estiver anexado.",
|
||||||
"Spell check while typing": "Verificação ortográfica ao digitar",
|
"Spell check while typing": "Verificação ortográfica ao digitar",
|
||||||
|
"Open the composer full screen": "Escrever mensagens em tela cheia",
|
||||||
"Confirm before deleting": "Confirmar antes de excluir",
|
"Confirm before deleting": "Confirmar antes de excluir",
|
||||||
"Show message snippets": "Mostrar um trecho das mensagens",
|
"Show message snippets": "Mostrar um trecho das mensagens",
|
||||||
"Preview the first line of each message in the list.": "Mostrar a primeira linha de cada mensagem na lista.",
|
"Preview the first line of each message in the list.": "Mostrar a primeira linha de cada mensagem na lista.",
|
||||||
@@ -831,7 +831,8 @@ export const catalog: Catalog = {
|
|||||||
"Reset to defaults": "Restaurar os padrões",
|
"Reset to defaults": "Restaurar os padrões",
|
||||||
"Default mail app": "Aplicativo de e-mail padrão",
|
"Default mail app": "Aplicativo de e-mail padrão",
|
||||||
"Documentation": "Documentação",
|
"Documentation": "Documentação",
|
||||||
"About ihasmail": "Sobre o ihasmail",
|
"About {app}": "Sobre o {app}",
|
||||||
|
"Built on {project}": "Baseado no {project}",
|
||||||
"About": "Sobre",
|
"About": "Sobre",
|
||||||
"Server": "Servidor",
|
"Server": "Servidor",
|
||||||
"Server capabilities": "Recursos do servidor",
|
"Server capabilities": "Recursos do servidor",
|
||||||
@@ -932,6 +933,7 @@ export const catalog: Catalog = {
|
|||||||
"Turn off two-factor authentication": "Desativar a autenticação em duas etapas",
|
"Turn off two-factor authentication": "Desativar a autenticação em duas etapas",
|
||||||
"Your password alone will be enough to sign in again.": "Sua senha sozinha bastará para entrar de novo.",
|
"Your password alone will be enough to sign in again.": "Sua senha sozinha bastará para entrar de novo.",
|
||||||
"App passwords": "Senhas de aplicativo",
|
"App passwords": "Senhas de aplicativo",
|
||||||
|
"Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "Sua organização permite apenas {app} e aplicativos JMAP, então aplicativos de e-mail no celular e no computador não conseguem se conectar a esta conta.",
|
||||||
"New app password for": "Nova senha de aplicativo para",
|
"New app password for": "Nova senha de aplicativo para",
|
||||||
"Your new app password": "Sua nova senha de aplicativo",
|
"Your new app password": "Sua nova senha de aplicativo",
|
||||||
"Secret": "Segredo",
|
"Secret": "Segredo",
|
||||||
@@ -959,8 +961,8 @@ export const catalog: Catalog = {
|
|||||||
"Notifications": "Notificações",
|
"Notifications": "Notificações",
|
||||||
"Notifications are blocked in your browser settings.": "As notificações estão bloqueadas nas configurações do seu navegador.",
|
"Notifications are blocked in your browser settings.": "As notificações estão bloqueadas nas configurações do seu navegador.",
|
||||||
"Not supported in this browser.": "Sem suporte neste navegador.",
|
"Not supported in this browser.": "Sem suporte neste navegador.",
|
||||||
"Desktop notifications while ihasmail is open": "Notificações do sistema enquanto o ihasmail estiver aberto",
|
"Desktop notifications while {app} is open": "Notificações do sistema enquanto o {app} estiver aberto",
|
||||||
"Notify me even when ihasmail is closed": "Avisar mesmo quando o ihasmail estiver fechado",
|
"Notify me even when {app} is closed": "Avisar mesmo quando o {app} estiver fechado",
|
||||||
"Play a sound for new mail": "Tocar um som ao chegar e-mail",
|
"Play a sound for new mail": "Tocar um som ao chegar e-mail",
|
||||||
"Test notification": "Testar a notificação",
|
"Test notification": "Testar a notificação",
|
||||||
"Background notifications are on": "As notificações em segundo plano estão ativadas",
|
"Background notifications are on": "As notificações em segundo plano estão ativadas",
|
||||||
@@ -1129,7 +1131,7 @@ export const catalog: Catalog = {
|
|||||||
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Uma nova identidade precisa usar um endereço a partir do qual esta conta tenha permissão para enviar (aliases configurados no servidor).",
|
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Uma nova identidade precisa usar um endereço a partir do qual esta conta tenha permissão para enviar (aliases configurados no servidor).",
|
||||||
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Não é oferecida ao escrever. O endereço continua recebendo mensagens, e você pode voltar a enviar por ele mostrando-o novamente.",
|
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Não é oferecida ao escrever. O endereço continua recebendo mensagens, e você pode voltar a enviar por ele mostrando-o novamente.",
|
||||||
"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.": "Cada identidade é um endereço de envio com nome, endereço de resposta e assinatura próprios. A identidade padrão vem pré-selecionada ao escrever; defina um endereço de resposta quando as respostas devam ir para outro lugar que não o remetente.",
|
"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.": "Cada identidade é um endereço de envio com nome, endereço de resposta e assinatura próprios. A identidade padrão vem pré-selecionada ao escrever; defina um endereço de resposta quando as respostas devam ir para outro lugar que não o remetente.",
|
||||||
"This signature is larger than the server's {limit}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.": "Esta assinatura passa do limite de {limit} bytes do servidor. O ihasmail guardará a versão completa nos seus Arquivos e uma versão curta em texto no servidor — os outros clientes verão a versão em texto simples.",
|
"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.": "Esta assinatura passa do limite de {limit} bytes do servidor. O {app} guardar\u00e1 a vers\u00e3o completa nos seus Arquivos e uma vers\u00e3o curta em texto no servidor \u2014 os outros clientes ver\u00e3o a vers\u00e3o em texto simples.",
|
||||||
"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.": "Categorias no estilo do Outlook que você pode atribuir aos eventos pelo menu do botão direito ou pelo editor de eventos. O nome da categoria fica guardado no evento, então sincroniza com outros clientes.",
|
"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.": "Categorias no estilo do Outlook que você pode atribuir aos eventos pelo menu do botão direito ou pelo editor de eventos. O nome da categoria fica guardado no evento, então sincroniza com outros clientes.",
|
||||||
"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.": "As mensagens em texto simples já seguem o tema. Com esta opção, as mensagens HTML sem cores próprias também seguem, em vez de aparecerem sobre um fundo branco. As mensagens com estilo próprio ficam exatamente como o remetente as desenhou.",
|
"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.": "As mensagens em texto simples já seguem o tema. Com esta opção, as mensagens HTML sem cores próprias também seguem, em vez de aparecerem sobre um fundo branco. As mensagens com estilo próprio ficam exatamente como o remetente as desenhou.",
|
||||||
"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.": "Isto é independente de {setting} em Geral, que define como datas, horas e números são escritos. Você pode ler uma interface em inglês com datas em português, ou o contrário.",
|
"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.": "Isto é independente de {setting} em Geral, que define como datas, horas e números são escritos. Você pode ler uma interface em inglês com datas em português, ou o contrário.",
|
||||||
@@ -1137,40 +1139,40 @@ export const catalog: Catalog = {
|
|||||||
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Esta tela não é sensível ao toque, então nada aqui muda o comportamento dela. Seu celular ou tablet vai adotar estas opções.",
|
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Esta tela não é sensível ao toque, então nada aqui muda o comportamento dela. Seu celular ou tablet vai adotar estas opções.",
|
||||||
"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.": "Manter uma mensagem pressionada a seleciona, e manter uma pasta pressionada abre o menu dela. Puxe o topo da lista para baixo para procurar mensagens novas.",
|
"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.": "Manter uma mensagem pressionada a seleciona, e manter uma pasta pressionada abre o menu dela. Puxe o topo da lista para baixo para procurar mensagens novas.",
|
||||||
"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.": "Uma confirmação diz a quem pediu que este endereço está ativo e quando a mensagem foi lida, e o remetente escolhe para onde ela vai — por isso não há opção automática. Para mala direta, listas de discussão e tudo o que estiver marcado como enviado automaticamente, ela nunca é oferecida.",
|
"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.": "Uma confirmação diz a quem pediu que este endereço está ativo e quando a mensagem foi lida, e o remetente escolhe para onde ela vai — por isso não há opção automática. Para mala direta, listas de discussão e tudo o que estiver marcado como enviado automaticamente, ela nunca é oferecida.",
|
||||||
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.": "Este navegador não consegue registrar aplicativos para links {scheme}. O Safari, em particular, não tem essa interface — mesmo assim você pode definir o ihasmail como padrão pelo seu sistema operacional se instalá-lo como aplicativo.",
|
"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.": "Este navegador n\u00e3o consegue registrar aplicativos para links {scheme}. O Safari, em particular, n\u00e3o tem essa interface \u2014 mesmo assim voc\u00ea pode definir o {app} como padr\u00e3o pelo seu sistema operacional se instal\u00e1-lo como aplicativo.",
|
||||||
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Registrar-se para links {scheme} exige uma conexão segura (HTTPS).",
|
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Registrar-se para links {scheme} exige uma conexão segura (HTTPS).",
|
||||||
"Open {scheme} links — in web pages, documents and other apps — in ihasmail 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 › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).": "Abrir links {scheme} — em páginas da web, documentos e outros aplicativos — no ihasmail em vez de um cliente de e-mail local. Seu navegador pedirá confirmação, e você pode mudar isso depois nas configurações dele (Chrome: Configurações › Privacidade e segurança › Configurações do site › Manipuladores de protocolo; Firefox: Configurações › Geral › Aplicativos).",
|
"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).": "Abrir links {scheme} \u2014 em p\u00e1ginas da web, documentos e outros aplicativos \u2014 no {app} em vez de um cliente de e-mail local. Seu navegador pedir\u00e1 confirma\u00e7\u00e3o, e voc\u00ea pode mudar isso depois nas configura\u00e7\u00f5es dele (Chrome: Configura\u00e7\u00f5es \u203a Privacidade e seguran\u00e7a \u203a Configura\u00e7\u00f5es do site \u203a Manipuladores de protocolo; Firefox: Configura\u00e7\u00f5es \u203a Geral \u203a Aplicativos).",
|
||||||
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Solicitado neste navegador. Se surtiu efeito é decisão dele — verifique as configurações se os links de e-mail ainda abrirem em outro lugar.",
|
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Solicitado neste navegador. Se surtiu efeito é decisão dele — verifique as configurações se os links de e-mail ainda abrirem em outro lugar.",
|
||||||
"For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.": "Para um padrão em todo o sistema, instale o ihasmail como aplicativo primeiro (no Chrome: o ícone de instalação na barra de endereços). Seu sistema operacional poderá então oferecer o ihasmail diretamente onde quer que pergunte qual aplicativo de e-mail usar.",
|
"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.": "Para um padrão em todo o sistema, instale o {app} como aplicativo primeiro (no Chrome: o ícone de instalação na barra de endereços). Seu sistema operacional poderá então oferecer o {app} diretamente onde quer que pergunte qual aplicativo de e-mail usar.",
|
||||||
"Needs a browser with the Push API and a mail server that publishes a push key.": "Exige um navegador com a API Push e um servidor de e-mail que publique uma chave push.",
|
"Needs a browser with the Push API and a mail server that publishes a push key.": "Exige um navegador com a API Push e um servidor de e-mail que publique uma chave push.",
|
||||||
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Seu servidor de e-mail as entrega direto ao navegador, então elas chegam sem nenhuma aba do ihasmail aberta, com o remetente e o assunto. Mesmo assim o navegador precisa estar em execução — se você fechá-lo por completo, as notificações esperam e chegam quando você abri-lo de novo.",
|
"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.": "Seu servidor de e-mail as entrega direto ao navegador, ent\u00e3o elas chegam sem nenhuma aba do {app} aberta, com o remetente e o assunto. Mesmo assim o navegador precisa estar em execu\u00e7\u00e3o \u2014 se voc\u00ea fech\u00e1-lo por completo, as notifica\u00e7\u00f5es esperam e chegam quando voc\u00ea abri-lo de novo.",
|
||||||
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Seu servidor de e-mail consegue acordar este navegador, mas não informa o remetente nem o assunto. Mesmo assim o navegador precisa estar em execução.",
|
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Seu servidor de e-mail consegue acordar este navegador, mas não informa o remetente nem o assunto. Mesmo assim o navegador precisa estar em execução.",
|
||||||
"This is what a new-mail notification looks like.": "É assim que uma notificação de e-mail novo aparece.",
|
"This is what a new-mail notification looks like.": "É assim que uma notificação de e-mail novo aparece.",
|
||||||
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Você está conectado como {user}. Sua senha nunca é guardada no navegador; o servidor a mantém criptografada por sessão para falar com o Stalwart.",
|
"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.": "Você entrou como {user}. Sua senha nunca é guardada no navegador; o servidor a mantém criptografada por sessão para se comunicar com o servidor de e-mail.",
|
||||||
"App passwords are managed by your mail administrator.": "As senhas de aplicativo são gerenciadas pelo seu administrador de e-mail.",
|
"App passwords are managed by your mail administrator.": "As senhas de aplicativo são gerenciadas pelo seu administrador de e-mail.",
|
||||||
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Mudar sua senha encerra suas outras sessões de webmail. As senhas de aplicativo continuam funcionando.",
|
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Mudar sua senha encerra suas outras sessões de webmail. As senhas de aplicativo continuam funcionando.",
|
||||||
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Esta conta está com a autenticação em duas etapas ativada. O ihasmail ainda não consegue conectar você com um código, então entrar em outro dispositivo exige uma senha de aplicativo — ou você pode desativar a autenticação em duas etapas aqui.",
|
"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.": "Esta conta est\u00e1 com a autentica\u00e7\u00e3o em duas etapas ativada. O {app} ainda n\u00e3o consegue conectar voc\u00ea com um c\u00f3digo, ent\u00e3o entrar em outro dispositivo exige uma senha de aplicativo \u2014 ou voc\u00ea pode desativar a autentica\u00e7\u00e3o em duas etapas aqui.",
|
||||||
"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.": "Uma senha separada para um aplicativo de e-mail ou dispositivo, que você pode revogar sozinha. As senhas de aplicativo dispensam os códigos de duas etapas, então continuam funcionando em aplicativos que não conseguem pedir um.",
|
"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.": "Uma senha separada para um aplicativo de e-mail ou dispositivo, que você pode revogar sozinha. As senhas de aplicativo dispensam os códigos de duas etapas, então continuam funcionando em aplicativos que não conseguem pedir um.",
|
||||||
"Copy it into {name} now — it isn't shown again.": "Copie-a para {name} agora — ela não será mostrada de novo.",
|
"Copy it into {name} now — it isn't shown again.": "Copie-a para {name} agora — ela não será mostrada de novo.",
|
||||||
"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.": "Nenhum outro usuário encontrado no diretório, então ninguém novo pode ser adicionado. O que já está compartilhado aparece abaixo e ainda pode ser removido.",
|
"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.": "Nenhum outro usuário encontrado no diretório, então ninguém novo pode ser adicionado. O que já está compartilhado aparece abaixo e ainda pode ser removido.",
|
||||||
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "O Stalwart não informa seu número de versão aos clientes de e-mail, então o ihasmail indica a edição quando o servidor fornece uma. O ihasmail exige a versão 0.16 ou mais recente, e o login recusa qualquer versão anterior.",
|
"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.": "O Stalwart n\u00e3o informa seu n\u00famero de vers\u00e3o aos clientes de e-mail, ent\u00e3o o {app} indica a edi\u00e7\u00e3o quando o servidor fornece uma. O {app} exige a vers\u00e3o 0.16 ou mais recente, e o login recusa qualquer vers\u00e3o anterior.",
|
||||||
"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.": "Ele {damage}, então as regras nele não podem ser mostradas nem editadas — salvar o que chegou sobrescreveria o resto. Recarregue a página para tentar de novo. Suas regras continuam no servidor; nada aqui as alterou.",
|
"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.": "Ele {damage}, então as regras nele não podem ser mostradas nem editadas — salvar o que chegou sobrescreveria o resto. Recarregue a página para tentar de novo. Suas regras continuam no servidor; nada aqui as alterou.",
|
||||||
"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).": "O editor visual de regras só gerencia os scripts que ele mesmo criou. Você pode editar o script na aba {tab}, ou começar do zero com regras (o script existente será mantido, mas desativado).",
|
"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).": "O editor visual de regras só gerencia os scripts que ele mesmo criou. Você pode editar o script na aba {tab}, ou começar do zero com regras (o script existente será mantido, mas desativado).",
|
||||||
"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.": "Seu script de filtragem {damage}, então só parte dele chegou. Adicionar uma regra escreveria essa parte por cima do todo. Recarregue a página e tente de novo.",
|
"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.": "Seu script de filtragem {damage}, então só parte dele chegou. Adicionar uma regra escreveria essa parte por cima do todo. Recarregue a página e tente de novo.",
|
||||||
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Seu script de filtragem não pôde ser lido agora, então adicionar uma regra poderia sobrescrevê-lo. Recarregue a página e tente de novo.",
|
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Seu script de filtragem não pôde ser lido agora, então adicionar uma regra poderia sobrescrevê-lo. Recarregue a página e tente de novo.",
|
||||||
"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.": "Seu script Sieve ativo foi escrito à mão, então não dá para adicionar regras automaticamente. Abra {where} para editar o script ou mudar para regras gerenciadas.",
|
"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.": "Seu script Sieve ativo foi escrito à mão, então não dá para adicionar regras automaticamente. Abra {where} para editar o script ou mudar para regras gerenciadas.",
|
||||||
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Aqui aparecem só os idiomas para os quais o ihasmail foi traduzido, então a lista cresce conforme as traduções chegam, e não antes — um idioma oferecido sem textos por trás faria a página afirmar estar em um idioma que não é o dela.",
|
"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.": "Aqui aparecem s\u00f3 os idiomas para os quais o {app} foi traduzido, ent\u00e3o a lista cresce conforme as tradu\u00e7\u00f5es chegam, e n\u00e3o antes \u2014 um idioma oferecido sem textos por tr\u00e1s faria a p\u00e1gina afirmar estar em um idioma que n\u00e3o \u00e9 o dela.",
|
||||||
"tell us about it": "conte para nós",
|
"tell us about it": "conte para nós",
|
||||||
"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}.": "Esta tradução foi gerada por IA e não foi revisada por uma pessoa nativa, então está marcada como Beta até que alguém a aprove. Tudo o que soar errado vale um aviso — {report}.",
|
"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}.": "Esta tradução foi gerada por IA e não foi revisada por uma pessoa nativa, então está marcada como Beta até que alguém a aprove. Tudo o que soar errado vale um aviso — {report}.",
|
||||||
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "A versão do próprio ihasmail é a data do commit a partir do qual ele foi compilado, seguida da origem desse commit: {example} foi compilado a partir de um commit de 30 de agosto de 2026 que veio pela pull request 129. Um commit que não veio por uma delas carrega no lugar o SHA curto — {sha}. A versão não diz nada sobre o Stalwart de propósito; o que esta compilação precisa do servidor está na linha acima.",
|
"{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.": "A vers\u00e3o do pr\u00f3prio {app} \u00e9 a data do commit a partir do qual ele foi compilado, seguida da origem desse commit: {example} foi compilado a partir de um commit de 30 de agosto de 2026 que veio pela pull request 129. Um commit que n\u00e3o veio por uma delas carrega no lugar o SHA curto \u2014 {sha}. A vers\u00e3o n\u00e3o diz nada sobre o Stalwart de prop\u00f3sito; o que esta compila\u00e7\u00e3o precisa do servidor est\u00e1 na linha acima.",
|
||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Nova mensagem",
|
"New message": "Nova mensagem",
|
||||||
"Start a new message with what was shared?": "Iniciar uma nova mensagem com o que foi compartilhado?",
|
"Start a new message with what was shared?": "Iniciar uma nova mensagem com o que foi compartilhado?",
|
||||||
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Algo foi compartilhado com o ihasmail. Nada é enviado até você escolher Enviar. Se não foi você que acabou de compartilhar, descarte.",
|
"Something was shared with {app}. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Algo foi compartilhado com o {app}. Nada é enviado até você escolher Enviar. Se não foi você que acabou de compartilhar, descarte.",
|
||||||
"Start a message": "Iniciar mensagem",
|
"Start a message": "Iniciar mensagem",
|
||||||
"New mail": "Novo e-mail",
|
"New mail": "Novo e-mail",
|
||||||
"Could not do that — open ihasmail and try again": "Não foi possível fazer isso — abra o ihasmail e tente novamente",
|
"Could not do that \u2014 open {app} and try again": "N\u00e3o foi poss\u00edvel fazer isso \u2014 abra o {app} e tente novamente",
|
||||||
"Sending…": "Enviando…",
|
"Sending…": "Enviando…",
|
||||||
"Saving…": "Salvando…",
|
"Saving…": "Salvando…",
|
||||||
"Error": "Erro",
|
"Error": "Erro",
|
||||||
@@ -1218,6 +1220,20 @@ export const catalog: Catalog = {
|
|||||||
"Could not save filters: {error}": "Não foi possível salvar os filtros: {error}",
|
"Could not save filters: {error}": "Não foi possível salvar os filtros: {error}",
|
||||||
"Could not send the receipt: {error}": "Não foi possível enviar a confirmação de leitura: {error}",
|
"Could not send the receipt: {error}": "Não foi possível enviar a confirmação de leitura: {error}",
|
||||||
"Could not sign in.": "Não foi possível entrar.",
|
"Could not sign in.": "Não foi possível entrar.",
|
||||||
|
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "Você entrou como {user}. Este webmail nunca vê sua senha: ele guarda um token de entrada do seu servidor de e-mail, criptografado por sessão.",
|
||||||
|
"Mail server": "Servidor de e-mail",
|
||||||
|
"You'll enter your password on your mail server's sign-in page.": "Você vai digitar sua senha na página de entrada do seu servidor de e-mail.",
|
||||||
|
"You'll sign in on your mail server's own page.": "Você vai entrar na própria página do seu servidor de e-mail.",
|
||||||
|
"This sign-in didn't start in this browser. Try again.": "Esta entrada não começou neste navegador. Tente de novo.",
|
||||||
|
"The sign-in took too long. Try again.": "A entrada demorou demais. Tente de novo.",
|
||||||
|
"Sign-in was cancelled.": "A entrada foi cancelada.",
|
||||||
|
"The mail server didn't accept the sign-in. Try again.": "O servidor de e-mail não aceitou a entrada. Tente de novo.",
|
||||||
|
"That account is on a different mail server than the address you entered. Sign in with that address.": "Essa conta está em um servidor de e-mail diferente do endereço que você digitou. Entre com esse endereço.",
|
||||||
|
"This mail server isn't supported.": "Este servidor de e-mail não é compatível.",
|
||||||
|
"Couldn't reach the mail server. Try again in a moment.": "Não foi possível acessar o servidor de e-mail. Tente de novo em instantes.",
|
||||||
|
"Your password was changed. Sign in with the new one.": "Sua senha foi alterada. Entre com a nova.",
|
||||||
|
"Your sign-in ended. Sign in again.": "Sua sessão terminou. Entre novamente.",
|
||||||
|
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Mudar sua senha encerra todas as suas sessões, inclusive esta, e você entra de novo com a nova senha. As senhas de aplicativo continuam funcionando.",
|
||||||
"Could not store image: {error}": "Não foi possível armazenar a imagem: {error}",
|
"Could not store image: {error}": "Não foi possível armazenar a imagem: {error}",
|
||||||
"Could not update labels: {error}": "Não foi possível atualizar os marcadores: {error}",
|
"Could not update labels: {error}": "Não foi possível atualizar os marcadores: {error}",
|
||||||
"Could not update the Scheduled folder: {error}": "Não foi possível atualizar a pasta Agendados: {error}",
|
"Could not update the Scheduled folder: {error}": "Não foi possível atualizar a pasta Agendados: {error}",
|
||||||
@@ -1340,7 +1356,7 @@ export const catalog: Catalog = {
|
|||||||
"Undo window: {seconds}s": "Tempo para desfazer: {seconds} s",
|
"Undo window: {seconds}s": "Tempo para desfazer: {seconds} s",
|
||||||
"You're all caught up": "Você está em dia",
|
"You're all caught up": "Você está em dia",
|
||||||
"Your browser refused the request: {error}": "Seu navegador recusou a solicitação: {error}",
|
"Your browser refused the request: {error}": "Seu navegador recusou a solicitação: {error}",
|
||||||
"Your browser will ask whether to open mail links in ihasmail": "Seu navegador vai perguntar se os links de e-mail devem abrir no ihasmail",
|
"Your browser will ask whether to open mail links in {app}": "Seu navegador vai perguntar se os links de e-mail devem abrir no {app}",
|
||||||
"Your message mentions an attachment, but nothing is attached.": "Sua mensagem menciona um anexo, mas nada foi anexado.",
|
"Your message mentions an attachment, but nothing is attached.": "Sua mensagem menciona um anexo, mas nada foi anexado.",
|
||||||
"event": "evento",
|
"event": "evento",
|
||||||
"Hide password": "Ocultar a senha",
|
"Hide password": "Ocultar a senha",
|
||||||
@@ -1411,7 +1427,7 @@ export const catalog: Catalog = {
|
|||||||
"Also count these domains as inside": "Contar também estes domínios como internos",
|
"Also count these domains as inside": "Contar também estes domínios como internos",
|
||||||
"Always": "Sempre",
|
"Always": "Sempre",
|
||||||
"Always showing images from": "Sempre exibindo imagens de",
|
"Always showing images from": "Sempre exibindo imagens de",
|
||||||
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.": "Uma imagem carregada do servidor do remetente informa a ele que a mensagem foi aberta, quando e aproximadamente de onde. As imagens aprovadas são buscadas pelo próprio servidor do ihasmail, e não pelo navegador, de modo que o remetente não fica sabendo de nada disso.",
|
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.": "Uma imagem carregada do servidor do remetente informa a ele que a mensagem foi aberta, quando e aproximadamente de onde. As imagens aprovadas são buscadas pelo próprio servidor do {app}, e não pelo navegador, de modo que o remetente não fica sabendo de nada disso.",
|
||||||
"Applies to": "Aplica-se a",
|
"Applies to": "Aplica-se a",
|
||||||
"Archive and next": "Arquivar e próxima",
|
"Archive and next": "Arquivar e próxima",
|
||||||
"Archive by month": "Arquivar por mês",
|
"Archive by month": "Arquivar por mês",
|
||||||
@@ -1653,8 +1669,8 @@ export const catalog: Catalog = {
|
|||||||
"Fingerprint": "Impressão digital",
|
"Fingerprint": "Impressão digital",
|
||||||
"Hide details": "Ocultar detalhes",
|
"Hide details": "Ocultar detalhes",
|
||||||
"Issued by": "Emitido por",
|
"Issued by": "Emitido por",
|
||||||
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Está assinada com OpenPGP, e o ihasmail não tem como obter a chave pública do remetente.",
|
"It is signed with OpenPGP, and {app} has no way to fetch the sender's public key.": "Está assinada com OpenPGP, e o {app} não tem como obter a chave pública do remetente.",
|
||||||
"It uses a signature algorithm ihasmail cannot check yet.": "Usa um algoritmo de assinatura que o ihasmail ainda não consegue conferir.",
|
"It uses a signature algorithm {app} cannot check yet.": "Usa um algoritmo de assinatura que o {app} ainda não consegue conferir.",
|
||||||
"It was made with a certificate belonging to {name}, which does not cover this address.": "Foi feita com um certificado de {name}, que não cobre este endereço.",
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Foi feita com um certificado de {name}, que não cobre este endereço.",
|
||||||
"Previous fingerprint": "Impressão digital anterior",
|
"Previous fingerprint": "Impressão digital anterior",
|
||||||
"Signed at": "Assinado em",
|
"Signed at": "Assinado em",
|
||||||
@@ -1671,18 +1687,39 @@ export const catalog: Catalog = {
|
|||||||
"The signature is not for this sender.": "A assinatura não é deste remetente.",
|
"The signature is not for this sender.": "A assinatura não é deste remetente.",
|
||||||
"The signed part is missing either the message or the signature.": "Falta à parte assinada ou a mensagem ou a assinatura.",
|
"The signed part is missing either the message or the signature.": "Falta à parte assinada ou a mensagem ou a assinatura.",
|
||||||
"The signer has changed.": "O signatário mudou.",
|
"The signer has changed.": "O signatário mudou.",
|
||||||
"This message is signed, and ihasmail could not check the signature.": "Esta mensagem está assinada, e o ihasmail não conseguiu conferir a assinatura.",
|
"This message is signed, and {app} could not check the signature.": "Esta mensagem está assinada, e o {app} não conseguiu conferir a assinatura.",
|
||||||
"This signature does not check out.": "Esta assinatura não confere.",
|
"This signature does not check out.": "Esta assinatura não confere.",
|
||||||
"Valid until": "Válido até",
|
"Valid until": "Válido até",
|
||||||
"a different certificate": "um certificado diferente",
|
"a different certificate": "um certificado diferente",
|
||||||
"an unnamed signer": "um signatário sem nome",
|
"an unnamed signer": "um signatário sem nome",
|
||||||
"as claimed by the signer": "conforme declarado pelo signatário",
|
"as claimed by the signer": "conforme declarado pelo signatário",
|
||||||
"first seen {date}": "visto pela primeira vez em {date}",
|
"first seen {date}": "visto pela primeira vez em {date}",
|
||||||
"ihasmail will tell you if a later message from this address is signed by anybody else.": "O ihasmail avisará você se uma mensagem posterior deste endereço for assinada por outra pessoa.",
|
"{app} will tell you if a later message from this address is signed by anybody else.": "O {app} avisará você se uma mensagem posterior deste endereço for assinada por outra pessoa.",
|
||||||
"itself, or an issuer it does not name": "ele mesmo, ou um emissor que ele não nomeia",
|
"itself, or an issuer it does not name": "ele mesmo, ou um emissor que ele não nomeia",
|
||||||
"no address": "nenhum endereço",
|
"no address": "nenhum endereço",
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"Legacy mail apps": "Aplicativos de e-mail com protocolos legados",
|
||||||
|
"Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.": "Desativados para {tenant}. Só {app} e aplicativos JMAP podem entrar nos domínios dessa organização.",
|
||||||
|
"On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.": "Ativados para {tenant}. Aplicativos de e-mail podem usar IMAP, POP3 e ManageSieve nos domínios dessa organização.",
|
||||||
|
"Turn legacy protocols back on": "Reativar os protocolos de e-mail legados",
|
||||||
|
"Turn off legacy protocols…": "Desativar os protocolos de e-mail legados…",
|
||||||
|
"No account used a legacy mail app in the last 30 days.": "Nenhuma conta usou um aplicativo de e-mail com protocolos legados nos últimos 30 dias.",
|
||||||
|
"Their mail apps will stop working the moment you turn this on:": "Os aplicativos de e-mail dessas contas vão parar de funcionar assim que você ativar isto:",
|
||||||
|
"Only {app} and JMAP apps will work.": "Só {app} e aplicativos JMAP vão funcionar.",
|
||||||
|
"Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {tenant}.": "Os protocolos de e-mail legados (IMAP, POP3, ManageSieve e o envio por aplicativos de e-mail) serão desativados para todos em {tenant}.",
|
||||||
|
"Phone and desktop mail apps will stop receiving and sending mail. That's iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.": "Aplicativos de e-mail no celular e no computador vão parar de receber e enviar e-mail. Isso inclui o Mail do iPhone e do iPad, os aplicativos Gmail e Outlook, o Outlook, o Thunderbird e o Apple Mail. As pessoas verão erros de login neles.",
|
||||||
|
"Filters managed from a mail app (ManageSieve) will stop working. Filters set in {app} keep working.": "Filtros gerenciados por um aplicativo de e-mail (ManageSieve) vão parar de funcionar. Filtros configurados no {app} continuam funcionando.",
|
||||||
|
"Incoming mail is not affected. Calendars and contacts are not affected.": "O e-mail recebido não é afetado. Agendas e contatos não são afetados.",
|
||||||
|
"People keep full access through {app}, which can be installed as an app on phones and computers.": "Todos mantêm acesso completo pelo {app}, que pode ser instalado como aplicativo em celulares e computadores.",
|
||||||
|
"Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and {app} (JMAP) are not affected and cannot be turned off here.": "O envio por aplicativos de e-mail (SMTP submission) vai parar de funcionar, mas as portas continuam abertas: os aplicativos de e-mail serão informados de que não podem entrar. O e-mail recebido (SMTP) e o {app} (JMAP) não são afetados e não podem ser desativados aqui.",
|
||||||
|
"You can turn legacy protocols back on at any time.": "Você pode reativar os protocolos de e-mail legados a qualquer momento.",
|
||||||
|
"To confirm, type {phrase}": "Para confirmar, digite {phrase}",
|
||||||
|
"Turn off legacy protocols": "Desativar os protocolos de e-mail legados",
|
||||||
|
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Os protocolos de e-mail legados estão desativados para sua organização. Só {app} e aplicativos JMAP podem entrar.",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"{n} accounts used a legacy mail app in the last 30 days.": {one: "{n} conta usou um aplicativo de e-mail com protocolos legados nos últimos 30 dias.", other: "{n} contas usaram um aplicativo de e-mail com protocolos legados nos últimos 30 dias."},
|
||||||
// ── Administration: domains ────────────────────────────────────
|
// ── Administration: domains ────────────────────────────────────
|
||||||
"{n} accounts use this domain. Move or delete them first.": { one: "{n} conta usa este domínio. Mova-a ou exclua-a primeiro.", other: "{n} contas usam este domínio. Mova-as ou exclua-as primeiro." },
|
"{n} accounts use this domain. Move or delete them first.": { one: "{n} conta usa este domínio. Mova-a ou exclua-a primeiro.", other: "{n} contas usam este domínio. Mova-as ou exclua-as primeiro." },
|
||||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "O servidor deixa de aceitar e-mails para este domínio, e a {n} chave DKIM dele é excluída. Não é possível desfazer.", other: "O servidor deixa de aceitar e-mails para este domínio, e as {n} chaves DKIM dele são excluídas. Não é possível desfazer." },
|
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "O servidor deixa de aceitar e-mails para este domínio, e a {n} chave DKIM dele é excluída. Não é possível desfazer.", other: "O servidor deixa de aceitar e-mails para este domínio, e as {n} chaves DKIM dele são excluídas. Não é possível desfazer." },
|
||||||
|
|||||||
+68
-31
@@ -141,8 +141,8 @@ export const catalog: Catalog = {
|
|||||||
"The numbers your role can see, as the server reports them.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.",
|
"The numbers your role can see, as the server reports them.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.",
|
||||||
"Nothing to show": "Нечего показать",
|
"Nothing to show": "Нечего показать",
|
||||||
"Could not be loaded": "Не удалось загрузить",
|
"Could not be loaded": "Не удалось загрузить",
|
||||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в собственной панели администрирования Stalwart.",
|
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Подробные метрики, очередь доставки, журналы и настройки сервера находятся в INBUXA Admin.",
|
||||||
"Open Stalwart admin": "Открыть администрирование Stalwart",
|
"Open INBUXA Admin": "Открыть INBUXA Admin",
|
||||||
"Default group role": "Роль группы по умолчанию",
|
"Default group role": "Роль группы по умолчанию",
|
||||||
"A group needs an address.": "Группе нужен адрес.",
|
"A group needs an address.": "Группе нужен адрес.",
|
||||||
"New group": "Новая группа",
|
"New group": "Новая группа",
|
||||||
@@ -211,10 +211,10 @@ export const catalog: Catalog = {
|
|||||||
"New role": "Новая роль",
|
"New role": "Новая роль",
|
||||||
"This role carries permissions yours doesn't, so you can view it but not change it.": "У этой роли есть разрешения, которых нет у вашей, поэтому её можно просматривать, но не изменять.",
|
"This role carries permissions yours doesn't, so you can view it but not change it.": "У этой роли есть разрешения, которых нет у вашей, поэтому её можно просматривать, но не изменять.",
|
||||||
"Your role lets you view roles but not change them.": "Ваша роль позволяет просматривать роли, но не изменять их.",
|
"Your role lets you view roles but not change them.": "Ваша роль позволяет просматривать роли, но не изменять их.",
|
||||||
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart по умолчанию выдаёт эту роль {kinds}. Изменение здесь затронет всех, кто получил её так.",
|
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Почтовый сервер по умолчанию выдаёт эту роль: {kinds}. Изменение здесь затронет всех, кто получил её так.",
|
||||||
"Builds on": "Основана на",
|
"Builds on": "Основана на",
|
||||||
"Permissions": "Разрешения",
|
"Permissions": "Разрешения",
|
||||||
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart выдаёт эту роль по умолчанию, поэтому её нельзя удалить. Сначала измените значения по умолчанию в собственной панели администрирования Stalwart.",
|
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Почтовый сервер выдаёт эту роль по умолчанию, поэтому её нельзя удалить. Сначала измените значения по умолчанию в INBUXA Admin.",
|
||||||
"This role carries permissions yours doesn't.": "У этой роли есть разрешения, которых нет у вашей.",
|
"This role carries permissions yours doesn't.": "У этой роли есть разрешения, которых нет у вашей.",
|
||||||
"Create role": "Создать роль",
|
"Create role": "Создать роль",
|
||||||
"builds on this one": "основана на этой",
|
"builds on this one": "основана на этой",
|
||||||
@@ -241,7 +241,7 @@ export const catalog: Catalog = {
|
|||||||
"Delete role": "Удалить роль",
|
"Delete role": "Удалить роль",
|
||||||
"It can't be undone.": "Это нельзя отменить.",
|
"It can't be undone.": "Это нельзя отменить.",
|
||||||
"Type {name} to confirm": "Введите {name} для подтверждения",
|
"Type {name} to confirm": "Введите {name} для подтверждения",
|
||||||
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Не удалось загрузить список разрешений Stalwart, поэтому изменить разрешения здесь нельзя. ({reason})",
|
"The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Не удалось загрузить список разрешений почтового сервера, поэтому здесь нельзя изменить разрешения. ({reason})",
|
||||||
"Named sets of permissions, given to accounts, groups and tenants.": "Именованные наборы разрешений для учётных записей, групп и арендаторов.",
|
"Named sets of permissions, given to accounts, groups and tenants.": "Именованные наборы разрешений для учётных записей, групп и арендаторов.",
|
||||||
"Search roles": "Поиск ролей",
|
"Search roles": "Поиск ролей",
|
||||||
"No roles match": "Нет подходящих ролей",
|
"No roles match": "Нет подходящих ролей",
|
||||||
@@ -264,11 +264,11 @@ export const catalog: Catalog = {
|
|||||||
"{used} used": "Занято {used}",
|
"{used} used": "Занято {used}",
|
||||||
"Your role lets you view tenants but not change them.": "Ваша роль позволяет просматривать арендаторов, но не изменять их.",
|
"Your role lets you view tenants but not change them.": "Ваша роль позволяет просматривать арендаторов, но не изменять их.",
|
||||||
"Logo": "Логотип",
|
"Logo": "Логотип",
|
||||||
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Адрес https или data-URL изображения. Stalwart показывает его людям арендатора там, где показывает логотип.",
|
"An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Адрес https или data-URL изображения. Почтовый сервер показывает его людям арендатора там, где показывает логотип.",
|
||||||
"What it holds": "Содержимое",
|
"What it holds": "Содержимое",
|
||||||
"{n} of {limit}": "{n} из {limit}",
|
"{n} of {limit}": "{n} из {limit}",
|
||||||
"Limits": "Лимиты",
|
"Limits": "Лимиты",
|
||||||
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart не даёт создать больше, чем позволяет лимит. Пустое поле — без лимита.",
|
"The mail server refuses to create more than a limit allows. An empty field is no limit.": "Почтовый сервер не позволяет создать больше, чем разрешает лимит. Пустое поле означает отсутствие лимита.",
|
||||||
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Максимум того, что может быть разрешено любому в этом арендаторе: их собственные роли урезаются до того, что дают эти. Предлагаются только роли, разрешения которых есть у вас самих.",
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Максимум того, что может быть разрешено любому в этом арендаторе: их собственные роли урезаются до того, что дают эти. Предлагаются только роли, разрешения которых есть у вас самих.",
|
||||||
"Checking what is still in this tenant…": "Проверка того, что ещё есть у этого арендатора…",
|
"Checking what is still in this tenant…": "Проверка того, что ещё есть у этого арендатора…",
|
||||||
"It still holds accounts, domains or other things. Move them out first.": "У него ещё есть учётные записи, домены или что-то другое. Сначала перенесите их.",
|
"It still holds accounts, domains or other things. Move them out first.": "У него ещё есть учётные записи, домены или что-то другое. Сначала перенесите их.",
|
||||||
@@ -284,7 +284,6 @@ export const catalog: Catalog = {
|
|||||||
"Still holds {things}. Move them out first.": "Ещё содержит: {things}. Сначала перенесите их.",
|
"Still holds {things}. Move them out first.": "Ещё содержит: {things}. Сначала перенесите их.",
|
||||||
"Delete tenant": "Удалить арендатора",
|
"Delete tenant": "Удалить арендатора",
|
||||||
"Separate organizations on one server, each with its own people, domains and limits.": "Отдельные организации на одном сервере, у каждой свои люди, домены и лимиты.",
|
"Separate organizations on one server, each with its own people, domains and limits.": "Отдельные организации на одном сервере, у каждой свои люди, домены и лимиты.",
|
||||||
"Tenants are a Stalwart Enterprise feature.": "Арендаторы — функция Stalwart Enterprise.",
|
|
||||||
"Search tenants": "Поиск арендаторов",
|
"Search tenants": "Поиск арендаторов",
|
||||||
"No tenants match": "Нет подходящих арендаторов",
|
"No tenants match": "Нет подходящих арендаторов",
|
||||||
"No tenants yet": "Арендаторов пока нет",
|
"No tenants yet": "Арендаторов пока нет",
|
||||||
@@ -512,7 +511,7 @@ export const catalog: Catalog = {
|
|||||||
"Waiting on the server — goes out {when}.": "Ожидает на сервере — будет отправлено {when}.",
|
"Waiting on the server — goes out {when}.": "Ожидает на сервере — будет отправлено {when}.",
|
||||||
"Scheduled — click to clear the schedule": "Отложено — нажмите, чтобы отменить",
|
"Scheduled — click to clear the schedule": "Отложено — нажмите, чтобы отменить",
|
||||||
"Nothing scheduled": "Ничего не отложено",
|
"Nothing scheduled": "Ничего не отложено",
|
||||||
"The message waits on the server, so it goes out whether or not ihasmail is open.": "Письмо ждёт на сервере и будет отправлено независимо от того, открыт ihasmail или нет.",
|
"The message waits on the server, so it goes out whether or not {app} is open.": "Письмо ждёт на сервере и будет отправлено независимо от того, открыт {app} или нет.",
|
||||||
"This server holds a message for up to {span}.": "Этот сервер удерживает письмо до {span}.",
|
"This server holds a message for up to {span}.": "Этот сервер удерживает письмо до {span}.",
|
||||||
"Date and time to send": "Дата и время отправки",
|
"Date and time to send": "Дата и время отправки",
|
||||||
"Undo send window": "Время на отмену отправки",
|
"Undo send window": "Время на отмену отправки",
|
||||||
@@ -735,7 +734,7 @@ export const catalog: Catalog = {
|
|||||||
"Sections": "Разделы",
|
"Sections": "Разделы",
|
||||||
"General": "Общие",
|
"General": "Общие",
|
||||||
"Appearance": "Внешний вид",
|
"Appearance": "Внешний вид",
|
||||||
"Make ihasmail yours.": "Настройте ihasmail под себя.",
|
"Make {app} yours.": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 {app} \u043f\u043e\u0434 \u0441\u0435\u0431\u044f.",
|
||||||
"Reading": "Чтение",
|
"Reading": "Чтение",
|
||||||
"Reading pane": "Область чтения",
|
"Reading pane": "Область чтения",
|
||||||
"Right of the list": "Справа от списка",
|
"Right of the list": "Справа от списка",
|
||||||
@@ -752,6 +751,7 @@ export const catalog: Catalog = {
|
|||||||
"Attachment reminder": "Напоминание о вложении",
|
"Attachment reminder": "Напоминание о вложении",
|
||||||
"Warn when the message mentions an attachment but none is attached.": "Предупреждать, если письмо упоминает вложение, но его нет.",
|
"Warn when the message mentions an attachment but none is attached.": "Предупреждать, если письмо упоминает вложение, но его нет.",
|
||||||
"Spell check while typing": "Проверять орфографию при вводе",
|
"Spell check while typing": "Проверять орфографию при вводе",
|
||||||
|
"Open the composer full screen": "Писать письма во весь экран",
|
||||||
"Confirm before deleting": "Спрашивать перед удалением",
|
"Confirm before deleting": "Спрашивать перед удалением",
|
||||||
"Show message snippets": "Показывать начало письма",
|
"Show message snippets": "Показывать начало письма",
|
||||||
"Preview the first line of each message in the list.": "Показывать первую строку каждого письма в списке.",
|
"Preview the first line of each message in the list.": "Показывать первую строку каждого письма в списке.",
|
||||||
@@ -831,7 +831,8 @@ export const catalog: Catalog = {
|
|||||||
"Reset to defaults": "Сбросить к значениям по умолчанию",
|
"Reset to defaults": "Сбросить к значениям по умолчанию",
|
||||||
"Default mail app": "Почтовая программа по умолчанию",
|
"Default mail app": "Почтовая программа по умолчанию",
|
||||||
"Documentation": "Документация",
|
"Documentation": "Документация",
|
||||||
"About ihasmail": "О программе ihasmail",
|
"About {app}": "О программе {app}",
|
||||||
|
"Built on {project}": "Основано на {project}",
|
||||||
"About": "О программе",
|
"About": "О программе",
|
||||||
"Server": "Сервер",
|
"Server": "Сервер",
|
||||||
"Server capabilities": "Возможности сервера",
|
"Server capabilities": "Возможности сервера",
|
||||||
@@ -932,6 +933,7 @@ export const catalog: Catalog = {
|
|||||||
"Turn off two-factor authentication": "Отключить двухфакторную аутентификацию",
|
"Turn off two-factor authentication": "Отключить двухфакторную аутентификацию",
|
||||||
"Your password alone will be enough to sign in again.": "Для входа снова будет достаточно одного пароля.",
|
"Your password alone will be enough to sign in again.": "Для входа снова будет достаточно одного пароля.",
|
||||||
"App passwords": "Пароли приложений",
|
"App passwords": "Пароли приложений",
|
||||||
|
"Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "Ваша организация разрешает только {app} и приложения JMAP, поэтому почтовые приложения на телефоне и компьютере не могут подключиться к этой учётной записи.",
|
||||||
"New app password for": "Новый пароль приложения для",
|
"New app password for": "Новый пароль приложения для",
|
||||||
"Your new app password": "Ваш новый пароль приложения",
|
"Your new app password": "Ваш новый пароль приложения",
|
||||||
"Secret": "Секрет",
|
"Secret": "Секрет",
|
||||||
@@ -959,8 +961,8 @@ export const catalog: Catalog = {
|
|||||||
"Notifications": "Уведомления",
|
"Notifications": "Уведомления",
|
||||||
"Notifications are blocked in your browser settings.": "Уведомления заблокированы в настройках браузера.",
|
"Notifications are blocked in your browser settings.": "Уведомления заблокированы в настройках браузера.",
|
||||||
"Not supported in this browser.": "Не поддерживается в этом браузере.",
|
"Not supported in this browser.": "Не поддерживается в этом браузере.",
|
||||||
"Desktop notifications while ihasmail is open": "Системные уведомления, пока ihasmail открыт",
|
"Desktop notifications while {app} is open": "Системные уведомления, пока {app} открыт",
|
||||||
"Notify me even when ihasmail is closed": "Уведомлять, даже когда ihasmail закрыт",
|
"Notify me even when {app} is closed": "Уведомлять, даже когда {app} закрыт",
|
||||||
"Play a sound for new mail": "Звук при новом письме",
|
"Play a sound for new mail": "Звук при новом письме",
|
||||||
"Test notification": "Проверить уведомление",
|
"Test notification": "Проверить уведомление",
|
||||||
"Background notifications are on": "Фоновые уведомления включены",
|
"Background notifications are on": "Фоновые уведомления включены",
|
||||||
@@ -1128,7 +1130,7 @@ export const catalog: Catalog = {
|
|||||||
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Новый профиль должен использовать адрес, с которого этой учётной записи разрешено отправлять (псевдонимы настраиваются на сервере).",
|
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Новый профиль должен использовать адрес, с которого этой учётной записи разрешено отправлять (псевдонимы настраиваются на сервере).",
|
||||||
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Не предлагается при написании письма. Адрес по-прежнему принимает почту, и с него снова можно отправлять, если показать его обратно.",
|
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Не предлагается при написании письма. Адрес по-прежнему принимает почту, и с него снова можно отправлять, если показать его обратно.",
|
||||||
"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.": "Каждый профиль — это адрес отправителя со своим именем, обратным адресом и подписью. Основной профиль подставляется при написании письма; укажите обратный адрес, если ответы должны приходить не на адрес отправителя.",
|
"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.": "Каждый профиль — это адрес отправителя со своим именем, обратным адресом и подписью. Основной профиль подставляется при написании письма; укажите обратный адрес, если ответы должны приходить не на адрес отправителя.",
|
||||||
"This signature is larger than the server's {limit}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.": "Эта подпись больше серверного предела в {limit} байт. ihasmail сохранит полную версию в ваших Файлах, а на сервере оставит короткий текстовый вариант — другие почтовые клиенты увидят именно его.",
|
"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.": "\u042d\u0442\u0430 \u043f\u043e\u0434\u043f\u0438\u0441\u044c \u0431\u043e\u043b\u044c\u0448\u0435 \u0441\u0435\u0440\u0432\u0435\u0440\u043d\u043e\u0433\u043e \u043f\u0440\u0435\u0434\u0435\u043b\u0430 \u0432 {limit} \u0431\u0430\u0439\u0442. {app} \u0441\u043e\u0445\u0440\u0430\u043d\u0438\u0442 \u043f\u043e\u043b\u043d\u0443\u044e \u0432\u0435\u0440\u0441\u0438\u044e \u0432 \u0432\u0430\u0448\u0438\u0445 \u0424\u0430\u0439\u043b\u0430\u0445, \u0430 \u043d\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u0435 \u043e\u0441\u0442\u0430\u0432\u0438\u0442 \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 \u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439 \u0432\u0430\u0440\u0438\u0430\u043d\u0442 \u2014 \u0434\u0440\u0443\u0433\u0438\u0435 \u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0435 \u043a\u043b\u0438\u0435\u043d\u0442\u044b \u0443\u0432\u0438\u0434\u044f\u0442 \u0438\u043c\u0435\u043d\u043d\u043e \u0435\u0433\u043e.",
|
||||||
"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.": "Категории в стиле Outlook, которые можно присваивать событиям через контекстное меню или редактор события. Название категории хранится в самом событии и синхронизируется с другими клиентами.",
|
"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.": "Категории в стиле Outlook, которые можно присваивать событиям через контекстное меню или редактор события. Название категории хранится в самом событии и синхронизируется с другими клиентами.",
|
||||||
"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.": "Письма в обычном тексте уже следуют теме. С этой настройкой ей следуют и HTML-письма без собственных цветов, а не показываются на белом фоне. Письма с собственным оформлением остаются ровно такими, какими их задумал отправитель.",
|
"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.": "Письма в обычном тексте уже следуют теме. С этой настройкой ей следуют и HTML-письма без собственных цветов, а не показываются на белом фоне. Письма с собственным оформлением остаются ровно такими, какими их задумал отправитель.",
|
||||||
"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.": "Это не то же самое, что {setting} в разделе «Общие», где задаётся, как пишутся даты, время и числа. Можно читать английский интерфейс с русскими датами — или наоборот.",
|
"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.": "Это не то же самое, что {setting} в разделе «Общие», где задаётся, как пишутся даты, время и числа. Можно читать английский интерфейс с русскими датами — или наоборот.",
|
||||||
@@ -1136,40 +1138,40 @@ export const catalog: Catalog = {
|
|||||||
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "У этого экрана нет сенсорного ввода, поэтому здесь ничего не изменится. Настройку подхватят телефон или планшет.",
|
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "У этого экрана нет сенсорного ввода, поэтому здесь ничего не изменится. Настройку подхватят телефон или планшет.",
|
||||||
"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.": "Долгое нажатие на письме выделяет его, а на папке — открывает её меню. Потяните список писем вниз, чтобы проверить почту.",
|
"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.": "Долгое нажатие на письме выделяет его, а на папке — открывает её меню. Потяните список писем вниз, чтобы проверить почту.",
|
||||||
"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.": "Уведомление сообщает запросившему, что адрес действующий и когда письмо было прочитано, а отправитель сам выбирает, куда его отправить, — поэтому автоматического варианта нет. Для массовых рассылок, списков рассылки и всего помеченного как отправленное автоматически оно не предлагается вовсе.",
|
"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.": "Уведомление сообщает запросившему, что адрес действующий и когда письмо было прочитано, а отправитель сам выбирает, куда его отправить, — поэтому автоматического варианта нет. Для массовых рассылок, списков рассылки и всего помеченного как отправленное автоматически оно не предлагается вовсе.",
|
||||||
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.": "Этот браузер не умеет регистрировать программы для ссылок {scheme}. В частности, в Safari нет такого интерфейса — но ihasmail всё равно можно сделать программой по умолчанию средствами операционной системы, установив его как приложение.",
|
"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.": "\u042d\u0442\u043e\u0442 \u0431\u0440\u0430\u0443\u0437\u0435\u0440 \u043d\u0435 \u0443\u043c\u0435\u0435\u0442 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u0434\u043b\u044f \u0441\u0441\u044b\u043b\u043e\u043a {scheme}. \u0412 \u0447\u0430\u0441\u0442\u043d\u043e\u0441\u0442\u0438, \u0432 Safari \u043d\u0435\u0442 \u0442\u0430\u043a\u043e\u0433\u043e \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u2014 \u043d\u043e {app} \u0432\u0441\u0451 \u0440\u0430\u0432\u043d\u043e \u043c\u043e\u0436\u043d\u043e \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u043e\u0439 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043c\u0438 \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u044b, \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0432 \u0435\u0433\u043e \u043a\u0430\u043a \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435.",
|
||||||
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Для регистрации ссылок {scheme} нужно защищённое соединение (HTTPS).",
|
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Для регистрации ссылок {scheme} нужно защищённое соединение (HTTPS).",
|
||||||
"Open {scheme} links — in web pages, documents and other apps — in ihasmail 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 › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).": "Открывать ссылки {scheme} — на веб-страницах, в документах и других программах — в ihasmail, а не в почтовой программе на компьютере. Браузер попросит подтверждение, и позже это можно изменить в его настройках (Chrome: Настройки › Конфиденциальность и безопасность › Настройки сайтов › Обработчики протоколов; Firefox: Настройки › Основные › Приложения).",
|
"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).": "\u041e\u0442\u043a\u0440\u044b\u0432\u0430\u0442\u044c \u0441\u0441\u044b\u043b\u043a\u0438 {scheme} \u2014 \u043d\u0430 \u0432\u0435\u0431-\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0430\u0445, \u0432 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0445 \u0438 \u0434\u0440\u0443\u0433\u0438\u0445 \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u0430\u0445 \u2014 \u0432 {app}, \u0430 \u043d\u0435 \u0432 \u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0439 \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u0435 \u043d\u0430 \u043a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u0435. \u0411\u0440\u0430\u0443\u0437\u0435\u0440 \u043f\u043e\u043f\u0440\u043e\u0441\u0438\u0442 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u0435, \u0438 \u043f\u043e\u0437\u0436\u0435 \u044d\u0442\u043e \u043c\u043e\u0436\u043d\u043e \u0438\u0437\u043c\u0435\u043d\u0438\u0442\u044c \u0432 \u0435\u0433\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 (Chrome: \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u203a \u041a\u043e\u043d\u0444\u0438\u0434\u0435\u043d\u0446\u0438\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u044c \u0438 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u044c \u203a \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0441\u0430\u0439\u0442\u043e\u0432 \u203a \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438 \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u043e\u0432; Firefox: \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u203a \u041e\u0441\u043d\u043e\u0432\u043d\u044b\u0435 \u203a \u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f).",
|
||||||
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Запрошено в этом браузере. Сработало ли это, решает он сам — проверьте его настройки, если почтовые ссылки по-прежнему открываются в другом месте.",
|
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Запрошено в этом браузере. Сработало ли это, решает он сам — проверьте его настройки, если почтовые ссылки по-прежнему открываются в другом месте.",
|
||||||
"For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.": "Чтобы задать программу по умолчанию для всей системы, сначала установите ihasmail как приложение (в Chrome — значок установки в адресной строке). После этого операционная система сможет предлагать ihasmail везде, где спрашивает, какой почтовой программой воспользоваться.",
|
"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.": "Чтобы задать программу по умолчанию для всей системы, сначала установите {app} как приложение (в Chrome — значок установки в адресной строке). После этого операционная система сможет предлагать {app} везде, где спрашивает, какой почтовой программой воспользоваться.",
|
||||||
"Needs a browser with the Push API and a mail server that publishes a push key.": "Нужен браузер с Push API и почтовый сервер, публикующий push-ключ.",
|
"Needs a browser with the Push API and a mail server that publishes a push key.": "Нужен браузер с Push API и почтовый сервер, публикующий push-ключ.",
|
||||||
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Почтовый сервер доставляет их прямо в браузер, поэтому они приходят без открытой вкладки ihasmail и содержат отправителя и тему. Браузер при этом должен быть запущен: если закрыть его полностью, уведомления подождут и придут при следующем запуске.",
|
"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.": "\u041f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0441\u0435\u0440\u0432\u0435\u0440 \u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0442 \u0438\u0445 \u043f\u0440\u044f\u043c\u043e \u0432 \u0431\u0440\u0430\u0443\u0437\u0435\u0440, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u043e\u043d\u0438 \u043f\u0440\u0438\u0445\u043e\u0434\u044f\u0442 \u0431\u0435\u0437 \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u0439 \u0432\u043a\u043b\u0430\u0434\u043a\u0438 {app} \u0438 \u0441\u043e\u0434\u0435\u0440\u0436\u0430\u0442 \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u0435\u043b\u044f \u0438 \u0442\u0435\u043c\u0443. \u0411\u0440\u0430\u0443\u0437\u0435\u0440 \u043f\u0440\u0438 \u044d\u0442\u043e\u043c \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0437\u0430\u043f\u0443\u0449\u0435\u043d: \u0435\u0441\u043b\u0438 \u0437\u0430\u043a\u0440\u044b\u0442\u044c \u0435\u0433\u043e \u043f\u043e\u043b\u043d\u043e\u0441\u0442\u044c\u044e, \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f \u043f\u043e\u0434\u043e\u0436\u0434\u0443\u0442 \u0438 \u043f\u0440\u0438\u0434\u0443\u0442 \u043f\u0440\u0438 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0435\u043c \u0437\u0430\u043f\u0443\u0441\u043a\u0435.",
|
||||||
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Почтовый сервер может разбудить этот браузер, но не сообщит отправителя и тему. Браузер при этом должен быть запущен.",
|
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Почтовый сервер может разбудить этот браузер, но не сообщит отправителя и тему. Браузер при этом должен быть запущен.",
|
||||||
"This is what a new-mail notification looks like.": "Так выглядит уведомление о новом письме.",
|
"This is what a new-mail notification looks like.": "Так выглядит уведомление о новом письме.",
|
||||||
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Вы вошли как {user}. Пароль никогда не хранится в браузере: сервер держит его в зашифрованном виде на время сеанса, чтобы общаться со Stalwart.",
|
"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.": "Вы вошли как {user}. Пароль никогда не хранится в браузере; сервер хранит его в зашифрованном виде для каждого сеанса, чтобы обращаться к почтовому серверу.",
|
||||||
"App passwords are managed by your mail administrator.": "Паролями приложений управляет ваш почтовый администратор.",
|
"App passwords are managed by your mail administrator.": "Паролями приложений управляет ваш почтовый администратор.",
|
||||||
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Смена пароля завершает остальные сеансы веб-почты. Пароли приложений продолжают работать.",
|
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Смена пароля завершает остальные сеансы веб-почты. Пароли приложений продолжают работать.",
|
||||||
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Для этой учётной записи включена двухфакторная аутентификация. ihasmail пока не умеет входить по коду, поэтому для входа на другом устройстве нужен пароль приложения — либо двухфакторную аутентификацию можно отключить здесь.",
|
"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.": "\u0414\u043b\u044f \u044d\u0442\u043e\u0439 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f. {app} \u043f\u043e\u043a\u0430 \u043d\u0435 \u0443\u043c\u0435\u0435\u0442 \u0432\u0445\u043e\u0434\u0438\u0442\u044c \u043f\u043e \u043a\u043e\u0434\u0443, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0434\u043b\u044f \u0432\u0445\u043e\u0434\u0430 \u043d\u0430 \u0434\u0440\u0443\u0433\u043e\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435 \u043d\u0443\u0436\u0435\u043d \u043f\u0430\u0440\u043e\u043b\u044c \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u2014 \u043b\u0438\u0431\u043e \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0443\u044e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e \u043c\u043e\u0436\u043d\u043e \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0437\u0434\u0435\u0441\u044c.",
|
||||||
"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.": "Отдельный пароль для почтовой программы или устройства, который можно отозвать по отдельности. Пароли приложений обходят двухфакторные коды и поэтому работают там, где запросить код невозможно.",
|
"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.": "Отдельный пароль для почтовой программы или устройства, который можно отозвать по отдельности. Пароли приложений обходят двухфакторные коды и поэтому работают там, где запросить код невозможно.",
|
||||||
"Copy it into {name} now — it isn't shown again.": "Скопируйте его в {name} сейчас — больше он не показывается.",
|
"Copy it into {name} now — it isn't shown again.": "Скопируйте его в {name} сейчас — больше он не показывается.",
|
||||||
"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.": "В каталоге не найдено других пользователей, поэтому добавить некого. Уже открытый доступ перечислен ниже, и его по-прежнему можно закрыть.",
|
"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.": "В каталоге не найдено других пользователей, поэтому добавить некого. Уже открытый доступ перечислен ниже, и его по-прежнему можно закрыть.",
|
||||||
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart не сообщает почтовым клиентам номер версии, поэтому ihasmail показывает редакцию, если сервер её называет. ihasmail требует версию 0.16 или новее, и вход с более старой не выполняется.",
|
"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 \u043d\u0435 \u0441\u043e\u043e\u0431\u0449\u0430\u0435\u0442 \u043f\u043e\u0447\u0442\u043e\u0432\u044b\u043c \u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043c \u043d\u043e\u043c\u0435\u0440 \u0432\u0435\u0440\u0441\u0438\u0438, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 {app} \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442 \u0440\u0435\u0434\u0430\u043a\u0446\u0438\u044e, \u0435\u0441\u043b\u0438 \u0441\u0435\u0440\u0432\u0435\u0440 \u0435\u0451 \u043d\u0430\u0437\u044b\u0432\u0430\u0435\u0442. {app} \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0432\u0435\u0440\u0441\u0438\u044e 0.16 \u0438\u043b\u0438 \u043d\u043e\u0432\u0435\u0435, \u0438 \u0432\u0445\u043e\u0434 \u0441 \u0431\u043e\u043b\u0435\u0435 \u0441\u0442\u0430\u0440\u043e\u0439 \u043d\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.",
|
||||||
"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.": "Он {damage}, поэтому правила в нём нельзя показать или изменить: сохранение полученной части затёрло бы остальное. Перезагрузите страницу и попробуйте снова. Ваши правила остаются на сервере, здесь их ничто не меняло.",
|
"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.": "Он {damage}, поэтому правила в нём нельзя показать или изменить: сохранение полученной части затёрло бы остальное. Перезагрузите страницу и попробуйте снова. Ваши правила остаются на сервере, здесь их ничто не меняло.",
|
||||||
"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).": "Визуальный редактор правил работает только со скриптами, которые создал сам. Скрипт можно изменить на вкладке {tab} или начать заново с правил (существующий скрипт сохранится, но будет отключён).",
|
"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).": "Визуальный редактор правил работает только со скриптами, которые создал сам. Скрипт можно изменить на вкладке {tab} или начать заново с правил (существующий скрипт сохранится, но будет отключён).",
|
||||||
"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.": "Ваш скрипт фильтрации {damage}, поэтому получена только его часть. Добавление правила затёрло бы этой частью весь скрипт. Перезагрузите страницу и попробуйте снова.",
|
"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.": "Ваш скрипт фильтрации {damage}, поэтому получена только его часть. Добавление правила затёрло бы этой частью весь скрипт. Перезагрузите страницу и попробуйте снова.",
|
||||||
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Скрипт фильтрации сейчас не удалось прочитать, поэтому добавление правила рискует его перезаписать. Перезагрузите страницу и попробуйте снова.",
|
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Скрипт фильтрации сейчас не удалось прочитать, поэтому добавление правила рискует его перезаписать. Перезагрузите страницу и попробуйте снова.",
|
||||||
"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.": "Ваш активный скрипт Sieve написан вручную, поэтому правила нельзя добавить автоматически. Откройте {where}, чтобы изменить скрипт или перейти к управляемым правилам.",
|
"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.": "Ваш активный скрипт Sieve написан вручную, поэтому правила нельзя добавить автоматически. Откройте {where}, чтобы изменить скрипт или перейти к управляемым правилам.",
|
||||||
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Здесь показаны только языки, на которые ihasmail переведён, поэтому список растёт вместе с переводами, а не опережает их: язык без текстов заставил бы страницу утверждать, что она написана на языке, которым не является.",
|
"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.": "\u0417\u0434\u0435\u0441\u044c \u043f\u043e\u043a\u0430\u0437\u0430\u043d\u044b \u0442\u043e\u043b\u044c\u043a\u043e \u044f\u0437\u044b\u043a\u0438, \u043d\u0430 \u043a\u043e\u0442\u043e\u0440\u044b\u0435 {app} \u043f\u0435\u0440\u0435\u0432\u0435\u0434\u0451\u043d, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0441\u043f\u0438\u0441\u043e\u043a \u0440\u0430\u0441\u0442\u0451\u0442 \u0432\u043c\u0435\u0441\u0442\u0435 \u0441 \u043f\u0435\u0440\u0435\u0432\u043e\u0434\u0430\u043c\u0438, \u0430 \u043d\u0435 \u043e\u043f\u0435\u0440\u0435\u0436\u0430\u0435\u0442 \u0438\u0445: \u044f\u0437\u044b\u043a \u0431\u0435\u0437 \u0442\u0435\u043a\u0441\u0442\u043e\u0432 \u0437\u0430\u0441\u0442\u0430\u0432\u0438\u043b \u0431\u044b \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0443 \u0443\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u0442\u044c, \u0447\u0442\u043e \u043e\u043d\u0430 \u043d\u0430\u043f\u0438\u0441\u0430\u043d\u0430 \u043d\u0430 \u044f\u0437\u044b\u043a\u0435, \u043a\u043e\u0442\u043e\u0440\u044b\u043c \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f.",
|
||||||
"tell us about it": "сообщите нам",
|
"tell us about it": "сообщите нам",
|
||||||
"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}.": "Этот перевод сделан ИИ и не проверен носителем языка, поэтому помечен как Beta до тех пор, пока кто-нибудь его не подтвердит. Обо всём, что звучит неправильно, стоит сообщить — {report}.",
|
"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}.": "Этот перевод сделан ИИ и не проверен носителем языка, поэтому помечен как Beta до тех пор, пока кто-нибудь его не подтвердит. Обо всём, что звучит неправильно, стоит сообщить — {report}.",
|
||||||
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "Собственная версия ihasmail — это дата коммита, из которого он собран, и указание, откуда этот коммит взялся: {example} собран из коммита от 30 августа 2026 года, пришедшего через pull request 129. Коммит, пришедший иначе, несёт вместо этого короткий SHA — {sha}. Версия намеренно ничего не сообщает о Stalwart; то, что этой сборке нужно от сервера, указано строкой выше.",
|
"{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.": "\u0421\u043e\u0431\u0441\u0442\u0432\u0435\u043d\u043d\u0430\u044f \u0432\u0435\u0440\u0441\u0438\u044f {app} \u2014 \u044d\u0442\u043e \u0434\u0430\u0442\u0430 \u043a\u043e\u043c\u043c\u0438\u0442\u0430, \u0438\u0437 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u043e\u043d \u0441\u043e\u0431\u0440\u0430\u043d, \u0438 \u0443\u043a\u0430\u0437\u0430\u043d\u0438\u0435, \u043e\u0442\u043a\u0443\u0434\u0430 \u044d\u0442\u043e\u0442 \u043a\u043e\u043c\u043c\u0438\u0442 \u0432\u0437\u044f\u043b\u0441\u044f: {example} \u0441\u043e\u0431\u0440\u0430\u043d \u0438\u0437 \u043a\u043e\u043c\u043c\u0438\u0442\u0430 \u043e\u0442 30 \u0430\u0432\u0433\u0443\u0441\u0442\u0430 2026 \u0433\u043e\u0434\u0430, \u043f\u0440\u0438\u0448\u0435\u0434\u0448\u0435\u0433\u043e \u0447\u0435\u0440\u0435\u0437 pull request 129. \u041a\u043e\u043c\u043c\u0438\u0442, \u043f\u0440\u0438\u0448\u0435\u0434\u0448\u0438\u0439 \u0438\u043d\u0430\u0447\u0435, \u043d\u0435\u0441\u0451\u0442 \u0432\u043c\u0435\u0441\u0442\u043e \u044d\u0442\u043e\u0433\u043e \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 SHA \u2014 {sha}. \u0412\u0435\u0440\u0441\u0438\u044f \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u043d\u043e \u043d\u0438\u0447\u0435\u0433\u043e \u043d\u0435 \u0441\u043e\u043e\u0431\u0449\u0430\u0435\u0442 \u043e Stalwart; \u0442\u043e, \u0447\u0442\u043e \u044d\u0442\u043e\u0439 \u0441\u0431\u043e\u0440\u043a\u0435 \u043d\u0443\u0436\u043d\u043e \u043e\u0442 \u0441\u0435\u0440\u0432\u0435\u0440\u0430, \u0443\u043a\u0430\u0437\u0430\u043d\u043e \u0441\u0442\u0440\u043e\u043a\u043e\u0439 \u0432\u044b\u0448\u0435.",
|
||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Новое письмо",
|
"New message": "Новое письмо",
|
||||||
"Start a new message with what was shared?": "Начать новое письмо с полученным содержимым?",
|
"Start a new message with what was shared?": "Начать новое письмо с полученным содержимым?",
|
||||||
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "В ihasmail что-то передали через «Поделиться». Ничего не отправится, пока вы не нажмёте «Отправить». Если вы только что ничего не передавали, нажмите «Не сохранять».",
|
"Something was shared with {app}. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "В {app} что-то передали через «Поделиться». Ничего не отправится, пока вы не нажмёте «Отправить». Если вы только что ничего не передавали, нажмите «Не сохранять».",
|
||||||
"Start a message": "Начать письмо",
|
"Start a message": "Начать письмо",
|
||||||
"New mail": "Новое письмо",
|
"New mail": "Новое письмо",
|
||||||
"Could not do that — open ihasmail and try again": "Не удалось — откройте ihasmail и повторите попытку",
|
"Could not do that \u2014 open {app} and try again": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u2014 \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 {app} \u0438 \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443",
|
||||||
"Sending…": "Отправка…",
|
"Sending…": "Отправка…",
|
||||||
"Saving…": "Сохранение…",
|
"Saving…": "Сохранение…",
|
||||||
"Error": "Ошибка",
|
"Error": "Ошибка",
|
||||||
@@ -1217,6 +1219,20 @@ export const catalog: Catalog = {
|
|||||||
"Could not save filters: {error}": "Не удалось сохранить фильтры: {error}",
|
"Could not save filters: {error}": "Не удалось сохранить фильтры: {error}",
|
||||||
"Could not send the receipt: {error}": "Не удалось отправить уведомление о прочтении: {error}",
|
"Could not send the receipt: {error}": "Не удалось отправить уведомление о прочтении: {error}",
|
||||||
"Could not sign in.": "Не удалось войти.",
|
"Could not sign in.": "Не удалось войти.",
|
||||||
|
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "Вы вошли как {user}. Эта веб-почта никогда не видит ваш пароль: она хранит токен входа от вашего почтового сервера, зашифрованный для каждого сеанса.",
|
||||||
|
"Mail server": "Почтовый сервер",
|
||||||
|
"You'll enter your password on your mail server's sign-in page.": "Пароль вводится на странице входа вашего почтового сервера.",
|
||||||
|
"You'll sign in on your mail server's own page.": "Вход выполняется на странице вашего почтового сервера.",
|
||||||
|
"This sign-in didn't start in this browser. Try again.": "Этот вход начат не в этом браузере. Попробуйте ещё раз.",
|
||||||
|
"The sign-in took too long. Try again.": "Вход занял слишком много времени. Попробуйте ещё раз.",
|
||||||
|
"Sign-in was cancelled.": "Вход отменён.",
|
||||||
|
"The mail server didn't accept the sign-in. Try again.": "Почтовый сервер не принял вход. Попробуйте ещё раз.",
|
||||||
|
"That account is on a different mail server than the address you entered. Sign in with that address.": "Эта учётная запись находится на другом почтовом сервере, чем введённый адрес. Войдите с этим адресом.",
|
||||||
|
"This mail server isn't supported.": "Этот почтовый сервер не поддерживается.",
|
||||||
|
"Couldn't reach the mail server. Try again in a moment.": "Не удалось связаться с почтовым сервером. Попробуйте ещё раз чуть позже.",
|
||||||
|
"Your password was changed. Sign in with the new one.": "Пароль изменён. Войдите с новым паролем.",
|
||||||
|
"Your sign-in ended. Sign in again.": "Сеанс завершён. Войдите снова.",
|
||||||
|
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Смена пароля завершает все сеансы, включая этот, после чего нужно войти с новым паролем. Пароли приложений продолжают работать.",
|
||||||
"Could not store image: {error}": "Не удалось сохранить изображение: {error}",
|
"Could not store image: {error}": "Не удалось сохранить изображение: {error}",
|
||||||
"Could not update labels: {error}": "Не удалось обновить метки: {error}",
|
"Could not update labels: {error}": "Не удалось обновить метки: {error}",
|
||||||
"Could not update the Scheduled folder: {error}": "Не удалось обновить папку «Отложенные»: {error}",
|
"Could not update the Scheduled folder: {error}": "Не удалось обновить папку «Отложенные»: {error}",
|
||||||
@@ -1339,7 +1355,7 @@ export const catalog: Catalog = {
|
|||||||
"Undo window: {seconds}s": "Время на отмену: {seconds} с",
|
"Undo window: {seconds}s": "Время на отмену: {seconds} с",
|
||||||
"You're all caught up": "Всё прочитано",
|
"You're all caught up": "Всё прочитано",
|
||||||
"Your browser refused the request: {error}": "Браузер отклонил запрос: {error}",
|
"Your browser refused the request: {error}": "Браузер отклонил запрос: {error}",
|
||||||
"Your browser will ask whether to open mail links in ihasmail": "Браузер спросит, открывать ли почтовые ссылки в ihasmail",
|
"Your browser will ask whether to open mail links in {app}": "Браузер спросит, открывать ли почтовые ссылки в {app}",
|
||||||
"Your message mentions an attachment, but nothing is attached.": "В письме упомянуто вложение, но ничего не приложено.",
|
"Your message mentions an attachment, but nothing is attached.": "В письме упомянуто вложение, но ничего не приложено.",
|
||||||
"event": "событие",
|
"event": "событие",
|
||||||
"Hide password": "Скрыть пароль",
|
"Hide password": "Скрыть пароль",
|
||||||
@@ -1410,7 +1426,7 @@ export const catalog: Catalog = {
|
|||||||
"Also count these domains as inside": "Считать внутренними также эти домены",
|
"Also count these domains as inside": "Считать внутренними также эти домены",
|
||||||
"Always": "Всегда",
|
"Always": "Всегда",
|
||||||
"Always showing images from": "Всегда показывать изображения от",
|
"Always showing images from": "Всегда показывать изображения от",
|
||||||
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.": "Изображение, загруженное с сервера отправителя, сообщает ему, что письмо открыли, когда и примерно откуда. Разрешённые изображения загружает сервер ihasmail, а не браузер, поэтому отправитель не узнаёт ничего из этого.",
|
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.": "Изображение, загруженное с сервера отправителя, сообщает ему, что письмо открыли, когда и примерно откуда. Разрешённые изображения загружает сервер {app}, а не браузер, поэтому отправитель не узнаёт ничего из этого.",
|
||||||
"Applies to": "Применяется к",
|
"Applies to": "Применяется к",
|
||||||
"Archive and next": "Архивировать и далее",
|
"Archive and next": "Архивировать и далее",
|
||||||
"Archive by month": "Архивировать по месяцам",
|
"Archive by month": "Архивировать по месяцам",
|
||||||
@@ -1652,8 +1668,8 @@ export const catalog: Catalog = {
|
|||||||
"Fingerprint": "Отпечаток",
|
"Fingerprint": "Отпечаток",
|
||||||
"Hide details": "Скрыть подробности",
|
"Hide details": "Скрыть подробности",
|
||||||
"Issued by": "Кем выдан",
|
"Issued by": "Кем выдан",
|
||||||
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Письмо подписано OpenPGP, а ihasmail не может получить открытый ключ отправителя.",
|
"It is signed with OpenPGP, and {app} has no way to fetch the sender's public key.": "Письмо подписано OpenPGP, а {app} не может получить открытый ключ отправителя.",
|
||||||
"It uses a signature algorithm ihasmail cannot check yet.": "Использован алгоритм подписи, который ihasmail пока не умеет проверять.",
|
"It uses a signature algorithm {app} cannot check yet.": "Использован алгоритм подписи, который {app} пока не умеет проверять.",
|
||||||
"It was made with a certificate belonging to {name}, which does not cover this address.": "Подпись сделана сертификатом, принадлежащим {name}, который не покрывает этот адрес.",
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Подпись сделана сертификатом, принадлежащим {name}, который не покрывает этот адрес.",
|
||||||
"Previous fingerprint": "Прежний отпечаток",
|
"Previous fingerprint": "Прежний отпечаток",
|
||||||
"Signed at": "Подписано",
|
"Signed at": "Подписано",
|
||||||
@@ -1670,18 +1686,39 @@ export const catalog: Catalog = {
|
|||||||
"The signature is not for this sender.": "Подпись не принадлежит этому отправителю.",
|
"The signature is not for this sender.": "Подпись не принадлежит этому отправителю.",
|
||||||
"The signed part is missing either the message or the signature.": "В подписанной части не хватает либо письма, либо подписи.",
|
"The signed part is missing either the message or the signature.": "В подписанной части не хватает либо письма, либо подписи.",
|
||||||
"The signer has changed.": "Подписавший изменился.",
|
"The signer has changed.": "Подписавший изменился.",
|
||||||
"This message is signed, and ihasmail could not check the signature.": "Это письмо подписано, и ihasmail не смог проверить подпись.",
|
"This message is signed, and {app} could not check the signature.": "Это письмо подписано, и {app} не смог проверить подпись.",
|
||||||
"This signature does not check out.": "Эта подпись не сходится.",
|
"This signature does not check out.": "Эта подпись не сходится.",
|
||||||
"Valid until": "Действует до",
|
"Valid until": "Действует до",
|
||||||
"a different certificate": "другим сертификатом",
|
"a different certificate": "другим сертификатом",
|
||||||
"an unnamed signer": "неназванным подписавшим",
|
"an unnamed signer": "неназванным подписавшим",
|
||||||
"as claimed by the signer": "по словам подписавшего",
|
"as claimed by the signer": "по словам подписавшего",
|
||||||
"first seen {date}": "впервые замечен {date}",
|
"first seen {date}": "впервые замечен {date}",
|
||||||
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail сообщит, если следующее письмо с этого адреса подпишет кто-то другой.",
|
"{app} will tell you if a later message from this address is signed by anybody else.": "{app} сообщит, если следующее письмо с этого адреса подпишет кто-то другой.",
|
||||||
"itself, or an issuer it does not name": "самим собой или неназванным издателем",
|
"itself, or an issuer it does not name": "самим собой или неназванным издателем",
|
||||||
"no address": "нет адреса",
|
"no address": "нет адреса",
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"Legacy mail apps": "Почтовые приложения на устаревших протоколах",
|
||||||
|
"Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.": "Отключены для {tenant}. Входить на домены этой организации могут только {app} и приложения JMAP.",
|
||||||
|
"On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.": "Включены для {tenant}. Почтовые приложения могут использовать IMAP, POP3 и ManageSieve на доменах этой организации.",
|
||||||
|
"Turn legacy protocols back on": "Снова включить устаревшие почтовые протоколы",
|
||||||
|
"Turn off legacy protocols…": "Отключить устаревшие почтовые протоколы…",
|
||||||
|
"No account used a legacy mail app in the last 30 days.": "За последние 30 дней ни одна учётная запись не использовала почтовое приложение на устаревших протоколах.",
|
||||||
|
"Their mail apps will stop working the moment you turn this on:": "Их почтовые приложения перестанут работать сразу после того, как вы это включите:",
|
||||||
|
"Only {app} and JMAP apps will work.": "Работать будут только {app} и приложения JMAP.",
|
||||||
|
"Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {tenant}.": "Устаревшие почтовые протоколы (IMAP, POP3, ManageSieve и отправка из почтовых приложений) будут отключены для всех в {tenant}.",
|
||||||
|
"Phone and desktop mail apps will stop receiving and sending mail. That's iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.": "Почтовые приложения на телефонах и компьютерах перестанут получать и отправлять почту. Это Почта на iPhone и iPad, приложения Gmail и Outlook, Outlook, Thunderbird и Apple Mail. В них появятся ошибки входа.",
|
||||||
|
"Filters managed from a mail app (ManageSieve) will stop working. Filters set in {app} keep working.": "Фильтры, управляемые из почтового приложения (ManageSieve), перестанут работать. Фильтры, настроенные в {app}, продолжат работать.",
|
||||||
|
"Incoming mail is not affected. Calendars and contacts are not affected.": "Входящая почта не затрагивается. Календари и контакты не затрагиваются.",
|
||||||
|
"People keep full access through {app}, which can be installed as an app on phones and computers.": "У всех остаётся полный доступ через {app}, который можно установить как приложение на телефоны и компьютеры.",
|
||||||
|
"Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and {app} (JMAP) are not affected and cannot be turned off here.": "Отправка из почтовых приложений (SMTP submission) перестанет работать, но её порты останутся открытыми: почтовым приложениям будет сообщено, что войти нельзя. Входящая почта (SMTP) и {app} (JMAP) не затрагиваются, и отключить их здесь нельзя.",
|
||||||
|
"You can turn legacy protocols back on at any time.": "Вы можете снова включить устаревшие почтовые протоколы в любой момент.",
|
||||||
|
"To confirm, type {phrase}": "Для подтверждения введите {phrase}",
|
||||||
|
"Turn off legacy protocols": "Отключить устаревшие почтовые протоколы",
|
||||||
|
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Устаревшие почтовые протоколы отключены для вашей организации. Входить могут только {app} и приложения JMAP.",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"{n} accounts used a legacy mail app in the last 30 days.": {one: "{n} учётная запись использовала почтовое приложение на устаревших протоколах за последние 30 дней.", few: "{n} учётные записи использовали почтовое приложение на устаревших протоколах за последние 30 дней.", many: "{n} учётных записей использовали почтовое приложение на устаревших протоколах за последние 30 дней.", other: "{n} учётной записи использовали почтовое приложение на устаревших протоколах за последние 30 дней."},
|
||||||
// ── Administration: domains ────────────────────────────────────
|
// ── Administration: domains ────────────────────────────────────
|
||||||
"{n} accounts use this domain. Move or delete them first.": { one: "Этот домен использует {n} учётная запись. Сначала перенесите или удалите её.", few: "Этот домен используют {n} учётные записи. Сначала перенесите или удалите их.", many: "Этот домен используют {n} учётных записей. Сначала перенесите или удалите их.", other: "Этот домен используют {n} учётной записи. Сначала перенесите или удалите их." },
|
"{n} accounts use this domain. Move or delete them first.": { one: "Этот домен использует {n} учётная запись. Сначала перенесите или удалите её.", few: "Этот домен используют {n} учётные записи. Сначала перенесите или удалите их.", many: "Этот домен используют {n} учётных записей. Сначала перенесите или удалите их.", other: "Этот домен используют {n} учётной записи. Сначала перенесите или удалите их." },
|
||||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Сервер перестанет принимать почту для этого домена, и его {n} ключ DKIM будет удалён. Отменить это нельзя.", few: "Сервер перестанет принимать почту для этого домена, и его {n} ключа DKIM будут удалены. Отменить это нельзя.", many: "Сервер перестанет принимать почту для этого домена, и его {n} ключей DKIM будут удалены. Отменить это нельзя.", other: "Сервер перестанет принимать почту для этого домена, и его {n} ключа DKIM будут удалены. Отменить это нельзя." },
|
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Сервер перестанет принимать почту для этого домена, и его {n} ключ DKIM будет удалён. Отменить это нельзя.", few: "Сервер перестанет принимать почту для этого домена, и его {n} ключа DKIM будут удалены. Отменить это нельзя.", many: "Сервер перестанет принимать почту для этого домена, и его {n} ключей DKIM будут удалены. Отменить это нельзя.", other: "Сервер перестанет принимать почту для этого домена, и его {n} ключа DKIM будут удалены. Отменить это нельзя." },
|
||||||
|
|||||||
+68
-31
@@ -135,8 +135,8 @@ export const catalog: Catalog = {
|
|||||||
"The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.",
|
"The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.",
|
||||||
"Nothing to show": "Нічого показати",
|
"Nothing to show": "Нічого показати",
|
||||||
"Could not be loaded": "Не вдалося завантажити",
|
"Could not be loaded": "Не вдалося завантажити",
|
||||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "Докладні метрики, черга доставки, журнали та налаштування сервера є у власній панелі адміністрування Stalwart.",
|
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "Докладні метрики, черга доставлення, журнали й налаштування сервера є в INBUXA Admin.",
|
||||||
"Open Stalwart admin": "Відкрити адміністрування Stalwart",
|
"Open INBUXA Admin": "Відкрити INBUXA Admin",
|
||||||
"Default group role": "Роль групи за замовчуванням",
|
"Default group role": "Роль групи за замовчуванням",
|
||||||
"A group needs an address.": "Групі потрібна адреса.",
|
"A group needs an address.": "Групі потрібна адреса.",
|
||||||
"New group": "Нова група",
|
"New group": "Нова група",
|
||||||
@@ -205,10 +205,10 @@ export const catalog: Catalog = {
|
|||||||
"New role": "Нова роль",
|
"New role": "Нова роль",
|
||||||
"This role carries permissions yours doesn't, so you can view it but not change it.": "Ця роль має дозволи, яких немає у вашої, тому її можна переглядати, але не змінювати.",
|
"This role carries permissions yours doesn't, so you can view it but not change it.": "Ця роль має дозволи, яких немає у вашої, тому її можна переглядати, але не змінювати.",
|
||||||
"Your role lets you view roles but not change them.": "Ваша роль дозволяє переглядати ролі, але не змінювати їх.",
|
"Your role lets you view roles but not change them.": "Ваша роль дозволяє переглядати ролі, але не змінювати їх.",
|
||||||
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart за замовчуванням надає цю роль {kinds}. Зміна тут стосується всіх, хто отримав її так.",
|
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Поштовий сервер типово надає цю роль: {kinds}. Зміна тут стосується всіх, хто отримав її так.",
|
||||||
"Builds on": "Базується на",
|
"Builds on": "Базується на",
|
||||||
"Permissions": "Дозволи",
|
"Permissions": "Дозволи",
|
||||||
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart надає цю роль за замовчуванням, тому її не можна видалити. Спершу змініть значення за замовчуванням у власній панелі адміністрування Stalwart.",
|
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "Поштовий сервер типово надає цю роль, тому її не можна видалити. Спершу змініть типові значення в INBUXA Admin.",
|
||||||
"This role carries permissions yours doesn't.": "Ця роль має дозволи, яких немає у вашої.",
|
"This role carries permissions yours doesn't.": "Ця роль має дозволи, яких немає у вашої.",
|
||||||
"Create role": "Створити роль",
|
"Create role": "Створити роль",
|
||||||
"builds on this one": "базується на цій",
|
"builds on this one": "базується на цій",
|
||||||
@@ -235,7 +235,7 @@ export const catalog: Catalog = {
|
|||||||
"Delete role": "Видалити роль",
|
"Delete role": "Видалити роль",
|
||||||
"It can't be undone.": "Це не можна скасувати.",
|
"It can't be undone.": "Це не можна скасувати.",
|
||||||
"Type {name} to confirm": "Введіть {name} для підтвердження",
|
"Type {name} to confirm": "Введіть {name} для підтвердження",
|
||||||
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Не вдалося завантажити список дозволів Stalwart, тому змінити дозволи тут не можна. ({reason})",
|
"The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "Не вдалося завантажити список дозволів поштового сервера, тому тут не можна змінити дозволи. ({reason})",
|
||||||
"Named sets of permissions, given to accounts, groups and tenants.": "Іменовані набори дозволів для облікових записів, груп та орендарів.",
|
"Named sets of permissions, given to accounts, groups and tenants.": "Іменовані набори дозволів для облікових записів, груп та орендарів.",
|
||||||
"Search roles": "Пошук ролей",
|
"Search roles": "Пошук ролей",
|
||||||
"No roles match": "Немає відповідних ролей",
|
"No roles match": "Немає відповідних ролей",
|
||||||
@@ -258,11 +258,11 @@ export const catalog: Catalog = {
|
|||||||
"{used} used": "Зайнято {used}",
|
"{used} used": "Зайнято {used}",
|
||||||
"Your role lets you view tenants but not change them.": "Ваша роль дозволяє переглядати орендарів, але не змінювати їх.",
|
"Your role lets you view tenants but not change them.": "Ваша роль дозволяє переглядати орендарів, але не змінювати їх.",
|
||||||
"Logo": "Логотип",
|
"Logo": "Логотип",
|
||||||
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "Адреса https або data-URL зображення. Stalwart показує його людям орендаря там, де показує логотип.",
|
"An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "Адреса https або data-URL зображення. Поштовий сервер показує його людям орендаря там, де показує логотип.",
|
||||||
"What it holds": "Вміст",
|
"What it holds": "Вміст",
|
||||||
"{n} of {limit}": "{n} з {limit}",
|
"{n} of {limit}": "{n} з {limit}",
|
||||||
"Limits": "Ліміти",
|
"Limits": "Ліміти",
|
||||||
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart не дає створити більше, ніж дозволяє ліміт. Порожнє поле — без ліміту.",
|
"The mail server refuses to create more than a limit allows. An empty field is no limit.": "Поштовий сервер не дозволяє створити більше, ніж дозволяє ліміт. Порожнє поле означає відсутність ліміту.",
|
||||||
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Максимум того, що може бути дозволено будь-кому в цьому орендарі: їхні власні ролі обмежуються тим, що надають ці. Пропонуються лише ролі, дозволи яких маєте ви самі.",
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "Максимум того, що може бути дозволено будь-кому в цьому орендарі: їхні власні ролі обмежуються тим, що надають ці. Пропонуються лише ролі, дозволи яких маєте ви самі.",
|
||||||
"Checking what is still in this tenant…": "Перевірка того, що ще є в цього орендаря…",
|
"Checking what is still in this tenant…": "Перевірка того, що ще є в цього орендаря…",
|
||||||
"It still holds accounts, domains or other things. Move them out first.": "У нього ще є облікові записи, домени чи щось інше. Спершу перенесіть їх.",
|
"It still holds accounts, domains or other things. Move them out first.": "У нього ще є облікові записи, домени чи щось інше. Спершу перенесіть їх.",
|
||||||
@@ -278,7 +278,6 @@ export const catalog: Catalog = {
|
|||||||
"Still holds {things}. Move them out first.": "Ще містить: {things}. Спершу перенесіть їх.",
|
"Still holds {things}. Move them out first.": "Ще містить: {things}. Спершу перенесіть їх.",
|
||||||
"Delete tenant": "Видалити орендаря",
|
"Delete tenant": "Видалити орендаря",
|
||||||
"Separate organizations on one server, each with its own people, domains and limits.": "Окремі організації на одному сервері, кожна зі своїми людьми, доменами й лімітами.",
|
"Separate organizations on one server, each with its own people, domains and limits.": "Окремі організації на одному сервері, кожна зі своїми людьми, доменами й лімітами.",
|
||||||
"Tenants are a Stalwart Enterprise feature.": "Орендарі — функція Stalwart Enterprise.",
|
|
||||||
"Search tenants": "Пошук орендарів",
|
"Search tenants": "Пошук орендарів",
|
||||||
"No tenants match": "Немає відповідних орендарів",
|
"No tenants match": "Немає відповідних орендарів",
|
||||||
"No tenants yet": "Орендарів поки немає",
|
"No tenants yet": "Орендарів поки немає",
|
||||||
@@ -506,7 +505,7 @@ export const catalog: Catalog = {
|
|||||||
"Waiting on the server — goes out {when}.": "Очікує на сервері — буде надіслано {when}.",
|
"Waiting on the server — goes out {when}.": "Очікує на сервері — буде надіслано {when}.",
|
||||||
"Scheduled — click to clear the schedule": "Заплановано — натисніть, щоб скасувати",
|
"Scheduled — click to clear the schedule": "Заплановано — натисніть, щоб скасувати",
|
||||||
"Nothing scheduled": "Нічого не заплановано",
|
"Nothing scheduled": "Нічого не заплановано",
|
||||||
"The message waits on the server, so it goes out whether or not ihasmail is open.": "Лист чекає на сервері й буде надісланий незалежно від того, чи відкрито ihasmail.",
|
"The message waits on the server, so it goes out whether or not {app} is open.": "Лист чекає на сервері й буде надісланий незалежно від того, чи відкрито {app}.",
|
||||||
"This server holds a message for up to {span}.": "Цей сервер утримує лист до {span}.",
|
"This server holds a message for up to {span}.": "Цей сервер утримує лист до {span}.",
|
||||||
"Date and time to send": "Дата й час надсилання",
|
"Date and time to send": "Дата й час надсилання",
|
||||||
"Undo send window": "Час на скасування надсилання",
|
"Undo send window": "Час на скасування надсилання",
|
||||||
@@ -729,7 +728,7 @@ export const catalog: Catalog = {
|
|||||||
"Sections": "Розділи",
|
"Sections": "Розділи",
|
||||||
"General": "Загальні",
|
"General": "Загальні",
|
||||||
"Appearance": "Вигляд",
|
"Appearance": "Вигляд",
|
||||||
"Make ihasmail yours.": "Налаштуйте ihasmail під себе.",
|
"Make {app} yours.": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0439\u0442\u0435 {app} \u043f\u0456\u0434 \u0441\u0435\u0431\u0435.",
|
||||||
"Reading": "Читання",
|
"Reading": "Читання",
|
||||||
"Reading pane": "Область читання",
|
"Reading pane": "Область читання",
|
||||||
"Right of the list": "Праворуч від списку",
|
"Right of the list": "Праворуч від списку",
|
||||||
@@ -746,6 +745,7 @@ export const catalog: Catalog = {
|
|||||||
"Attachment reminder": "Нагадування про вкладення",
|
"Attachment reminder": "Нагадування про вкладення",
|
||||||
"Warn when the message mentions an attachment but none is attached.": "Попереджати, якщо лист згадує вкладення, але його немає.",
|
"Warn when the message mentions an attachment but none is attached.": "Попереджати, якщо лист згадує вкладення, але його немає.",
|
||||||
"Spell check while typing": "Перевіряти орфографію під час введення",
|
"Spell check while typing": "Перевіряти орфографію під час введення",
|
||||||
|
"Open the composer full screen": "Писати листи на весь екран",
|
||||||
"Confirm before deleting": "Питати перед видаленням",
|
"Confirm before deleting": "Питати перед видаленням",
|
||||||
"Show message snippets": "Показувати початок листа",
|
"Show message snippets": "Показувати початок листа",
|
||||||
"Preview the first line of each message in the list.": "Показувати перший рядок кожного листа у списку.",
|
"Preview the first line of each message in the list.": "Показувати перший рядок кожного листа у списку.",
|
||||||
@@ -825,7 +825,8 @@ export const catalog: Catalog = {
|
|||||||
"Reset to defaults": "Скинути до значень за замовчуванням",
|
"Reset to defaults": "Скинути до значень за замовчуванням",
|
||||||
"Default mail app": "Поштова програма за замовчуванням",
|
"Default mail app": "Поштова програма за замовчуванням",
|
||||||
"Documentation": "Документація",
|
"Documentation": "Документація",
|
||||||
"About ihasmail": "Про ihasmail",
|
"About {app}": "Про {app}",
|
||||||
|
"Built on {project}": "Створено на основі {project}",
|
||||||
"About": "Про програму",
|
"About": "Про програму",
|
||||||
"Server": "Сервер",
|
"Server": "Сервер",
|
||||||
"Server capabilities": "Можливості сервера",
|
"Server capabilities": "Можливості сервера",
|
||||||
@@ -926,6 +927,7 @@ export const catalog: Catalog = {
|
|||||||
"Turn off two-factor authentication": "Вимкнути двофакторну автентифікацію",
|
"Turn off two-factor authentication": "Вимкнути двофакторну автентифікацію",
|
||||||
"Your password alone will be enough to sign in again.": "Для входу знову буде достатньо самого пароля.",
|
"Your password alone will be enough to sign in again.": "Для входу знову буде достатньо самого пароля.",
|
||||||
"App passwords": "Паролі програм",
|
"App passwords": "Паролі програм",
|
||||||
|
"Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "Ваша організація дозволяє лише {app} і програми JMAP, тому поштові програми на телефоні й комп'ютері не можуть підключитися до цього облікового запису.",
|
||||||
"New app password for": "Новий пароль програми для",
|
"New app password for": "Новий пароль програми для",
|
||||||
"Your new app password": "Ваш новий пароль програми",
|
"Your new app password": "Ваш новий пароль програми",
|
||||||
"Secret": "Секрет",
|
"Secret": "Секрет",
|
||||||
@@ -953,8 +955,8 @@ export const catalog: Catalog = {
|
|||||||
"Notifications": "Сповіщення",
|
"Notifications": "Сповіщення",
|
||||||
"Notifications are blocked in your browser settings.": "Сповіщення заблоковано в налаштуваннях браузера.",
|
"Notifications are blocked in your browser settings.": "Сповіщення заблоковано в налаштуваннях браузера.",
|
||||||
"Not supported in this browser.": "Не підтримується в цьому браузері.",
|
"Not supported in this browser.": "Не підтримується в цьому браузері.",
|
||||||
"Desktop notifications while ihasmail is open": "Системні сповіщення, поки ihasmail відкрито",
|
"Desktop notifications while {app} is open": "Системні сповіщення, поки {app} відкрито",
|
||||||
"Notify me even when ihasmail is closed": "Сповіщати, навіть коли ihasmail закрито",
|
"Notify me even when {app} is closed": "Сповіщати, навіть коли {app} закрито",
|
||||||
"Play a sound for new mail": "Звук при новому листі",
|
"Play a sound for new mail": "Звук при новому листі",
|
||||||
"Test notification": "Перевірити сповіщення",
|
"Test notification": "Перевірити сповіщення",
|
||||||
"Background notifications are on": "Фонові сповіщення увімкнено",
|
"Background notifications are on": "Фонові сповіщення увімкнено",
|
||||||
@@ -1122,7 +1124,7 @@ export const catalog: Catalog = {
|
|||||||
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Новий профіль має використовувати адресу, з якої цьому обліковому запису дозволено надсилати (псевдоніми налаштовуються на сервері).",
|
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "Новий профіль має використовувати адресу, з якої цьому обліковому запису дозволено надсилати (псевдоніми налаштовуються на сервері).",
|
||||||
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Не пропонується під час написання листа. Адреса й далі отримує пошту, і з неї знову можна надсилати, якщо показати її назад.",
|
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Не пропонується під час написання листа. Адреса й далі отримує пошту, і з неї знову можна надсилати, якщо показати її назад.",
|
||||||
"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.": "Кожен профіль — це адреса відправника зі своїм іменем, зворотною адресою та підписом. Основний профіль підставляється під час написання листа; вкажіть зворотну адресу, якщо відповіді мають надходити не на адресу відправника.",
|
"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.": "Кожен профіль — це адреса відправника зі своїм іменем, зворотною адресою та підписом. Основний профіль підставляється під час написання листа; вкажіть зворотну адресу, якщо відповіді мають надходити не на адресу відправника.",
|
||||||
"This signature is larger than the server's {limit}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.": "Цей підпис більший за серверне обмеження в {limit} байт. ihasmail збереже повну версію у ваших Файлах, а на сервері залишить короткий текстовий варіант — інші поштові клієнти побачать саме його.",
|
"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.": "\u0426\u0435\u0439 \u043f\u0456\u0434\u043f\u0438\u0441 \u0431\u0456\u043b\u044c\u0448\u0438\u0439 \u0437\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u043d\u0435 \u043e\u0431\u043c\u0435\u0436\u0435\u043d\u043d\u044f \u0432 {limit} \u0431\u0430\u0439\u0442. {app} \u0437\u0431\u0435\u0440\u0435\u0436\u0435 \u043f\u043e\u0432\u043d\u0443 \u0432\u0435\u0440\u0441\u0456\u044e \u0443 \u0432\u0430\u0448\u0438\u0445 \u0424\u0430\u0439\u043b\u0430\u0445, \u0430 \u043d\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u0456 \u0437\u0430\u043b\u0438\u0448\u0438\u0442\u044c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 \u0442\u0435\u043a\u0441\u0442\u043e\u0432\u0438\u0439 \u0432\u0430\u0440\u0456\u0430\u043d\u0442 \u2014 \u0456\u043d\u0448\u0456 \u043f\u043e\u0448\u0442\u043e\u0432\u0456 \u043a\u043b\u0456\u0454\u043d\u0442\u0438 \u043f\u043e\u0431\u0430\u0447\u0430\u0442\u044c \u0441\u0430\u043c\u0435 \u0439\u043e\u0433\u043e.",
|
||||||
"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.": "Категорії у стилі Outlook, які можна призначати подіям через контекстне меню або редактор події. Назва категорії зберігається в самій події й синхронізується з іншими клієнтами.",
|
"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.": "Категорії у стилі Outlook, які можна призначати подіям через контекстне меню або редактор події. Назва категорії зберігається в самій події й синхронізується з іншими клієнтами.",
|
||||||
"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.": "Листи у звичайному тексті вже відповідають темі. З цим налаштуванням їй відповідають і HTML-листи без власних кольорів, замість того щоб показуватися на білому тлі. Листи з власним оформленням залишаються саме такими, якими їх задумав відправник.",
|
"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.": "Листи у звичайному тексті вже відповідають темі. З цим налаштуванням їй відповідають і HTML-листи без власних кольорів, замість того щоб показуватися на білому тлі. Листи з власним оформленням залишаються саме такими, якими їх задумав відправник.",
|
||||||
"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.": "Це не те саме, що {setting} у розділі «Загальні», де визначається, як записуються дати, час і числа. Можна читати англійський інтерфейс з українськими датами — або навпаки.",
|
"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.": "Це не те саме, що {setting} у розділі «Загальні», де визначається, як записуються дати, час і числа. Можна читати англійський інтерфейс з українськими датами — або навпаки.",
|
||||||
@@ -1130,40 +1132,40 @@ export const catalog: Catalog = {
|
|||||||
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Цей екран не сенсорний, тому тут нічого не зміниться. Налаштування підхоплять телефон або планшет.",
|
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Цей екран не сенсорний, тому тут нічого не зміниться. Налаштування підхоплять телефон або планшет.",
|
||||||
"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.": "Довге натискання на листі позначає його, а на теці — відкриває її меню. Потягніть список листів донизу, щоб перевірити пошту.",
|
"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.": "Довге натискання на листі позначає його, а на теці — відкриває її меню. Потягніть список листів донизу, щоб перевірити пошту.",
|
||||||
"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.": "Сповіщення повідомляє тому, хто його запитав, що адреса діюча і коли лист було прочитано, а відправник сам обирає, куди його надіслати, — тому автоматичного варіанта немає. Для масових розсилок, списків розсилки та всього позначеного як надіслане автоматично воно не пропонується взагалі.",
|
"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.": "Сповіщення повідомляє тому, хто його запитав, що адреса діюча і коли лист було прочитано, а відправник сам обирає, куди його надіслати, — тому автоматичного варіанта немає. Для масових розсилок, списків розсилки та всього позначеного як надіслане автоматично воно не пропонується взагалі.",
|
||||||
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.": "Цей браузер не вміє реєструвати програми для посилань {scheme}. Зокрема, у Safari немає такого інтерфейсу — але ihasmail усе одно можна зробити програмою за замовчуванням засобами операційної системи, встановивши його як застосунок.",
|
"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.": "\u0426\u0435\u0439 \u0431\u0440\u0430\u0443\u0437\u0435\u0440 \u043d\u0435 \u0432\u043c\u0456\u0454 \u0440\u0435\u0454\u0441\u0442\u0440\u0443\u0432\u0430\u0442\u0438 \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u0438 \u0434\u043b\u044f \u043f\u043e\u0441\u0438\u043b\u0430\u043d\u044c {scheme}. \u0417\u043e\u043a\u0440\u0435\u043c\u0430, \u0443 Safari \u043d\u0435\u043c\u0430\u0454 \u0442\u0430\u043a\u043e\u0433\u043e \u0456\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0443 \u2014 \u0430\u043b\u0435 {app} \u0443\u0441\u0435 \u043e\u0434\u043d\u043e \u043c\u043e\u0436\u043d\u0430 \u0437\u0440\u043e\u0431\u0438\u0442\u0438 \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043e\u044e \u0437\u0430 \u0437\u0430\u043c\u043e\u0432\u0447\u0443\u0432\u0430\u043d\u043d\u044f\u043c \u0437\u0430\u0441\u043e\u0431\u0430\u043c\u0438 \u043e\u043f\u0435\u0440\u0430\u0446\u0456\u0439\u043d\u043e\u0457 \u0441\u0438\u0441\u0442\u0435\u043c\u0438, \u0432\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0432\u0448\u0438 \u0439\u043e\u0433\u043e \u044f\u043a \u0437\u0430\u0441\u0442\u043e\u0441\u0443\u043d\u043e\u043a.",
|
||||||
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Для реєстрації посилань {scheme} потрібне захищене з'єднання (HTTPS).",
|
"Registering for {scheme} links requires a secure (HTTPS) connection.": "Для реєстрації посилань {scheme} потрібне захищене з'єднання (HTTPS).",
|
||||||
"Open {scheme} links — in web pages, documents and other apps — in ihasmail 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 › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).": "Відкривати посилання {scheme} — на вебсторінках, у документах та інших програмах — у ihasmail, а не в поштовій програмі на комп'ютері. Браузер попросить підтвердження, і згодом це можна змінити в його налаштуваннях (Chrome: Налаштування › Конфіденційність і безпека › Налаштування сайтів › Обробники протоколів; Firefox: Налаштування › Загальні › Програми).",
|
"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).": "\u0412\u0456\u0434\u043a\u0440\u0438\u0432\u0430\u0442\u0438 \u043f\u043e\u0441\u0438\u043b\u0430\u043d\u043d\u044f {scheme} \u2014 \u043d\u0430 \u0432\u0435\u0431\u0441\u0442\u043e\u0440\u0456\u043d\u043a\u0430\u0445, \u0443 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0445 \u0442\u0430 \u0456\u043d\u0448\u0438\u0445 \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u0430\u0445 \u2014 \u0443 {app}, \u0430 \u043d\u0435 \u0432 \u043f\u043e\u0448\u0442\u043e\u0432\u0456\u0439 \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u0456 \u043d\u0430 \u043a\u043e\u043c\u043f'\u044e\u0442\u0435\u0440\u0456. \u0411\u0440\u0430\u0443\u0437\u0435\u0440 \u043f\u043e\u043f\u0440\u043e\u0441\u0438\u0442\u044c \u043f\u0456\u0434\u0442\u0432\u0435\u0440\u0434\u0436\u0435\u043d\u043d\u044f, \u0456 \u0437\u0433\u043e\u0434\u043e\u043c \u0446\u0435 \u043c\u043e\u0436\u043d\u0430 \u0437\u043c\u0456\u043d\u0438\u0442\u0438 \u0432 \u0439\u043e\u0433\u043e \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f\u0445 (Chrome: \u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u203a \u041a\u043e\u043d\u0444\u0456\u0434\u0435\u043d\u0446\u0456\u0439\u043d\u0456\u0441\u0442\u044c \u0456 \u0431\u0435\u0437\u043f\u0435\u043a\u0430 \u203a \u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0441\u0430\u0439\u0442\u0456\u0432 \u203a \u041e\u0431\u0440\u043e\u0431\u043d\u0438\u043a\u0438 \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0456\u0432; Firefox: \u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u203a \u0417\u0430\u0433\u0430\u043b\u044c\u043d\u0456 \u203a \u041f\u0440\u043e\u0433\u0440\u0430\u043c\u0438).",
|
||||||
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Запитано в цьому браузері. Чи спрацювало це, вирішує він сам — перевірте його налаштування, якщо поштові посилання й далі відкриваються деінде.",
|
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "Запитано в цьому браузері. Чи спрацювало це, вирішує він сам — перевірте його налаштування, якщо поштові посилання й далі відкриваються деінде.",
|
||||||
"For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.": "Щоб задати програму за замовчуванням для всієї системи, спершу встановіть ihasmail як застосунок (у Chrome — значок встановлення в адресному рядку). Після цього операційна система зможе пропонувати ihasmail усюди, де запитує, якою поштовою програмою скористатися.",
|
"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.": "Щоб задати програму за замовчуванням для всієї системи, спершу встановіть {app} як застосунок (у Chrome — значок встановлення в адресному рядку). Після цього операційна система зможе пропонувати {app} усюди, де запитує, якою поштовою програмою скористатися.",
|
||||||
"Needs a browser with the Push API and a mail server that publishes a push key.": "Потрібен браузер із Push API та поштовий сервер, який публікує push-ключ.",
|
"Needs a browser with the Push API and a mail server that publishes a push key.": "Потрібен браузер із Push API та поштовий сервер, який публікує push-ключ.",
|
||||||
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Поштовий сервер доставляє їх прямо в браузер, тому вони надходять без відкритої вкладки ihasmail і містять відправника й тему. Браузер при цьому має бути запущений: якщо закрити його повністю, сповіщення почекають і надійдуть під час наступного запуску.",
|
"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.": "\u041f\u043e\u0448\u0442\u043e\u0432\u0438\u0439 \u0441\u0435\u0440\u0432\u0435\u0440 \u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0454 \u0457\u0445 \u043f\u0440\u044f\u043c\u043e \u0432 \u0431\u0440\u0430\u0443\u0437\u0435\u0440, \u0442\u043e\u043c\u0443 \u0432\u043e\u043d\u0438 \u043d\u0430\u0434\u0445\u043e\u0434\u044f\u0442\u044c \u0431\u0435\u0437 \u0432\u0456\u0434\u043a\u0440\u0438\u0442\u043e\u0457 \u0432\u043a\u043b\u0430\u0434\u043a\u0438 {app} \u0456 \u043c\u0456\u0441\u0442\u044f\u0442\u044c \u0432\u0456\u0434\u043f\u0440\u0430\u0432\u043d\u0438\u043a\u0430 \u0439 \u0442\u0435\u043c\u0443. \u0411\u0440\u0430\u0443\u0437\u0435\u0440 \u043f\u0440\u0438 \u0446\u044c\u043e\u043c\u0443 \u043c\u0430\u0454 \u0431\u0443\u0442\u0438 \u0437\u0430\u043f\u0443\u0449\u0435\u043d\u0438\u0439: \u044f\u043a\u0449\u043e \u0437\u0430\u043a\u0440\u0438\u0442\u0438 \u0439\u043e\u0433\u043e \u043f\u043e\u0432\u043d\u0456\u0441\u0442\u044e, \u0441\u043f\u043e\u0432\u0456\u0449\u0435\u043d\u043d\u044f \u043f\u043e\u0447\u0435\u043a\u0430\u044e\u0442\u044c \u0456 \u043d\u0430\u0434\u0456\u0439\u0434\u0443\u0442\u044c \u043f\u0456\u0434 \u0447\u0430\u0441 \u043d\u0430\u0441\u0442\u0443\u043f\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0443\u0441\u043a\u0443.",
|
||||||
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Поштовий сервер може розбудити цей браузер, але не повідомить відправника й тему. Браузер при цьому має бути запущений.",
|
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Поштовий сервер може розбудити цей браузер, але не повідомить відправника й тему. Браузер при цьому має бути запущений.",
|
||||||
"This is what a new-mail notification looks like.": "Так виглядає сповіщення про новий лист.",
|
"This is what a new-mail notification looks like.": "Так виглядає сповіщення про новий лист.",
|
||||||
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Ви увійшли як {user}. Пароль ніколи не зберігається в браузері: сервер тримає його зашифрованим на час сеансу, щоб спілкуватися зі Stalwart.",
|
"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.": "Ви ввійшли як {user}. Пароль ніколи не зберігається в браузері; сервер зберігає його зашифрованим для кожного сеансу, щоб звертатися до поштового сервера.",
|
||||||
"App passwords are managed by your mail administrator.": "Паролями програм керує ваш поштовий адміністратор.",
|
"App passwords are managed by your mail administrator.": "Паролями програм керує ваш поштовий адміністратор.",
|
||||||
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Зміна пароля завершує інші сеанси вебпошти. Паролі програм продовжують працювати.",
|
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Зміна пароля завершує інші сеанси вебпошти. Паролі програм продовжують працювати.",
|
||||||
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Для цього облікового запису увімкнено двофакторну автентифікацію. ihasmail поки не вміє входити за кодом, тому для входу на іншому пристрої потрібен пароль програми — або двофакторну автентифікацію можна вимкнути тут.",
|
"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.": "\u0414\u043b\u044f \u0446\u044c\u043e\u0433\u043e \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u043e\u0433\u043e \u0437\u0430\u043f\u0438\u0441\u0443 \u0443\u0432\u0456\u043c\u043a\u043d\u0435\u043d\u043e \u0434\u0432\u043e\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0443 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044e. {app} \u043f\u043e\u043a\u0438 \u043d\u0435 \u0432\u043c\u0456\u0454 \u0432\u0445\u043e\u0434\u0438\u0442\u0438 \u0437\u0430 \u043a\u043e\u0434\u043e\u043c, \u0442\u043e\u043c\u0443 \u0434\u043b\u044f \u0432\u0445\u043e\u0434\u0443 \u043d\u0430 \u0456\u043d\u0448\u043e\u043c\u0443 \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u0457 \u043f\u043e\u0442\u0440\u0456\u0431\u0435\u043d \u043f\u0430\u0440\u043e\u043b\u044c \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u0438 \u2014 \u0430\u0431\u043e \u0434\u0432\u043e\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0443 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044e \u043c\u043e\u0436\u043d\u0430 \u0432\u0438\u043c\u043a\u043d\u0443\u0442\u0438 \u0442\u0443\u0442.",
|
||||||
"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.": "Окремий пароль для поштової програми чи пристрою, який можна відкликати окремо. Паролі програм обходять двофакторні коди й тому працюють там, де запитати код неможливо.",
|
"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.": "Окремий пароль для поштової програми чи пристрою, який можна відкликати окремо. Паролі програм обходять двофакторні коди й тому працюють там, де запитати код неможливо.",
|
||||||
"Copy it into {name} now — it isn't shown again.": "Скопіюйте його до {name} зараз — більше він не показується.",
|
"Copy it into {name} now — it isn't shown again.": "Скопіюйте його до {name} зараз — більше він не показується.",
|
||||||
"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.": "У каталозі не знайдено інших користувачів, тому додати нікого. Уже відкритий доступ перелічено нижче, і його й далі можна закрити.",
|
"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.": "У каталозі не знайдено інших користувачів, тому додати нікого. Уже відкритий доступ перелічено нижче, і його й далі можна закрити.",
|
||||||
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart не повідомляє поштовим клієнтам номер версії, тому ihasmail показує редакцію, якщо сервер її називає. ihasmail потребує версію 0.16 або новішу, і вхід зі старішою не виконується.",
|
"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 \u043d\u0435 \u043f\u043e\u0432\u0456\u0434\u043e\u043c\u043b\u044f\u0454 \u043f\u043e\u0448\u0442\u043e\u0432\u0438\u043c \u043a\u043b\u0456\u0454\u043d\u0442\u0430\u043c \u043d\u043e\u043c\u0435\u0440 \u0432\u0435\u0440\u0441\u0456\u0457, \u0442\u043e\u043c\u0443 {app} \u043f\u043e\u043a\u0430\u0437\u0443\u0454 \u0440\u0435\u0434\u0430\u043a\u0446\u0456\u044e, \u044f\u043a\u0449\u043e \u0441\u0435\u0440\u0432\u0435\u0440 \u0457\u0457 \u043d\u0430\u0437\u0438\u0432\u0430\u0454. {app} \u043f\u043e\u0442\u0440\u0435\u0431\u0443\u0454 \u0432\u0435\u0440\u0441\u0456\u044e 0.16 \u0430\u0431\u043e \u043d\u043e\u0432\u0456\u0448\u0443, \u0456 \u0432\u0445\u0456\u0434 \u0437\u0456 \u0441\u0442\u0430\u0440\u0456\u0448\u043e\u044e \u043d\u0435 \u0432\u0438\u043a\u043e\u043d\u0443\u0454\u0442\u044c\u0441\u044f.",
|
||||||
"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.": "Він {damage}, тому правила в ньому не можна показати чи змінити: збереження отриманої частини затерло б решту. Перезавантажте сторінку й спробуйте знову. Ваші правила залишаються на сервері, тут їх ніщо не змінювало.",
|
"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.": "Він {damage}, тому правила в ньому не можна показати чи змінити: збереження отриманої частини затерло б решту. Перезавантажте сторінку й спробуйте знову. Ваші правила залишаються на сервері, тут їх ніщо не змінювало.",
|
||||||
"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).": "Візуальний редактор правил працює лише зі скриптами, які створив сам. Скрипт можна змінити на вкладці {tab} або почати заново з правил (наявний скрипт збережеться, але буде вимкнено).",
|
"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).": "Візуальний редактор правил працює лише зі скриптами, які створив сам. Скрипт можна змінити на вкладці {tab} або почати заново з правил (наявний скрипт збережеться, але буде вимкнено).",
|
||||||
"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.": "Ваш скрипт фільтрації {damage}, тому отримано лише його частину. Додавання правила затерло б цією частиною весь скрипт. Перезавантажте сторінку й спробуйте знову.",
|
"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.": "Ваш скрипт фільтрації {damage}, тому отримано лише його частину. Додавання правила затерло б цією частиною весь скрипт. Перезавантажте сторінку й спробуйте знову.",
|
||||||
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Скрипт фільтрації зараз не вдалося прочитати, тому додавання правила ризикує його перезаписати. Перезавантажте сторінку й спробуйте знову.",
|
"Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Скрипт фільтрації зараз не вдалося прочитати, тому додавання правила ризикує його перезаписати. Перезавантажте сторінку й спробуйте знову.",
|
||||||
"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.": "Ваш активний скрипт Sieve написано вручну, тому правила не можна додати автоматично. Відкрийте {where}, щоб змінити скрипт або перейти до керованих правил.",
|
"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.": "Ваш активний скрипт Sieve написано вручну, тому правила не можна додати автоматично. Відкрийте {where}, щоб змінити скрипт або перейти до керованих правил.",
|
||||||
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Тут показано лише мови, якими перекладено ihasmail, тому список зростає разом із перекладами, а не випереджає їх: мова без текстів змусила б сторінку стверджувати, що вона написана мовою, якою не є.",
|
"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.": "\u0422\u0443\u0442 \u043f\u043e\u043a\u0430\u0437\u0430\u043d\u043e \u043b\u0438\u0448\u0435 \u043c\u043e\u0432\u0438, \u044f\u043a\u0438\u043c\u0438 \u043f\u0435\u0440\u0435\u043a\u043b\u0430\u0434\u0435\u043d\u043e {app}, \u0442\u043e\u043c\u0443 \u0441\u043f\u0438\u0441\u043e\u043a \u0437\u0440\u043e\u0441\u0442\u0430\u0454 \u0440\u0430\u0437\u043e\u043c \u0456\u0437 \u043f\u0435\u0440\u0435\u043a\u043b\u0430\u0434\u0430\u043c\u0438, \u0430 \u043d\u0435 \u0432\u0438\u043f\u0435\u0440\u0435\u0434\u0436\u0430\u0454 \u0457\u0445: \u043c\u043e\u0432\u0430 \u0431\u0435\u0437 \u0442\u0435\u043a\u0441\u0442\u0456\u0432 \u0437\u043c\u0443\u0441\u0438\u043b\u0430 \u0431 \u0441\u0442\u043e\u0440\u0456\u043d\u043a\u0443 \u0441\u0442\u0432\u0435\u0440\u0434\u0436\u0443\u0432\u0430\u0442\u0438, \u0449\u043e \u0432\u043e\u043d\u0430 \u043d\u0430\u043f\u0438\u0441\u0430\u043d\u0430 \u043c\u043e\u0432\u043e\u044e, \u044f\u043a\u043e\u044e \u043d\u0435 \u0454.",
|
||||||
"tell us about it": "повідомте нам",
|
"tell us about it": "повідомте нам",
|
||||||
"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}.": "Цей переклад зроблено ШІ й не перевірено носієм мови, тому його позначено як Beta, доки хтось його не підтвердить. Про все, що звучить неправильно, варто повідомити — {report}.",
|
"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}.": "Цей переклад зроблено ШІ й не перевірено носієм мови, тому його позначено як Beta, доки хтось його не підтвердить. Про все, що звучить неправильно, варто повідомити — {report}.",
|
||||||
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "Власна версія ihasmail — це дата коміту, з якого його зібрано, і вказівка, звідки цей коміт узявся: {example} зібрано з коміту від 30 серпня 2026 року, що надійшов через pull request 129. Коміт, який надійшов інакше, несе замість цього короткий SHA — {sha}. Версія навмисно нічого не повідомляє про Stalwart; те, що цій збірці потрібно від сервера, вказано рядком вище.",
|
"{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.": "\u0412\u043b\u0430\u0441\u043d\u0430 \u0432\u0435\u0440\u0441\u0456\u044f {app} \u2014 \u0446\u0435 \u0434\u0430\u0442\u0430 \u043a\u043e\u043c\u0456\u0442\u0443, \u0437 \u044f\u043a\u043e\u0433\u043e \u0439\u043e\u0433\u043e \u0437\u0456\u0431\u0440\u0430\u043d\u043e, \u0456 \u0432\u043a\u0430\u0437\u0456\u0432\u043a\u0430, \u0437\u0432\u0456\u0434\u043a\u0438 \u0446\u0435\u0439 \u043a\u043e\u043c\u0456\u0442 \u0443\u0437\u044f\u0432\u0441\u044f: {example} \u0437\u0456\u0431\u0440\u0430\u043d\u043e \u0437 \u043a\u043e\u043c\u0456\u0442\u0443 \u0432\u0456\u0434 30 \u0441\u0435\u0440\u043f\u043d\u044f 2026 \u0440\u043e\u043a\u0443, \u0449\u043e \u043d\u0430\u0434\u0456\u0439\u0448\u043e\u0432 \u0447\u0435\u0440\u0435\u0437 pull request 129. \u041a\u043e\u043c\u0456\u0442, \u044f\u043a\u0438\u0439 \u043d\u0430\u0434\u0456\u0439\u0448\u043e\u0432 \u0456\u043d\u0430\u043a\u0448\u0435, \u043d\u0435\u0441\u0435 \u0437\u0430\u043c\u0456\u0441\u0442\u044c \u0446\u044c\u043e\u0433\u043e \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 SHA \u2014 {sha}. \u0412\u0435\u0440\u0441\u0456\u044f \u043d\u0430\u0432\u043c\u0438\u0441\u043d\u043e \u043d\u0456\u0447\u043e\u0433\u043e \u043d\u0435 \u043f\u043e\u0432\u0456\u0434\u043e\u043c\u043b\u044f\u0454 \u043f\u0440\u043e Stalwart; \u0442\u0435, \u0449\u043e \u0446\u0456\u0439 \u0437\u0431\u0456\u0440\u0446\u0456 \u043f\u043e\u0442\u0440\u0456\u0431\u043d\u043e \u0432\u0456\u0434 \u0441\u0435\u0440\u0432\u0435\u0440\u0430, \u0432\u043a\u0430\u0437\u0430\u043d\u043e \u0440\u044f\u0434\u043a\u043e\u043c \u0432\u0438\u0449\u0435.",
|
||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Новий лист",
|
"New message": "Новий лист",
|
||||||
"Start a new message with what was shared?": "Почати новий лист з отриманим вмістом?",
|
"Start a new message with what was shared?": "Почати новий лист з отриманим вмістом?",
|
||||||
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "До ihasmail щось передали через «Поділитися». Нічого не буде надіслано, доки ви не натиснете «Надіслати». Якщо ви щойно нічого не передавали, натисніть «Не зберігати».",
|
"Something was shared with {app}. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "До {app} щось передали через «Поділитися». Нічого не буде надіслано, доки ви не натиснете «Надіслати». Якщо ви щойно нічого не передавали, натисніть «Не зберігати».",
|
||||||
"Start a message": "Почати лист",
|
"Start a message": "Почати лист",
|
||||||
"New mail": "Новий лист",
|
"New mail": "Новий лист",
|
||||||
"Could not do that — open ihasmail and try again": "Не вдалося — відкрийте ihasmail і повторіть спробу",
|
"Could not do that \u2014 open {app} and try again": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u2014 \u0432\u0456\u0434\u043a\u0440\u0438\u0439\u0442\u0435 {app} \u0456 \u043f\u043e\u0432\u0442\u043e\u0440\u0456\u0442\u044c \u0441\u043f\u0440\u043e\u0431\u0443",
|
||||||
"Sending…": "Надсилання…",
|
"Sending…": "Надсилання…",
|
||||||
"Saving…": "Збереження…",
|
"Saving…": "Збереження…",
|
||||||
"Error": "Помилка",
|
"Error": "Помилка",
|
||||||
@@ -1211,6 +1213,20 @@ export const catalog: Catalog = {
|
|||||||
"Could not save filters: {error}": "Не вдалося зберегти фільтри: {error}",
|
"Could not save filters: {error}": "Не вдалося зберегти фільтри: {error}",
|
||||||
"Could not send the receipt: {error}": "Не вдалося надіслати сповіщення про прочитання: {error}",
|
"Could not send the receipt: {error}": "Не вдалося надіслати сповіщення про прочитання: {error}",
|
||||||
"Could not sign in.": "Не вдалося увійти.",
|
"Could not sign in.": "Не вдалося увійти.",
|
||||||
|
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "Ви ввійшли як {user}. Ця вебпошта ніколи не бачить ваш пароль: вона зберігає токен входу від вашого поштового сервера, зашифрований для кожного сеансу.",
|
||||||
|
"Mail server": "Поштовий сервер",
|
||||||
|
"You'll enter your password on your mail server's sign-in page.": "Пароль вводиться на сторінці входу вашого поштового сервера.",
|
||||||
|
"You'll sign in on your mail server's own page.": "Вхід виконується на сторінці вашого поштового сервера.",
|
||||||
|
"This sign-in didn't start in this browser. Try again.": "Цей вхід розпочато не в цьому браузері. Спробуйте ще раз.",
|
||||||
|
"The sign-in took too long. Try again.": "Вхід тривав занадто довго. Спробуйте ще раз.",
|
||||||
|
"Sign-in was cancelled.": "Вхід скасовано.",
|
||||||
|
"The mail server didn't accept the sign-in. Try again.": "Поштовий сервер не прийняв вхід. Спробуйте ще раз.",
|
||||||
|
"That account is on a different mail server than the address you entered. Sign in with that address.": "Цей обліковий запис на іншому поштовому сервері, ніж введена адреса. Увійдіть із цією адресою.",
|
||||||
|
"This mail server isn't supported.": "Цей поштовий сервер не підтримується.",
|
||||||
|
"Couldn't reach the mail server. Try again in a moment.": "Не вдалося зв’язатися з поштовим сервером. Спробуйте ще раз трохи згодом.",
|
||||||
|
"Your password was changed. Sign in with the new one.": "Пароль змінено. Увійдіть із новим паролем.",
|
||||||
|
"Your sign-in ended. Sign in again.": "Сеанс завершено. Увійдіть знову.",
|
||||||
|
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Зміна пароля завершує всі сеанси, зокрема цей, після чого треба увійти з новим паролем. Паролі програм продовжують працювати.",
|
||||||
"Could not store image: {error}": "Не вдалося зберегти зображення: {error}",
|
"Could not store image: {error}": "Не вдалося зберегти зображення: {error}",
|
||||||
"Could not update labels: {error}": "Не вдалося оновити мітки: {error}",
|
"Could not update labels: {error}": "Не вдалося оновити мітки: {error}",
|
||||||
"Could not update the Scheduled folder: {error}": "Не вдалося оновити теку «Заплановані»: {error}",
|
"Could not update the Scheduled folder: {error}": "Не вдалося оновити теку «Заплановані»: {error}",
|
||||||
@@ -1333,7 +1349,7 @@ export const catalog: Catalog = {
|
|||||||
"Undo window: {seconds}s": "Час на скасування: {seconds} с",
|
"Undo window: {seconds}s": "Час на скасування: {seconds} с",
|
||||||
"You're all caught up": "Усе прочитано",
|
"You're all caught up": "Усе прочитано",
|
||||||
"Your browser refused the request: {error}": "Браузер відхилив запит: {error}",
|
"Your browser refused the request: {error}": "Браузер відхилив запит: {error}",
|
||||||
"Your browser will ask whether to open mail links in ihasmail": "Браузер запитає, чи відкривати поштові посилання в ihasmail",
|
"Your browser will ask whether to open mail links in {app}": "Браузер запитає, чи відкривати поштові посилання в {app}",
|
||||||
"Your message mentions an attachment, but nothing is attached.": "У листі згадано вкладення, але нічого не долучено.",
|
"Your message mentions an attachment, but nothing is attached.": "У листі згадано вкладення, але нічого не долучено.",
|
||||||
"event": "подія",
|
"event": "подія",
|
||||||
"Hide password": "Сховати пароль",
|
"Hide password": "Сховати пароль",
|
||||||
@@ -1404,7 +1420,7 @@ export const catalog: Catalog = {
|
|||||||
"Also count these domains as inside": "Вважати внутрішніми також ці домени",
|
"Also count these domains as inside": "Вважати внутрішніми також ці домени",
|
||||||
"Always": "Завжди",
|
"Always": "Завжди",
|
||||||
"Always showing images from": "Завжди показувати зображення від",
|
"Always showing images from": "Завжди показувати зображення від",
|
||||||
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.": "Зображення, завантажене із сервера відправника, повідомляє йому, що лист відкрили, коли і приблизно звідки. Дозволені зображення завантажує сервер ihasmail, а не браузер, тому відправник не дізнається нічого з цього.",
|
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.": "Зображення, завантажене із сервера відправника, повідомляє йому, що лист відкрили, коли і приблизно звідки. Дозволені зображення завантажує сервер {app}, а не браузер, тому відправник не дізнається нічого з цього.",
|
||||||
"Applies to": "Застосовується до",
|
"Applies to": "Застосовується до",
|
||||||
"Archive and next": "Архівувати й далі",
|
"Archive and next": "Архівувати й далі",
|
||||||
"Archive by month": "Архівувати за місяцями",
|
"Archive by month": "Архівувати за місяцями",
|
||||||
@@ -1646,8 +1662,8 @@ export const catalog: Catalog = {
|
|||||||
"Fingerprint": "Відбиток",
|
"Fingerprint": "Відбиток",
|
||||||
"Hide details": "Сховати подробиці",
|
"Hide details": "Сховати подробиці",
|
||||||
"Issued by": "Ким видано",
|
"Issued by": "Ким видано",
|
||||||
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Лист підписано OpenPGP, а ihasmail не може отримати відкритий ключ відправника.",
|
"It is signed with OpenPGP, and {app} has no way to fetch the sender's public key.": "Лист підписано OpenPGP, а {app} не може отримати відкритий ключ відправника.",
|
||||||
"It uses a signature algorithm ihasmail cannot check yet.": "Використано алгоритм підпису, який ihasmail поки не вміє перевіряти.",
|
"It uses a signature algorithm {app} cannot check yet.": "Використано алгоритм підпису, який {app} поки не вміє перевіряти.",
|
||||||
"It was made with a certificate belonging to {name}, which does not cover this address.": "Підпис зроблено сертифікатом, що належить {name} і не покриває цю адресу.",
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Підпис зроблено сертифікатом, що належить {name} і не покриває цю адресу.",
|
||||||
"Previous fingerprint": "Попередній відбиток",
|
"Previous fingerprint": "Попередній відбиток",
|
||||||
"Signed at": "Підписано",
|
"Signed at": "Підписано",
|
||||||
@@ -1664,18 +1680,39 @@ export const catalog: Catalog = {
|
|||||||
"The signature is not for this sender.": "Підпис не належить цьому відправникові.",
|
"The signature is not for this sender.": "Підпис не належить цьому відправникові.",
|
||||||
"The signed part is missing either the message or the signature.": "У підписаній частині бракує або листа, або підпису.",
|
"The signed part is missing either the message or the signature.": "У підписаній частині бракує або листа, або підпису.",
|
||||||
"The signer has changed.": "Підписувач змінився.",
|
"The signer has changed.": "Підписувач змінився.",
|
||||||
"This message is signed, and ihasmail could not check the signature.": "Цей лист підписано, і ihasmail не зміг перевірити підпис.",
|
"This message is signed, and {app} could not check the signature.": "Цей лист підписано, і {app} не зміг перевірити підпис.",
|
||||||
"This signature does not check out.": "Цей підпис не сходиться.",
|
"This signature does not check out.": "Цей підпис не сходиться.",
|
||||||
"Valid until": "Чинний до",
|
"Valid until": "Чинний до",
|
||||||
"a different certificate": "іншим сертифікатом",
|
"a different certificate": "іншим сертифікатом",
|
||||||
"an unnamed signer": "неназваним підписувачем",
|
"an unnamed signer": "неназваним підписувачем",
|
||||||
"as claimed by the signer": "за словами підписувача",
|
"as claimed by the signer": "за словами підписувача",
|
||||||
"first seen {date}": "уперше побачено {date}",
|
"first seen {date}": "уперше побачено {date}",
|
||||||
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail повідомить, якщо наступний лист із цієї адреси підпише хтось інший.",
|
"{app} will tell you if a later message from this address is signed by anybody else.": "{app} повідомить, якщо наступний лист із цієї адреси підпише хтось інший.",
|
||||||
"itself, or an issuer it does not name": "самим собою або неназваним видавцем",
|
"itself, or an issuer it does not name": "самим собою або неназваним видавцем",
|
||||||
"no address": "немає адреси",
|
"no address": "немає адреси",
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"Legacy mail apps": "Поштові програми на застарілих протоколах",
|
||||||
|
"Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.": "Вимкнено для {tenant}. Входити на домени цієї організації можуть лише {app} і програми JMAP.",
|
||||||
|
"On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.": "Увімкнено для {tenant}. Поштові програми можуть використовувати IMAP, POP3 і ManageSieve на доменах цієї організації.",
|
||||||
|
"Turn legacy protocols back on": "Знову увімкнути застарілі поштові протоколи",
|
||||||
|
"Turn off legacy protocols…": "Вимкнути застарілі поштові протоколи…",
|
||||||
|
"No account used a legacy mail app in the last 30 days.": "За останні 30 днів жоден обліковий запис не використовував поштову програму на застарілих протоколах.",
|
||||||
|
"Their mail apps will stop working the moment you turn this on:": "Їхні поштові програми перестануть працювати, щойно ви це ввімкнете:",
|
||||||
|
"Only {app} and JMAP apps will work.": "Працюватимуть лише {app} і програми JMAP.",
|
||||||
|
"Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {tenant}.": "Застарілі поштові протоколи (IMAP, POP3, ManageSieve і надсилання з поштових програм) буде вимкнено для всіх у {tenant}.",
|
||||||
|
"Phone and desktop mail apps will stop receiving and sending mail. That's iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.": "Поштові програми на телефонах і комп'ютерах перестануть отримувати й надсилати пошту. Це Пошта на iPhone та iPad, програми Gmail і Outlook, Outlook, Thunderbird і Apple Mail. У них з'являтимуться помилки входу.",
|
||||||
|
"Filters managed from a mail app (ManageSieve) will stop working. Filters set in {app} keep working.": "Фільтри, якими керують із поштової програми (ManageSieve), перестануть працювати. Фільтри, налаштовані в {app}, працюватимуть і далі.",
|
||||||
|
"Incoming mail is not affected. Calendars and contacts are not affected.": "Вхідну пошту це не зачіпає. Календарі й контакти це не зачіпає.",
|
||||||
|
"People keep full access through {app}, which can be installed as an app on phones and computers.": "Усі зберігають повний доступ через {app}, який можна встановити як програму на телефони й комп'ютери.",
|
||||||
|
"Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and {app} (JMAP) are not affected and cannot be turned off here.": "Надсилання з поштових програм (SMTP submission) перестане працювати, але його порти залишаться відкритими: поштовим програмам повідомлять, що увійти не можна. Вхідну пошту (SMTP) і {app} (JMAP) це не зачіпає, і вимкнути їх тут не можна.",
|
||||||
|
"You can turn legacy protocols back on at any time.": "Ви можете знову ввімкнути застарілі поштові протоколи будь-коли.",
|
||||||
|
"To confirm, type {phrase}": "Для підтвердження введіть {phrase}",
|
||||||
|
"Turn off legacy protocols": "Вимкнути застарілі поштові протоколи",
|
||||||
|
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Застарілі поштові протоколи вимкнено для вашої організації. Входити можуть лише {app} і програми JMAP.",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"{n} accounts used a legacy mail app in the last 30 days.": {one: "{n} обліковий запис використовував поштову програму на застарілих протоколах за останні 30 днів.", few: "{n} облікові записи використовували поштову програму на застарілих протоколах за останні 30 днів.", many: "{n} облікових записів використовували поштову програму на застарілих протоколах за останні 30 днів.", other: "{n} облікового запису використовували поштову програму на застарілих протоколах за останні 30 днів."},
|
||||||
// ── Administration: domains ────────────────────────────────────
|
// ── Administration: domains ────────────────────────────────────
|
||||||
"{n} accounts use this domain. Move or delete them first.": { one: "Цей домен використовує {n} обліковий запис. Спершу перенесіть або видаліть його.", few: "Цей домен використовують {n} облікові записи. Спершу перенесіть або видаліть їх.", many: "Цей домен використовують {n} облікових записів. Спершу перенесіть або видаліть їх.", other: "Цей домен використовують {n} облікового запису. Спершу перенесіть або видаліть їх." },
|
"{n} accounts use this domain. Move or delete them first.": { one: "Цей домен використовує {n} обліковий запис. Спершу перенесіть або видаліть його.", few: "Цей домен використовують {n} облікові записи. Спершу перенесіть або видаліть їх.", many: "Цей домен використовують {n} облікових записів. Спершу перенесіть або видаліть їх.", other: "Цей домен використовують {n} облікового запису. Спершу перенесіть або видаліть їх." },
|
||||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Сервер перестане приймати пошту для цього домену, і його {n} ключ DKIM буде видалено. Скасувати це неможливо.", few: "Сервер перестане приймати пошту для цього домену, і його {n} ключі DKIM буде видалено. Скасувати це неможливо.", many: "Сервер перестане приймати пошту для цього домену, і його {n} ключів DKIM буде видалено. Скасувати це неможливо.", other: "Сервер перестане приймати пошту для цього домену, і його {n} ключа DKIM буде видалено. Скасувати це неможливо." },
|
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { one: "Сервер перестане приймати пошту для цього домену, і його {n} ключ DKIM буде видалено. Скасувати це неможливо.", few: "Сервер перестане приймати пошту для цього домену, і його {n} ключі DKIM буде видалено. Скасувати це неможливо.", many: "Сервер перестане приймати пошту для цього домену, і його {n} ключів DKIM буде видалено. Скасувати це неможливо.", other: "Сервер перестане приймати пошту для цього домену, і його {n} ключа DKIM буде видалено. Скасувати це неможливо." },
|
||||||
|
|||||||
+68
-31
@@ -137,8 +137,8 @@ export const catalog: Catalog = {
|
|||||||
"The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。",
|
"The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。",
|
||||||
"Nothing to show": "没有可显示的内容",
|
"Nothing to show": "没有可显示的内容",
|
||||||
"Could not be loaded": "无法加载",
|
"Could not be loaded": "无法加载",
|
||||||
"Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.": "详细指标、投递队列、日志和服务器设置位于 Stalwart 自身的管理界面中。",
|
"Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.": "详细指标、投递队列、日志和服务器设置都在 INBUXA Admin 中。",
|
||||||
"Open Stalwart admin": "打开 Stalwart 管理界面",
|
"Open INBUXA Admin": "打开 INBUXA Admin",
|
||||||
"Default group role": "默认群组角色",
|
"Default group role": "默认群组角色",
|
||||||
"A group needs an address.": "群组需要一个地址。",
|
"A group needs an address.": "群组需要一个地址。",
|
||||||
"New group": "新建群组",
|
"New group": "新建群组",
|
||||||
@@ -207,10 +207,10 @@ export const catalog: Catalog = {
|
|||||||
"New role": "新建角色",
|
"New role": "新建角色",
|
||||||
"This role carries permissions yours doesn't, so you can view it but not change it.": "此角色拥有您的角色所没有的权限,因此您可以查看但不能更改。",
|
"This role carries permissions yours doesn't, so you can view it but not change it.": "此角色拥有您的角色所没有的权限,因此您可以查看但不能更改。",
|
||||||
"Your role lets you view roles but not change them.": "您的角色可以查看角色,但不能更改。",
|
"Your role lets you view roles but not change them.": "您的角色可以查看角色,但不能更改。",
|
||||||
"Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "Stalwart 默认将此角色授予 {kinds}。此处的更改会影响所有以这种方式获得它的人。",
|
"The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.": "邮件服务器默认将此角色授予{kinds}。在此处的更改会影响所有以此方式拥有该角色的人。",
|
||||||
"Builds on": "基于",
|
"Builds on": "基于",
|
||||||
"Permissions": "权限",
|
"Permissions": "权限",
|
||||||
"Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.": "Stalwart 默认授予此角色,因此无法删除。请先在 Stalwart 自身的管理界面中更改默认设置。",
|
"The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.": "邮件服务器默认授予此角色,因此无法删除。请先在 INBUXA Admin 中更改默认设置。",
|
||||||
"This role carries permissions yours doesn't.": "此角色拥有您的角色所没有的权限。",
|
"This role carries permissions yours doesn't.": "此角色拥有您的角色所没有的权限。",
|
||||||
"Create role": "创建角色",
|
"Create role": "创建角色",
|
||||||
"builds on this one": "基于此角色",
|
"builds on this one": "基于此角色",
|
||||||
@@ -237,7 +237,7 @@ export const catalog: Catalog = {
|
|||||||
"Delete role": "删除角色",
|
"Delete role": "删除角色",
|
||||||
"It can't be undone.": "此操作无法撤销。",
|
"It can't be undone.": "此操作无法撤销。",
|
||||||
"Type {name} to confirm": "输入 {name} 以确认",
|
"Type {name} to confirm": "输入 {name} 以确认",
|
||||||
"Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "无法加载 Stalwart 的权限列表,因此无法在此更改权限。({reason})",
|
"The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})": "无法加载邮件服务器的权限列表,因此无法在此更改权限。({reason})",
|
||||||
"Named sets of permissions, given to accounts, groups and tenants.": "命名的权限集合,授予账户、群组和租户。",
|
"Named sets of permissions, given to accounts, groups and tenants.": "命名的权限集合,授予账户、群组和租户。",
|
||||||
"Search roles": "搜索角色",
|
"Search roles": "搜索角色",
|
||||||
"No roles match": "没有匹配的角色",
|
"No roles match": "没有匹配的角色",
|
||||||
@@ -260,11 +260,11 @@ export const catalog: Catalog = {
|
|||||||
"{used} used": "已用 {used}",
|
"{used} used": "已用 {used}",
|
||||||
"Your role lets you view tenants but not change them.": "您的角色可以查看租户,但不能更改。",
|
"Your role lets you view tenants but not change them.": "您的角色可以查看租户,但不能更改。",
|
||||||
"Logo": "徽标",
|
"Logo": "徽标",
|
||||||
"An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.": "https 地址或图片的 data URL。Stalwart 会在显示徽标的地方向租户成员展示它。",
|
"An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.": "图片的 https 地址或 data URL。邮件服务器会在显示徽标的位置向该租户的成员显示它。",
|
||||||
"What it holds": "包含内容",
|
"What it holds": "包含内容",
|
||||||
"{n} of {limit}": "{n}/{limit}",
|
"{n} of {limit}": "{n}/{limit}",
|
||||||
"Limits": "限额",
|
"Limits": "限额",
|
||||||
"Stalwart refuses to create more than a limit allows. An empty field is no limit.": "Stalwart 会拒绝超出限额的创建。留空表示不限。",
|
"The mail server refuses to create more than a limit allows. An empty field is no limit.": "邮件服务器会拒绝创建超出限额的内容。留空表示不限。",
|
||||||
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "此租户中任何人可被允许的上限:各自的角色会被缩减到这些角色授予的范围。只提供您自己拥有其权限的角色。",
|
"The most anyone in this tenant can be allowed: their own roles are cut down to what these grant. Only roles whose permissions you hold yourself are offered.": "此租户中任何人可被允许的上限:各自的角色会被缩减到这些角色授予的范围。只提供您自己拥有其权限的角色。",
|
||||||
"Checking what is still in this tenant…": "正在检查此租户中还有什么…",
|
"Checking what is still in this tenant…": "正在检查此租户中还有什么…",
|
||||||
"It still holds accounts, domains or other things. Move them out first.": "它仍包含账户、域名或其他内容。请先将其移出。",
|
"It still holds accounts, domains or other things. Move them out first.": "它仍包含账户、域名或其他内容。请先将其移出。",
|
||||||
@@ -280,7 +280,6 @@ export const catalog: Catalog = {
|
|||||||
"Still holds {things}. Move them out first.": "仍包含 {things}。请先将其移出。",
|
"Still holds {things}. Move them out first.": "仍包含 {things}。请先将其移出。",
|
||||||
"Delete tenant": "删除租户",
|
"Delete tenant": "删除租户",
|
||||||
"Separate organizations on one server, each with its own people, domains and limits.": "同一服务器上相互独立的组织,各有自己的成员、域名和限额。",
|
"Separate organizations on one server, each with its own people, domains and limits.": "同一服务器上相互独立的组织,各有自己的成员、域名和限额。",
|
||||||
"Tenants are a Stalwart Enterprise feature.": "租户是 Stalwart Enterprise 的功能。",
|
|
||||||
"Search tenants": "搜索租户",
|
"Search tenants": "搜索租户",
|
||||||
"No tenants match": "没有匹配的租户",
|
"No tenants match": "没有匹配的租户",
|
||||||
"No tenants yet": "还没有租户",
|
"No tenants yet": "还没有租户",
|
||||||
@@ -508,7 +507,7 @@ export const catalog: Catalog = {
|
|||||||
"Waiting on the server — goes out {when}.": "正在服务器上等待,将于 {when} 发出。",
|
"Waiting on the server — goes out {when}.": "正在服务器上等待,将于 {when} 发出。",
|
||||||
"Scheduled — click to clear the schedule": "已定时,点击可取消定时",
|
"Scheduled — click to clear the schedule": "已定时,点击可取消定时",
|
||||||
"Nothing scheduled": "没有定时邮件",
|
"Nothing scheduled": "没有定时邮件",
|
||||||
"The message waits on the server, so it goes out whether or not ihasmail is open.": "邮件在服务器上等待,无论 ihasmail 是否打开都会发出。",
|
"The message waits on the server, so it goes out whether or not {app} is open.": "邮件在服务器上等待,无论 {app} 是否打开都会发出。",
|
||||||
"This server holds a message for up to {span}.": "此服务器最多可将邮件保留 {span}。",
|
"This server holds a message for up to {span}.": "此服务器最多可将邮件保留 {span}。",
|
||||||
"Date and time to send": "发送日期和时间",
|
"Date and time to send": "发送日期和时间",
|
||||||
"Undo send window": "撤销发送时限",
|
"Undo send window": "撤销发送时限",
|
||||||
@@ -731,7 +730,7 @@ export const catalog: Catalog = {
|
|||||||
"Sections": "分区",
|
"Sections": "分区",
|
||||||
"General": "常规",
|
"General": "常规",
|
||||||
"Appearance": "外观",
|
"Appearance": "外观",
|
||||||
"Make ihasmail yours.": "把 ihasmail 调成您喜欢的样子。",
|
"Make {app} yours.": "\u628a {app} \u8c03\u6210\u60a8\u559c\u6b22\u7684\u6837\u5b50\u3002",
|
||||||
"Reading": "阅读",
|
"Reading": "阅读",
|
||||||
"Reading pane": "阅读窗格",
|
"Reading pane": "阅读窗格",
|
||||||
"Right of the list": "列表右侧",
|
"Right of the list": "列表右侧",
|
||||||
@@ -748,6 +747,7 @@ export const catalog: Catalog = {
|
|||||||
"Attachment reminder": "附件提醒",
|
"Attachment reminder": "附件提醒",
|
||||||
"Warn when the message mentions an attachment but none is attached.": "邮件提到附件但未添加时提醒。",
|
"Warn when the message mentions an attachment but none is attached.": "邮件提到附件但未添加时提醒。",
|
||||||
"Spell check while typing": "输入时检查拼写",
|
"Spell check while typing": "输入时检查拼写",
|
||||||
|
"Open the composer full screen": "全屏写邮件",
|
||||||
"Confirm before deleting": "删除前确认",
|
"Confirm before deleting": "删除前确认",
|
||||||
"Show message snippets": "显示邮件摘要",
|
"Show message snippets": "显示邮件摘要",
|
||||||
"Preview the first line of each message in the list.": "在列表中显示每封邮件的首行。",
|
"Preview the first line of each message in the list.": "在列表中显示每封邮件的首行。",
|
||||||
@@ -827,7 +827,8 @@ export const catalog: Catalog = {
|
|||||||
"Reset to defaults": "恢复默认设置",
|
"Reset to defaults": "恢复默认设置",
|
||||||
"Default mail app": "默认邮件应用",
|
"Default mail app": "默认邮件应用",
|
||||||
"Documentation": "文档",
|
"Documentation": "文档",
|
||||||
"About ihasmail": "关于 ihasmail",
|
"About {app}": "关于 {app}",
|
||||||
|
"Built on {project}": "基于 {project} 构建",
|
||||||
"About": "关于",
|
"About": "关于",
|
||||||
"Server": "服务器",
|
"Server": "服务器",
|
||||||
"Server capabilities": "服务器功能",
|
"Server capabilities": "服务器功能",
|
||||||
@@ -928,6 +929,7 @@ export const catalog: Catalog = {
|
|||||||
"Turn off two-factor authentication": "关闭两步验证",
|
"Turn off two-factor authentication": "关闭两步验证",
|
||||||
"Your password alone will be enough to sign in again.": "之后仅凭密码即可重新登录。",
|
"Your password alone will be enough to sign in again.": "之后仅凭密码即可重新登录。",
|
||||||
"App passwords": "应用专用密码",
|
"App passwords": "应用专用密码",
|
||||||
|
"Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.": "您的组织只允许使用 {app} 和 JMAP 应用,因此手机和电脑上的邮件应用无法连接到此账户。",
|
||||||
"New app password for": "新建应用专用密码,用于",
|
"New app password for": "新建应用专用密码,用于",
|
||||||
"Your new app password": "您的新应用专用密码",
|
"Your new app password": "您的新应用专用密码",
|
||||||
"Secret": "密钥",
|
"Secret": "密钥",
|
||||||
@@ -956,8 +958,8 @@ export const catalog: Catalog = {
|
|||||||
"Notifications": "通知",
|
"Notifications": "通知",
|
||||||
"Notifications are blocked in your browser settings.": "浏览器设置中已阻止通知。",
|
"Notifications are blocked in your browser settings.": "浏览器设置中已阻止通知。",
|
||||||
"Not supported in this browser.": "此浏览器不支持。",
|
"Not supported in this browser.": "此浏览器不支持。",
|
||||||
"Desktop notifications while ihasmail is open": "打开 ihasmail 时显示桌面通知",
|
"Desktop notifications while {app} is open": "打开 {app} 时显示桌面通知",
|
||||||
"Notify me even when ihasmail is closed": "关闭 ihasmail 后也通知我",
|
"Notify me even when {app} is closed": "关闭 {app} 后也通知我",
|
||||||
"Play a sound for new mail": "新邮件提示音",
|
"Play a sound for new mail": "新邮件提示音",
|
||||||
"Test notification": "测试通知",
|
"Test notification": "测试通知",
|
||||||
"Background notifications are on": "后台通知已开启",
|
"Background notifications are on": "后台通知已开启",
|
||||||
@@ -1071,7 +1073,7 @@ export const catalog: Catalog = {
|
|||||||
// ── Settings prose ─────────────────────────────────────────────────
|
// ── Settings prose ─────────────────────────────────────────────────
|
||||||
"tell us about it": "告诉我们",
|
"tell us about it": "告诉我们",
|
||||||
"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}.": "本翻译由 AI 生成,尚未经母语者校对,因此在有人校对签核之前会一直标记为 Beta。任何读起来不对的地方都值得反馈——{report}。",
|
"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}.": "本翻译由 AI 生成,尚未经母语者校对,因此在有人校对签核之前会一直标记为 Beta。任何读起来不对的地方都值得反馈——{report}。",
|
||||||
"Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "这里只列出 ihasmail 已经翻译过的语言,因此列表会随着译文落地而增加,而不会提前出现——提供一种背后没有译文的语言,只会让页面声称自己使用着一种它并未使用的语言。",
|
"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.": "\u8fd9\u91cc\u53ea\u5217\u51fa {app} \u5df2\u7ecf\u7ffb\u8bd1\u8fc7\u7684\u8bed\u8a00\uff0c\u56e0\u6b64\u5217\u8868\u4f1a\u968f\u7740\u8bd1\u6587\u843d\u5730\u800c\u589e\u52a0\uff0c\u800c\u4e0d\u4f1a\u63d0\u524d\u51fa\u73b0\u2014\u2014\u63d0\u4f9b\u4e00\u79cd\u80cc\u540e\u6ca1\u6709\u8bd1\u6587\u7684\u8bed\u8a00\uff0c\u53ea\u4f1a\u8ba9\u9875\u9762\u58f0\u79f0\u81ea\u5df1\u4f7f\u7528\u7740\u4e00\u79cd\u5b83\u5e76\u672a\u4f7f\u7528\u7684\u8bed\u8a00\u3002",
|
||||||
"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.": "这与「常规」中的{setting}是两回事,后者决定日期、时间和数字的写法。您可以用英文界面配德式日期,反过来也可以。",
|
"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.": "这与「常规」中的{setting}是两回事,后者决定日期、时间和数字的写法。您可以用英文界面配德式日期,反过来也可以。",
|
||||||
"Defaults for the calendar views and new events.": "日历视图和新建日程的默认设置。",
|
"Defaults for the calendar views and new events.": "日历视图和新建日程的默认设置。",
|
||||||
"Replies will go to this address instead of the From address": "回复将发往此地址,而不是发件人地址",
|
"Replies will go to this address instead of the From address": "回复将发往此地址,而不是发件人地址",
|
||||||
@@ -1079,31 +1081,31 @@ export const catalog: Catalog = {
|
|||||||
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "新建发件身份必须使用此账户获准发信的地址(在服务器上配置的别名)。",
|
"New identities must use an address this account is allowed to send from (aliases configured on the server).": "新建发件身份必须使用此账户获准发信的地址(在服务器上配置的别名)。",
|
||||||
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "写邮件时不再提供此身份。它仍会接收邮件,重新显示后也仍可用于发信。",
|
"Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "写邮件时不再提供此身份。它仍会接收邮件,重新显示后也仍可用于发信。",
|
||||||
"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.": "每个发件身份都是一个发件地址,拥有各自的名称、回复地址和签名。写邮件时会预先选中默认身份;若希望回复发往发件人地址以外的地方,请设置回复地址。",
|
"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.": "每个发件身份都是一个发件地址,拥有各自的名称、回复地址和签名。写邮件时会预先选中默认身份;若希望回复发往发件人地址以外的地方,请设置回复地址。",
|
||||||
"This signature is larger than the server's {limit}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.": "此签名超出了服务器 {limit} 字节的限制。ihasmail 会把完整版本保存在您的「文件」中,并在服务器上存放一段简短的文本备用版——其他邮件客户端看到的将是纯文本版本。",
|
"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.": "\u6b64\u7b7e\u540d\u8d85\u51fa\u4e86\u670d\u52a1\u5668 {limit} \u5b57\u8282\u7684\u9650\u5236\u3002{app} \u4f1a\u628a\u5b8c\u6574\u7248\u672c\u4fdd\u5b58\u5728\u60a8\u7684\u300c\u6587\u4ef6\u300d\u4e2d\uff0c\u5e76\u5728\u670d\u52a1\u5668\u4e0a\u5b58\u653e\u4e00\u6bb5\u7b80\u77ed\u7684\u6587\u672c\u5907\u7528\u7248\u2014\u2014\u5176\u4ed6\u90ae\u4ef6\u5ba2\u6237\u7aef\u770b\u5230\u7684\u5c06\u662f\u7eaf\u6587\u672c\u7248\u672c\u3002",
|
||||||
"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.": "Outlook 风格的分类,可通过右键菜单或日程编辑器指定给日程。分类名称保存在日程上,因此会同步到其他客户端。",
|
"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.": "Outlook 风格的分类,可通过右键菜单或日程编辑器指定给日程。分类名称保存在日程上,因此会同步到其他客户端。",
|
||||||
"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.": "纯文本邮件本就会跟随主题。开启后,未自带配色的 HTML 邮件也会跟随主题,而不再显示在白色卡片上。自带样式的邮件则完全保持发件人设计的样子。",
|
"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.": "纯文本邮件本就会跟随主题。开启后,未自带配色的 HTML 邮件也会跟随主题,而不再显示在白色卡片上。自带样式的邮件则完全保持发件人设计的样子。",
|
||||||
"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.": "在触摸屏上,横向拖动邮件即可对其操作。每个方向可以执行一项操作,也可以什么都不做。这些设置跟随您的账户,因此手机和平板保持一致;鼠标不受影响,仍然是把邮件拖入文件夹。",
|
"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.": "在触摸屏上,横向拖动邮件即可对其操作。每个方向可以执行一项操作,也可以什么都不做。这些设置跟随您的账户,因此手机和平板保持一致;鼠标不受影响,仍然是把邮件拖入文件夹。",
|
||||||
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "此屏幕没有触摸屏,因此这里的设置不会改变它的行为。您的手机或平板会应用这些设置。",
|
"This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "此屏幕没有触摸屏,因此这里的设置不会改变它的行为。您的手机或平板会应用这些设置。",
|
||||||
"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.": "长按邮件可选中它,长按文件夹可打开其菜单。下拉邮件列表顶部即可检查新邮件。",
|
"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.": "长按邮件可选中它,长按文件夹可打开其菜单。下拉邮件列表顶部即可检查新邮件。",
|
||||||
"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.": "回执会告诉请求方这个地址确实有人在用,以及邮件是何时被读的,而回执发往何处由发件人指定——因此这里没有自动发送的选项。群发邮件、邮件列表以及任何标记为自动提交的邮件,一律不提供发送回执的选项。",
|
"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.": "回执会告诉请求方这个地址确实有人在用,以及邮件是何时被读的,而回执发往何处由发件人指定——因此这里没有自动发送的选项。群发邮件、邮件列表以及任何标记为自动提交的邮件,一律不提供发送回执的选项。",
|
||||||
"This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.": "此浏览器无法为 {scheme} 链接注册应用。Safari 尤其没有相应的接口——如果您把 ihasmail 安装为应用,仍可在操作系统中将它设为默认。",
|
"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.": "\u6b64\u6d4f\u89c8\u5668\u65e0\u6cd5\u4e3a {scheme} \u94fe\u63a5\u6ce8\u518c\u5e94\u7528\u3002Safari \u5c24\u5176\u6ca1\u6709\u76f8\u5e94\u7684\u63a5\u53e3\u2014\u2014\u5982\u679c\u60a8\u628a {app} \u5b89\u88c5\u4e3a\u5e94\u7528\uff0c\u4ecd\u53ef\u5728\u64cd\u4f5c\u7cfb\u7edf\u4e2d\u5c06\u5b83\u8bbe\u4e3a\u9ed8\u8ba4\u3002",
|
||||||
"Registering for {scheme} links requires a secure (HTTPS) connection.": "注册 {scheme} 链接需要安全连接(HTTPS)。",
|
"Registering for {scheme} links requires a secure (HTTPS) connection.": "注册 {scheme} 链接需要安全连接(HTTPS)。",
|
||||||
"Open {scheme} links — in web pages, documents and other apps — in ihasmail 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 › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).": "让网页、文档和其他应用中的 {scheme} 链接在 ihasmail 中打开,而不是桌面邮件客户端。浏览器会请您确认,之后也可以在浏览器自身的设置中更改(Chrome:设置 › 隐私和安全 › 网站设置 › 协议处理程序;Firefox:设置 › 常规 › 应用程序)。",
|
"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).": "\u8ba9\u7f51\u9875\u3001\u6587\u6863\u548c\u5176\u4ed6\u5e94\u7528\u4e2d\u7684 {scheme} \u94fe\u63a5\u5728 {app} \u4e2d\u6253\u5f00\uff0c\u800c\u4e0d\u662f\u684c\u9762\u90ae\u4ef6\u5ba2\u6237\u7aef\u3002\u6d4f\u89c8\u5668\u4f1a\u8bf7\u60a8\u786e\u8ba4\uff0c\u4e4b\u540e\u4e5f\u53ef\u4ee5\u5728\u6d4f\u89c8\u5668\u81ea\u8eab\u7684\u8bbe\u7f6e\u4e2d\u66f4\u6539\uff08Chrome\uff1a\u8bbe\u7f6e \u203a \u9690\u79c1\u548c\u5b89\u5168 \u203a \u7f51\u7ad9\u8bbe\u7f6e \u203a \u534f\u8bae\u5904\u7406\u7a0b\u5e8f\uff1bFirefox\uff1a\u8bbe\u7f6e \u203a \u5e38\u89c4 \u203a \u5e94\u7528\u7a0b\u5e8f\uff09\u3002",
|
||||||
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "已在此浏览器中提出请求。是否生效由浏览器决定——如果邮件链接仍在别处打开,请检查浏览器的设置。",
|
"Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "已在此浏览器中提出请求。是否生效由浏览器决定——如果邮件链接仍在别处打开,请检查浏览器的设置。",
|
||||||
"For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.": "若要设为系统级默认,请先把 ihasmail 安装为应用(在 Chrome 中:地址栏里的安装图标)。之后操作系统在询问使用哪个邮件应用时,就会直接提供 ihasmail。",
|
"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.": "若要设为系统级默认,请先把 {app} 安装为应用(在 Chrome 中:地址栏里的安装图标)。之后操作系统在询问使用哪个邮件应用时,就会直接提供 {app}。",
|
||||||
"Needs a browser with the Push API and a mail server that publishes a push key.": "需要支持 Push API 的浏览器,以及发布了推送密钥的邮件服务器。",
|
"Needs a browser with the Push API and a mail server that publishes a push key.": "需要支持 Push API 的浏览器,以及发布了推送密钥的邮件服务器。",
|
||||||
"Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "您的邮件服务器会把通知直接送到浏览器,因此不必打开 ihasmail 标签页也能收到,并会显示发件人和主题。但浏览器仍需保持运行——如果完全退出浏览器,通知会等到您再次打开时送达。",
|
"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.": "\u60a8\u7684\u90ae\u4ef6\u670d\u52a1\u5668\u4f1a\u628a\u901a\u77e5\u76f4\u63a5\u9001\u5230\u6d4f\u89c8\u5668\uff0c\u56e0\u6b64\u4e0d\u5fc5\u6253\u5f00 {app} \u6807\u7b7e\u9875\u4e5f\u80fd\u6536\u5230\uff0c\u5e76\u4f1a\u663e\u793a\u53d1\u4ef6\u4eba\u548c\u4e3b\u9898\u3002\u4f46\u6d4f\u89c8\u5668\u4ecd\u9700\u4fdd\u6301\u8fd0\u884c\u2014\u2014\u5982\u679c\u5b8c\u5168\u9000\u51fa\u6d4f\u89c8\u5668\uff0c\u901a\u77e5\u4f1a\u7b49\u5230\u60a8\u518d\u6b21\u6253\u5f00\u65f6\u9001\u8fbe\u3002",
|
||||||
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "您的邮件服务器可以唤醒此浏览器,但不会包含发件人或主题。浏览器仍需保持运行。",
|
"Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "您的邮件服务器可以唤醒此浏览器,但不会包含发件人或主题。浏览器仍需保持运行。",
|
||||||
"This is what a new-mail notification looks like.": "新邮件通知就是这个样子。",
|
"This is what a new-mail notification looks like.": "新邮件通知就是这个样子。",
|
||||||
"You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "您当前以 {user} 登录。您的密码从不保存在浏览器中;服务器会按会话加密保存,用于与 Stalwart 通信。",
|
"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.": "您已以 {user} 身份登录。您的密码从不保存在浏览器中;服务器会按会话加密保存它,用于与邮件服务器通信。",
|
||||||
"App passwords are managed by your mail administrator.": "应用专用密码由您的邮件管理员管理。",
|
"App passwords are managed by your mail administrator.": "应用专用密码由您的邮件管理员管理。",
|
||||||
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "更改密码会让您的其他网页邮箱会话退出登录。已有的应用专用密码仍可继续使用。",
|
"Changing your password signs out your other webmail sessions. Any app passwords keep working.": "更改密码会让您的其他网页邮箱会话退出登录。已有的应用专用密码仍可继续使用。",
|
||||||
"This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "此账户已开启两步验证。ihasmail 目前还不能通过验证码登录,因此在其他设备上登录需要使用应用专用密码——您也可以在这里关闭两步验证。",
|
"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.": "\u6b64\u8d26\u6237\u5df2\u5f00\u542f\u4e24\u6b65\u9a8c\u8bc1\u3002{app} \u76ee\u524d\u8fd8\u4e0d\u80fd\u901a\u8fc7\u9a8c\u8bc1\u7801\u767b\u5f55\uff0c\u56e0\u6b64\u5728\u5176\u4ed6\u8bbe\u5907\u4e0a\u767b\u5f55\u9700\u8981\u4f7f\u7528\u5e94\u7528\u4e13\u7528\u5bc6\u7801\u2014\u2014\u60a8\u4e5f\u53ef\u4ee5\u5728\u8fd9\u91cc\u5173\u95ed\u4e24\u6b65\u9a8c\u8bc1\u3002",
|
||||||
"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.": "为某个邮件应用或设备单独设置的密码,可以单独吊销。应用专用密码会跳过两步验证码,因此在无法输入验证码的应用中仍然可用。",
|
"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.": "为某个邮件应用或设备单独设置的密码,可以单独吊销。应用专用密码会跳过两步验证码,因此在无法输入验证码的应用中仍然可用。",
|
||||||
"Copy it into {name} now — it isn't shown again.": "请立即把它复制到 {name}——它不会再次显示。",
|
"Copy it into {name} now — it isn't shown again.": "请立即把它复制到 {name}——它不会再次显示。",
|
||||||
"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.": "目录中没有找到其他用户,因此无法添加新的共享对象。已有的共享列在下方,仍可移除。",
|
"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.": "目录中没有找到其他用户,因此无法添加新的共享对象。已有的共享列在下方,仍可移除。",
|
||||||
"Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart 不会向邮件客户端公布版本号,因此只有在服务器给出版本类型时,ihasmail 才会报告它。ihasmail 需要 0.16 或更高版本,更旧的版本一律无法登录。",
|
"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 \u4e0d\u4f1a\u5411\u90ae\u4ef6\u5ba2\u6237\u7aef\u516c\u5e03\u7248\u672c\u53f7\uff0c\u56e0\u6b64\u53ea\u6709\u5728\u670d\u52a1\u5668\u7ed9\u51fa\u7248\u672c\u7c7b\u578b\u65f6\uff0c{app} \u624d\u4f1a\u62a5\u544a\u5b83\u3002{app} \u9700\u8981 0.16 \u6216\u66f4\u9ad8\u7248\u672c\uff0c\u66f4\u65e7\u7684\u7248\u672c\u4e00\u5f8b\u65e0\u6cd5\u767b\u5f55\u3002",
|
||||||
"ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "ihasmail 自身的版本号是其构建所用提交的日期,后面跟着该提交的来源:{example} 表示由 2026 年 8 月 30 日的一个提交构建而成,而该提交来自第 129 号拉取请求。未经拉取请求的提交则改用简短 SHA 表示——{sha}。版本号刻意不包含任何关于 Stalwart 的信息;此版本对服务器的要求见上一行。",
|
"{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.": "{app} \u81ea\u8eab\u7684\u7248\u672c\u53f7\u662f\u5176\u6784\u5efa\u6240\u7528\u63d0\u4ea4\u7684\u65e5\u671f\uff0c\u540e\u9762\u8ddf\u7740\u8be5\u63d0\u4ea4\u7684\u6765\u6e90\uff1a{example} \u8868\u793a\u7531 2026 \u5e74 8 \u6708 30 \u65e5\u7684\u4e00\u4e2a\u63d0\u4ea4\u6784\u5efa\u800c\u6210\uff0c\u800c\u8be5\u63d0\u4ea4\u6765\u81ea\u7b2c 129 \u53f7\u62c9\u53d6\u8bf7\u6c42\u3002\u672a\u7ecf\u62c9\u53d6\u8bf7\u6c42\u7684\u63d0\u4ea4\u5219\u6539\u7528\u7b80\u77ed SHA \u8868\u793a\u2014\u2014{sha}\u3002\u7248\u672c\u53f7\u523b\u610f\u4e0d\u5305\u542b\u4efb\u4f55\u5173\u4e8e Stalwart \u7684\u4fe1\u606f\uff1b\u6b64\u7248\u672c\u5bf9\u670d\u52a1\u5668\u7684\u8981\u6c42\u89c1\u4e0a\u4e00\u884c\u3002",
|
||||||
|
|
||||||
// ── Constant labels ────────────────────────────────────────────────
|
// ── Constant labels ────────────────────────────────────────────────
|
||||||
"Add": "添加",
|
"Add": "添加",
|
||||||
@@ -1171,10 +1173,10 @@ export const catalog: Catalog = {
|
|||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "新邮件",
|
"New message": "新邮件",
|
||||||
"Start a new message with what was shared?": "用共享的内容新建邮件吗?",
|
"Start a new message with what was shared?": "用共享的内容新建邮件吗?",
|
||||||
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "有内容被共享到 ihasmail。在您选择“发送”之前不会发送任何内容。如果不是您刚才共享的,请放弃。",
|
"Something was shared with {app}. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "有内容被共享到 {app}。在您选择“发送”之前不会发送任何内容。如果不是您刚才共享的,请放弃。",
|
||||||
"Start a message": "新建邮件",
|
"Start a message": "新建邮件",
|
||||||
"New mail": "新邮件",
|
"New mail": "新邮件",
|
||||||
"Could not do that — open ihasmail and try again": "无法执行 — 请打开 ihasmail 后重试",
|
"Could not do that \u2014 open {app} and try again": "\u65e0\u6cd5\u6267\u884c \u2014 \u8bf7\u6253\u5f00 {app} \u540e\u91cd\u8bd5",
|
||||||
"Sending…": "正在发送…",
|
"Sending…": "正在发送…",
|
||||||
"Saving…": "正在保存…",
|
"Saving…": "正在保存…",
|
||||||
"Error": "错误",
|
"Error": "错误",
|
||||||
@@ -1222,6 +1224,20 @@ export const catalog: Catalog = {
|
|||||||
"Could not save filters: {error}": "无法保存过滤器:{error}",
|
"Could not save filters: {error}": "无法保存过滤器:{error}",
|
||||||
"Could not send the receipt: {error}": "无法发送已读回执:{error}",
|
"Could not send the receipt: {error}": "无法发送已读回执:{error}",
|
||||||
"Could not sign in.": "无法登录。",
|
"Could not sign in.": "无法登录。",
|
||||||
|
"You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.": "您已以 {user} 身份登录。此网页邮箱从不接触您的密码:它保存的是来自邮件服务器的登录令牌,并按会话加密。",
|
||||||
|
"Mail server": "邮件服务器",
|
||||||
|
"You'll enter your password on your mail server's sign-in page.": "您将在邮件服务器的登录页面上输入密码。",
|
||||||
|
"You'll sign in on your mail server's own page.": "您将在邮件服务器自己的页面上登录。",
|
||||||
|
"This sign-in didn't start in this browser. Try again.": "此次登录不是在这个浏览器中发起的。请重试。",
|
||||||
|
"The sign-in took too long. Try again.": "登录耗时过长。请重试。",
|
||||||
|
"Sign-in was cancelled.": "登录已取消。",
|
||||||
|
"The mail server didn't accept the sign-in. Try again.": "邮件服务器未接受此次登录。请重试。",
|
||||||
|
"That account is on a different mail server than the address you entered. Sign in with that address.": "该账户所在的邮件服务器与您输入的地址不同。请使用该地址登录。",
|
||||||
|
"This mail server isn't supported.": "不支持此邮件服务器。",
|
||||||
|
"Couldn't reach the mail server. Try again in a moment.": "无法连接邮件服务器。请稍后重试。",
|
||||||
|
"Your password was changed. Sign in with the new one.": "您的密码已更改。请使用新密码登录。",
|
||||||
|
"Your sign-in ended. Sign in again.": "您的登录已结束。请重新登录。",
|
||||||
|
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "更改密码会让您在所有地方退出登录(包括这里),然后需用新密码重新登录。已有的应用专用密码仍可继续使用。",
|
||||||
"Could not store image: {error}": "无法保存图片:{error}",
|
"Could not store image: {error}": "无法保存图片:{error}",
|
||||||
"Could not update labels: {error}": "无法更新标签:{error}",
|
"Could not update labels: {error}": "无法更新标签:{error}",
|
||||||
"Could not update the Scheduled folder: {error}": "无法更新「定时发送」文件夹:{error}",
|
"Could not update the Scheduled folder: {error}": "无法更新「定时发送」文件夹:{error}",
|
||||||
@@ -1344,7 +1360,7 @@ export const catalog: Catalog = {
|
|||||||
"Undo window: {seconds}s": "撤销时限:{seconds} 秒",
|
"Undo window: {seconds}s": "撤销时限:{seconds} 秒",
|
||||||
"You're all caught up": "您已看完全部邮件",
|
"You're all caught up": "您已看完全部邮件",
|
||||||
"Your browser refused the request: {error}": "您的浏览器拒绝了该请求:{error}",
|
"Your browser refused the request: {error}": "您的浏览器拒绝了该请求:{error}",
|
||||||
"Your browser will ask whether to open mail links in ihasmail": "浏览器会询问是否用 ihasmail 打开邮件链接",
|
"Your browser will ask whether to open mail links in {app}": "浏览器会询问是否用 {app} 打开邮件链接",
|
||||||
"Your message mentions an attachment, but nothing is attached.": "您的邮件提到了附件,但没有添加任何附件。",
|
"Your message mentions an attachment, but nothing is attached.": "您的邮件提到了附件,但没有添加任何附件。",
|
||||||
"event": "日程",
|
"event": "日程",
|
||||||
"Hide password": "隐藏密码",
|
"Hide password": "隐藏密码",
|
||||||
@@ -1415,7 +1431,7 @@ export const catalog: Catalog = {
|
|||||||
"Also count these domains as inside": "也将这些域名视为内部",
|
"Also count these domains as inside": "也将这些域名视为内部",
|
||||||
"Always": "始终",
|
"Always": "始终",
|
||||||
"Always showing images from": "始终显示以下发件人的图片",
|
"Always showing images from": "始终显示以下发件人的图片",
|
||||||
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.": "从发件人服务器加载的图片会告诉对方邮件已被打开、打开时间以及大致位置。已允许的图片由 ihasmail 自己的服务器抓取,而非浏览器,因此发件人无从得知这些信息。",
|
"An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by {app}'s own server rather than the browser, so the sender learns none of those.": "从发件人服务器加载的图片会告诉对方邮件已被打开、打开时间以及大致位置。已允许的图片由 {app} 自己的服务器抓取,而非浏览器,因此发件人无从得知这些信息。",
|
||||||
"Applies to": "适用于",
|
"Applies to": "适用于",
|
||||||
"Archive and next": "归档并转到下一封",
|
"Archive and next": "归档并转到下一封",
|
||||||
"Archive by month": "按月归档",
|
"Archive by month": "按月归档",
|
||||||
@@ -1657,8 +1673,8 @@ export const catalog: Catalog = {
|
|||||||
"Fingerprint": "指纹",
|
"Fingerprint": "指纹",
|
||||||
"Hide details": "隐藏详情",
|
"Hide details": "隐藏详情",
|
||||||
"Issued by": "颁发者",
|
"Issued by": "颁发者",
|
||||||
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "该邮件使用 OpenPGP 签名,而 ihasmail 无法获取发件人的公钥。",
|
"It is signed with OpenPGP, and {app} has no way to fetch the sender's public key.": "该邮件使用 OpenPGP 签名,而 {app} 无法获取发件人的公钥。",
|
||||||
"It uses a signature algorithm ihasmail cannot check yet.": "它使用了 ihasmail 尚不能校验的签名算法。",
|
"It uses a signature algorithm {app} cannot check yet.": "它使用了 {app} 尚不能校验的签名算法。",
|
||||||
"It was made with a certificate belonging to {name}, which does not cover this address.": "签名使用的是 {name} 的证书,该证书并不包含此地址。",
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "签名使用的是 {name} 的证书,该证书并不包含此地址。",
|
||||||
"Previous fingerprint": "以前的指纹",
|
"Previous fingerprint": "以前的指纹",
|
||||||
"Signed at": "签名时间",
|
"Signed at": "签名时间",
|
||||||
@@ -1675,18 +1691,39 @@ export const catalog: Catalog = {
|
|||||||
"The signature is not for this sender.": "该签名不属于此发件人。",
|
"The signature is not for this sender.": "该签名不属于此发件人。",
|
||||||
"The signed part is missing either the message or the signature.": "签名部分缺少邮件正文或签名。",
|
"The signed part is missing either the message or the signature.": "签名部分缺少邮件正文或签名。",
|
||||||
"The signer has changed.": "签名者已更换。",
|
"The signer has changed.": "签名者已更换。",
|
||||||
"This message is signed, and ihasmail could not check the signature.": "此邮件带有签名,但 ihasmail 无法校验该签名。",
|
"This message is signed, and {app} could not check the signature.": "此邮件带有签名,但 {app} 无法校验该签名。",
|
||||||
"This signature does not check out.": "此签名不成立。",
|
"This signature does not check out.": "此签名不成立。",
|
||||||
"Valid until": "有效期至",
|
"Valid until": "有效期至",
|
||||||
"a different certificate": "另一份证书",
|
"a different certificate": "另一份证书",
|
||||||
"an unnamed signer": "未具名的签名者",
|
"an unnamed signer": "未具名的签名者",
|
||||||
"as claimed by the signer": "据签名者声称",
|
"as claimed by the signer": "据签名者声称",
|
||||||
"first seen {date}": "首次见于 {date}",
|
"first seen {date}": "首次见于 {date}",
|
||||||
"ihasmail will tell you if a later message from this address is signed by anybody else.": "如果此地址之后的邮件由他人签名,ihasmail 会提醒您。",
|
"{app} will tell you if a later message from this address is signed by anybody else.": "如果此地址之后的邮件由他人签名,{app} 会提醒您。",
|
||||||
"itself, or an issuer it does not name": "其自身,或一个未具名的颁发者",
|
"itself, or an issuer it does not name": "其自身,或一个未具名的颁发者",
|
||||||
"no address": "无地址",
|
"no address": "无地址",
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"Legacy mail apps": "使用传统协议的邮件应用",
|
||||||
|
"Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.": "{tenant} 已关闭。只有 {app} 和 JMAP 应用可以登录其域名。",
|
||||||
|
"On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.": "{tenant} 已开启。邮件应用可以在其域名上使用 IMAP、POP3 和 ManageSieve。",
|
||||||
|
"Turn legacy protocols back on": "重新开启传统邮件协议",
|
||||||
|
"Turn off legacy protocols…": "关闭传统邮件协议…",
|
||||||
|
"No account used a legacy mail app in the last 30 days.": "过去 30 天内没有账户使用过传统协议的邮件应用。",
|
||||||
|
"Their mail apps will stop working the moment you turn this on:": "您开启此项后,这些账户的邮件应用将立即停止工作:",
|
||||||
|
"Only {app} and JMAP apps will work.": "只有 {app} 和 JMAP 应用可以使用。",
|
||||||
|
"Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {tenant}.": "{tenant} 中所有人的传统邮件协议(IMAP、POP3、ManageSieve 以及从邮件应用发信)都将被关闭。",
|
||||||
|
"Phone and desktop mail apps will stop receiving and sending mail. That's iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.": "手机和电脑上的邮件应用将无法收发邮件,包括 iPhone 和 iPad 上的邮件、Gmail 和 Outlook 应用、Outlook、Thunderbird 以及 Apple Mail。用户会在这些应用中看到登录错误。",
|
||||||
|
"Filters managed from a mail app (ManageSieve) will stop working. Filters set in {app} keep working.": "通过邮件应用管理的过滤器(ManageSieve)将停止工作。在 {app} 中设置的过滤器会继续工作。",
|
||||||
|
"Incoming mail is not affected. Calendars and contacts are not affected.": "收件不受影响。日历和联系人不受影响。",
|
||||||
|
"People keep full access through {app}, which can be installed as an app on phones and computers.": "所有人仍可通过 {app} 完整访问,{app} 可以作为应用安装在手机和电脑上。",
|
||||||
|
"Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and {app} (JMAP) are not affected and cannot be turned off here.": "从邮件应用发信(SMTP submission)将停止工作,但其端口保持开放:邮件应用会被告知无法登录。收件(SMTP)和 {app}(JMAP)不受影响,也无法在此关闭。",
|
||||||
|
"You can turn legacy protocols back on at any time.": "您可以随时重新开启传统邮件协议。",
|
||||||
|
"To confirm, type {phrase}": "请输入 {phrase} 以确认",
|
||||||
|
"Turn off legacy protocols": "关闭传统邮件协议",
|
||||||
|
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "您的组织已关闭传统邮件协议。只有 {app} 和 JMAP 应用可以登录。",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── Administration: legacy mail protocols (INBUXA) ──────────────
|
||||||
|
"{n} accounts used a legacy mail app in the last 30 days.": {other: "过去 30 天内有 {n} 个账户使用过传统协议的邮件应用。"},
|
||||||
// ── Administration: domains ────────────────────────────────────
|
// ── Administration: domains ────────────────────────────────────
|
||||||
"{n} accounts use this domain. Move or delete them first.": { other: "有 {n} 个账户正在使用此域名。请先移动或删除它们。" },
|
"{n} accounts use this domain. Move or delete them first.": { other: "有 {n} 个账户正在使用此域名。请先移动或删除它们。" },
|
||||||
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { other: "服务器将不再接收此域名的邮件,其 {n} 个 DKIM 密钥也会被删除。此操作无法撤销。" },
|
"The server stops accepting mail for this domain, and its {n} DKIM keys are deleted. This can't be undone.": { other: "服务器将不再接收此域名的邮件,其 {n} 个 DKIM 密钥也会被删除。此操作无法撤销。" },
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { useCompose } from "@/store/compose";
|
||||||
|
import { useMail } from "@/store/mail";
|
||||||
|
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Open the composer full screen" (#401): the size a new composer starts at.
|
||||||
|
*
|
||||||
|
* Only new composers follow it. A draft put back after an undone or failed
|
||||||
|
* send keeps the size it had, since that is the window somebody was already
|
||||||
|
* looking at.
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||||
|
useMail.setState({ accountId: "a1", identities: [] as never });
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const opened = () => {
|
||||||
|
const key = useCompose.getState().open();
|
||||||
|
return useCompose.getState().drafts.find((d) => d.key === key)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("the size a new composer opens at", () => {
|
||||||
|
it("is the usual window by default", () => {
|
||||||
|
expect(opened().maximized).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is full screen with the setting on", () => {
|
||||||
|
useSettings.setState((s) => ({ settings: { ...s.settings, composeMaximized: true } }));
|
||||||
|
expect(opened().maximized).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can still be restored to a window once open", () => {
|
||||||
|
useSettings.setState((s) => ({ settings: { ...s.settings, composeMaximized: true } }));
|
||||||
|
const d = opened();
|
||||||
|
useCompose.getState().update(d.key, { maximized: false });
|
||||||
|
expect(useCompose.getState().drafts.find((x) => x.key === d.key)!.maximized).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -108,7 +108,7 @@ export function isOccurrence(event: CalendarEvent): boolean {
|
|||||||
* before it is sent — rejected properties throw, inherited ones are reported to
|
* before it is sent — rejected properties throw, inherited ones are reported to
|
||||||
* the caller — rather than being posted hopefully and believed.
|
* the caller — rather than being posted hopefully and believed.
|
||||||
*
|
*
|
||||||
* [#26]: https://github.com/Coffey-Labs/ihasmail/issues/26
|
* [#26]: https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/26
|
||||||
*/
|
*/
|
||||||
const OCCURRENCE_REJECTED = new Set([
|
const OCCURRENCE_REJECTED = new Set([
|
||||||
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
|
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ function blankDraft(init: Partial<Draft> = {}): Draft {
|
|||||||
showBcc: false,
|
showBcc: false,
|
||||||
showReplyTo: false,
|
showReplyTo: false,
|
||||||
minimized: false,
|
minimized: false,
|
||||||
maximized: false,
|
maximized: s.composeMaximized,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
savedAt: null,
|
savedAt: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { useMail } from "./mail";
|
|||||||
* `ContactCard/set` down they are the same" was always claiming and is now
|
* `ContactCard/set` down they are the same" was always claiming and is now
|
||||||
* true of.
|
* true of.
|
||||||
*
|
*
|
||||||
* [#173]: https://github.com/Coffey-Labs/ihasmail/issues/173
|
* [#173]: https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/173
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* The UIDs an address book already holds.
|
* The UIDs an address book already holds.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { plural, t } from "@/lib/i18n";
|
|||||||
import { withBase } from "@/lib/basePath";
|
import { withBase } from "@/lib/basePath";
|
||||||
import { isDeviceTrusted, loadRaw, saveJson } from "@/lib/storage";
|
import { isDeviceTrusted, loadRaw, saveJson } from "@/lib/storage";
|
||||||
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
|
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
|
||||||
|
import { compareFolders } from "@/lib/mailbox/folderOrder";
|
||||||
import { type ListQuery, type MailState } from "./types";
|
import { type ListQuery, type MailState } from "./types";
|
||||||
import { playNewMailSound, showNotification } from "@/lib/notify/notify";
|
import { playNewMailSound, showNotification } from "@/lib/notify/notify";
|
||||||
import { pushEnabledHere } from "@/lib/notify/webpush";
|
import { pushEnabledHere } from "@/lib/notify/webpush";
|
||||||
@@ -161,7 +162,7 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
childrenOf(parentId) {
|
childrenOf(parentId) {
|
||||||
return Object.values(get().mailboxes)
|
return Object.values(get().mailboxes)
|
||||||
.filter((m) => (m.parentId ?? null) === parentId)
|
.filter((m) => (m.parentId ?? null) === parentId)
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
.sort(compareFolders);
|
||||||
},
|
},
|
||||||
|
|
||||||
async query(q, opts = {}) {
|
async query(q, opts = {}) {
|
||||||
@@ -762,6 +763,20 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
if (before.length) await followFolders(before);
|
if (before.length) await followFolders(before);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async arrangeMailboxes(updates) {
|
||||||
|
const accountId = get().accountId!;
|
||||||
|
const moved = Object.keys(updates).filter((id) => updates[id]!.parentId !== undefined);
|
||||||
|
const before = moved.flatMap((id) => folderRefs(get(), id));
|
||||||
|
// One request for the whole level rather than one per folder. JMAP applies
|
||||||
|
// each update on its own, so a refusal can leave the level part-numbered;
|
||||||
|
// reloading shows whatever order the server actually kept.
|
||||||
|
const res = await client.call<SetResponse>("Mailbox/set", { accountId, update: updates });
|
||||||
|
const failed = Object.values(res.notUpdated ?? {})[0];
|
||||||
|
await get().loadMailboxes();
|
||||||
|
if (failed) throw new Error(setErrorMessage(failed));
|
||||||
|
if (before.length) await followFolders(before);
|
||||||
|
},
|
||||||
|
|
||||||
async destroyMailbox(id, removeEmails = true) {
|
async destroyMailbox(id, removeEmails = true) {
|
||||||
const accountId = get().accountId!;
|
const accountId = get().accountId!;
|
||||||
const before = folderRefs(get(), id);
|
const before = folderRefs(get(), id);
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export interface MailState {
|
|||||||
/** Give something the Archive role -- adopting a folder already named for it, or making one. */
|
/** Give something the Archive role -- adopting a folder already named for it, or making one. */
|
||||||
ensureArchiveFolder(): Promise<Id>;
|
ensureArchiveFolder(): Promise<Id>;
|
||||||
updateMailbox(id: Id, patch: Partial<Mailbox>): Promise<void>;
|
updateMailbox(id: Id, patch: Partial<Mailbox>): Promise<void>;
|
||||||
|
/** Several folders' `sortOrder` (and at most a new parent) in one request: a reorder from the tree. */
|
||||||
|
arrangeMailboxes(updates: Record<Id, Partial<Mailbox>>): Promise<void>;
|
||||||
destroyMailbox(id: Id, removeEmails?: boolean): Promise<void>;
|
destroyMailbox(id: Id, removeEmails?: boolean): Promise<void>;
|
||||||
|
|
||||||
loadIdentities(): Promise<Identity[]>;
|
loadIdentities(): Promise<Identity[]>;
|
||||||
|
|||||||
@@ -251,6 +251,11 @@ export interface Settings {
|
|||||||
archiveOnReply: boolean;
|
archiveOnReply: boolean;
|
||||||
autoAdvance: "newer" | "older" | "list";
|
autoAdvance: "newer" | "older" | "list";
|
||||||
spellcheck: boolean;
|
spellcheck: boolean;
|
||||||
|
/**
|
||||||
|
* Open every new composer full screen (#401). Desktop only: on a phone the
|
||||||
|
* composer fills the screen already and has no size to choose.
|
||||||
|
*/
|
||||||
|
composeMaximized: boolean;
|
||||||
sendAndArchive: boolean;
|
sendAndArchive: boolean;
|
||||||
/** Width (px) of the message list when the reading pane is on the right. */
|
/** Width (px) of the message list when the reading pane is on the right. */
|
||||||
listPaneWidth: number;
|
listPaneWidth: number;
|
||||||
@@ -376,6 +381,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
archiveOnReply: false,
|
archiveOnReply: false,
|
||||||
autoAdvance: "list",
|
autoAdvance: "list",
|
||||||
spellcheck: true,
|
spellcheck: true,
|
||||||
|
composeMaximized: false,
|
||||||
sendAndArchive: false,
|
sendAndArchive: false,
|
||||||
listPaneWidth: 520,
|
listPaneWidth: 520,
|
||||||
listPaneHeight: 340,
|
listPaneHeight: 340,
|
||||||
|
|||||||
@@ -1310,6 +1310,9 @@ a.menu-item:hover { color: var(--fg); }
|
|||||||
.nav-item.active.unread .nav-label, .nav-item.active.unread .nav-count { color: inherit; }
|
.nav-item.active.unread .nav-label, .nav-item.active.unread .nav-count { color: inherit; }
|
||||||
.nav-item.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; }
|
.nav-item.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; }
|
||||||
.nav-item.folder-row.dragging { opacity: .45; }
|
.nav-item.folder-row.dragging { opacity: .45; }
|
||||||
|
/* A folder dragged between two others: a line where it will land. */
|
||||||
|
.nav-item.folder-row.drop-before { box-shadow: inset 0 2px 0 var(--accent); }
|
||||||
|
.nav-item.folder-row.drop-after { box-shadow: inset 0 -2px 0 var(--accent); }
|
||||||
/* A folder color tints its icon; the label keeps the sidebar's contrast. */
|
/* A folder color tints its icon; the label keeps the sidebar's contrast. */
|
||||||
.folder-row .folder-icon { display: inline-flex; align-items: center; }
|
.folder-row .folder-icon { display: inline-flex; align-items: center; }
|
||||||
/* .nav-item svg sets color on the svg itself, so inheriting from the span is
|
/* .nav-item svg sets color on the svg itself, so inheriting from the span is
|
||||||
@@ -1723,6 +1726,12 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
|||||||
.admin-notice { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border-radius: var(--radius-sm); background: var(--bg-sunken); color: var(--fg-muted); font-size: .92em; margin: 8px 0; }
|
.admin-notice { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border-radius: var(--radius-sm); background: var(--bg-sunken); color: var(--fg-muted); font-size: .92em; margin: 8px 0; }
|
||||||
.admin-notice svg { flex: none; margin-top: 2px; }
|
.admin-notice svg { flex: none; margin-top: 2px; }
|
||||||
.admin-notice.warn { background: var(--warn-soft); color: var(--fg); }
|
.admin-notice.warn { background: var(--warn-soft); color: var(--fg); }
|
||||||
|
/* INBUXA: a tenant's legacy mail protocols switch (legacy-protocols LP-15 to LP-17) */
|
||||||
|
.admin-legacy-confirm { display: grid; gap: 12px; margin-top: 12px; }
|
||||||
|
.admin-legacy-box { padding: 10px 12px; border-radius: var(--radius-sm); background: var(--bg-sunken); font-size: .92em; }
|
||||||
|
.admin-legacy-box.warn { background: var(--warn-soft); color: var(--fg); }
|
||||||
|
.admin-legacy-box p { margin: 0 0 6px; }
|
||||||
|
.admin-legacy-box ul { margin: 6px 0; padding-left: 18px; }
|
||||||
.admin-notice.warn svg { color: var(--warn); }
|
.admin-notice.warn svg { color: var(--warn); }
|
||||||
.admin-notice.error { background: var(--danger-soft); color: var(--fg); }
|
.admin-notice.error { background: var(--danger-soft); color: var(--fg); }
|
||||||
.admin-sheet { position: absolute; top: 0; right: 0; bottom: 0; width: min(460px, 100%); z-index: 20; display: flex; flex-direction: column; background: var(--bg-elev); border-left: 1px solid var(--border); box-shadow: var(--shadow-3); animation: admin-sheet-in .18s var(--ease); }
|
.admin-sheet { position: absolute; top: 0; right: 0; bottom: 0; width: min(460px, 100%); z-index: 20; display: flex; flex-direction: column; background: var(--bg-elev); border-left: 1px solid var(--border); box-shadow: var(--shadow-3); animation: admin-sheet-in .18s var(--ease); }
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* ihasmail-inbuxa only. Public ihasmail keeps its own name.
|
||||||
|
*/
|
||||||
|
export function InbuxaWordmark({ className, height }: { className?: string; height: number }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="296 44 488 112"
|
||||||
|
height={height}
|
||||||
|
width={(height * 488) / 112}
|
||||||
|
role="img"
|
||||||
|
aria-label="INBUXA"
|
||||||
|
className={`notranslate ${className ?? ""}`}
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M70 0V496H196V0ZM133 554Q99 554 75.5 576.0Q52 598 52 634Q52 670 75.5 692.0Q99 714 133 714Q168 714 191.0 692.0Q214 670 214 634Q214 598 191.0 576.0Q168 554 133 554Z" transform="translate(303.25,151.90) scale(0.150000,-0.150000)" />
|
||||||
|
<path d="M70 0V496H194V431H212Q224 457 257.0 480.5Q290 504 357 504Q415 504 458.5 477.5Q502 451 526.0 404.5Q550 358 550 296V0H424V286Q424 342 396.5 370.0Q369 398 318 398Q260 398 228.0 359.5Q196 321 196 252V0Z" transform="translate(340.15,151.90) scale(0.150000,-0.150000)" />
|
||||||
|
<path d="M368 -14Q301 -14 265.0 9.0Q229 32 212 60H194V0H70V700H196V439H214Q225 457 243.5 473.0Q262 489 292.5 499.5Q323 510 368 510Q428 510 479.0 480.5Q530 451 561.0 394.0Q592 337 592 256V240Q592 159 561.0 102.0Q530 45 479.0 15.5Q428 -14 368 -14ZM330 96Q388 96 427.0 133.5Q466 171 466 243V253Q466 325 427.5 362.5Q389 400 330 400Q272 400 233.0 362.5Q194 325 194 253V243Q194 171 233.0 133.5Q272 96 330 96Z" transform="translate(429.55,151.90) scale(0.150000,-0.150000)" />
|
||||||
|
<path d="M259 -8Q201 -8 157.5 18.5Q114 45 90.0 92.0Q66 139 66 200V496H192V210Q192 154 219.5 126.0Q247 98 298 98Q356 98 388.0 136.5Q420 175 420 244V496H546V0H422V65H404Q392 40 359.0 16.0Q326 -8 259 -8Z" transform="translate(522.25,151.90) scale(0.150000,-0.150000)" />
|
||||||
|
<path d="M26 0 206 250 28 496H174L287 331H305L418 496H564L386 250L566 0H418L305 167H287L174 0Z" transform="translate(611.65,151.90) scale(0.150000,-0.150000)" />
|
||||||
|
<path d="M224 -14Q171 -14 129.0 4.5Q87 23 62.5 58.5Q38 94 38 145Q38 196 62.5 230.5Q87 265 130.5 282.5Q174 300 230 300H366V328Q366 363 344.0 385.5Q322 408 274 408Q227 408 204.0 386.5Q181 365 174 331L58 370Q70 408 96.5 439.5Q123 471 167.5 490.5Q212 510 276 510Q374 510 431.0 461.0Q488 412 488 319V134Q488 104 516 104H556V0H472Q435 0 411.0 18.0Q387 36 387 66V67H368Q364 55 350.0 35.5Q336 16 306.0 1.0Q276 -14 224 -14ZM246 88Q299 88 332.5 117.5Q366 147 366 196V206H239Q204 206 184.0 191.0Q164 176 164 149Q164 122 185.0 105.0Q206 88 246 88Z" transform="translate(697.45,151.90) scale(0.150000,-0.150000)" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -101,7 +101,7 @@ export interface DialogChoice {
|
|||||||
* which is what "Discard changes" was, on a guard whose whole purpose is to
|
* which is what "Discard changes" was, on a guard whose whole purpose is to
|
||||||
* stop you losing work ([#175]).
|
* stop you losing work ([#175]).
|
||||||
*
|
*
|
||||||
* [#175]: https://github.com/Coffey-Labs/ihasmail/issues/175
|
* [#175]: https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/175
|
||||||
*/
|
*/
|
||||||
primary?: boolean;
|
primary?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
|
import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
import { Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { withBase } from "@/lib/basePath";
|
import { withBase } from "@/lib/basePath";
|
||||||
import { DEFAULT_APP_NAME } from "@/lib/brand";
|
import { DEFAULT_APP_NAME } from "@/lib/brand";
|
||||||
|
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
||||||
import { useEffectiveTheme, useSettings } from "@/store/settings";
|
import { useEffectiveTheme, useSettings } from "@/store/settings";
|
||||||
import { toggleTarget } from "@/lib/palette";
|
import { toggleTarget } from "@/lib/palette";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
@@ -147,14 +148,18 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<MenuIcon size={22} />
|
<MenuIcon size={22} />
|
||||||
</button>
|
</button>
|
||||||
<Link href="/mail" className="brand">
|
<Link href="/mail" className="brand">
|
||||||
<img src={withBase("/img/logo.png")} alt="" />
|
<img src={withBase(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" />
|
||||||
{/* A product name, not a word: translated it is a different product.
|
{/* A product name, not a word: translated it is a different product.
|
||||||
Read from the session rather than written here, so a deployment
|
Read from the session rather than written here, so a deployment
|
||||||
that set APP_NAME is called what it calls itself -- the document
|
that set APP_NAME is called what it calls itself -- the document
|
||||||
title has taken it from there all along. */}
|
title has taken it from there all along. */}
|
||||||
<span className="brand-name notranslate" translate="no">
|
{appName === DEFAULT_APP_NAME ? (
|
||||||
{appName}
|
<InbuxaWordmark className="brand-name" height={22} />
|
||||||
</span>
|
) : (
|
||||||
|
<span className="brand-name notranslate" translate="no">
|
||||||
|
{appName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
@@ -182,11 +187,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
<MenuItem icon={<BookOpen size={16} />} label={t("Documentation")} href="https://docs.ihasmail.org" external />
|
|
||||||
{/* The project site. It is linked from the login screen footer, which
|
{/* The project site. It is linked from the login screen footer, which
|
||||||
is a page a signed-in user never sees again -- so from inside the
|
is a page a signed-in user never sees again -- so from inside the
|
||||||
app there was no way back to it. */}
|
app there was no way back to it.
|
||||||
<MenuItem icon={<Globe size={16} />} label={t("About ihasmail")} href="https://ihasmail.org" external />
|
ihasmail-inbuxa: INBUXA's site, and no Documentation entry until
|
||||||
|
INBUXA has documentation of its own to point at. */}
|
||||||
|
<MenuItem icon={<Globe size={16} />} label={t("About {app}", { app: appName })} href="https://inbuxa.org" external />
|
||||||
<MenuItem icon={<Settings size={16} />} label={t("Settings")} onClick={() => navigate("/settings")} />
|
<MenuItem icon={<Settings size={16} />} label={t("Settings")} onClick={() => navigate("/settings")} />
|
||||||
{/* Only for an account whose Stalwart role manages other accounts.
|
{/* Only for an account whose Stalwart role manages other accounts.
|
||||||
Nobody else is shown an entry that would open onto refusals. */}
|
Nobody else is shown an entry that would open onto refusals. */}
|
||||||
|
|||||||
+82
-11
@@ -3,16 +3,16 @@ import { Eye, EyeOff, LogIn } from "lucide-react";
|
|||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { ApiError } from "@/jmap/client";
|
import { ApiError } from "@/jmap/client";
|
||||||
import { withBase } from "@/lib/basePath";
|
import { withBase } from "@/lib/basePath";
|
||||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||||
import { DEFAULT_APP_NAME } from "@/lib/brand";
|
import { DEFAULT_APP_NAME } from "@/lib/brand";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
|
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const login = useSession((s) => s.login);
|
const login = useSession((s) => s.login);
|
||||||
// The AGPL's offer has to reach everyone who interacts with the app over the
|
// The AGPL's offer has to reach everyone who interacts with the app over the
|
||||||
// network, and that includes whoever is looking at this form. The server says
|
// network, and that includes whoever is looking at this form.
|
||||||
// where its own source lives, so a modified deployment points at its own.
|
|
||||||
const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL);
|
const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL);
|
||||||
/*
|
/*
|
||||||
* What this instance calls itself.
|
* What this instance calls itself.
|
||||||
@@ -27,6 +27,14 @@ export function LoginPage() {
|
|||||||
* sign-in form with no name on it would be worse than a wrong one.
|
* sign-in form with no name on it would be worse than a wrong one.
|
||||||
*/
|
*/
|
||||||
const [appName, setAppName] = useState(DEFAULT_APP_NAME);
|
const [appName, setAppName] = useState(DEFAULT_APP_NAME);
|
||||||
|
/*
|
||||||
|
* How this installation signs people in: on the mail server's own page
|
||||||
|
* ("oauth"), or with the password form. Unknown until the config arrives,
|
||||||
|
* and the password form if it never does.
|
||||||
|
*/
|
||||||
|
const [signIn, setSignIn] = useState<"oauth" | "password" | null>(null);
|
||||||
|
/* With "oauth" and one mail server, its page asks for the username: no address here. */
|
||||||
|
const [direct, setDirect] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
fetch(withBase("/api/config"))
|
fetch(withBase("/api/config"))
|
||||||
@@ -35,8 +43,11 @@ export function LoginPage() {
|
|||||||
if (!live || !c) return;
|
if (!live || !c) return;
|
||||||
if (c.sourceUrl) setSourceUrl(c.sourceUrl as string);
|
if (c.sourceUrl) setSourceUrl(c.sourceUrl as string);
|
||||||
if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim());
|
if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim());
|
||||||
|
setSignIn(c.signIn === "oauth" ? "oauth" : "password");
|
||||||
|
setDirect(c.signIn === "oauth" && c.signInDirect === true);
|
||||||
})
|
})
|
||||||
.catch(() => { /* the default stands */ });
|
.catch(() => { /* the default stands */ })
|
||||||
|
.finally(() => { if (live) setSignIn((m) => m ?? "password"); });
|
||||||
return () => { live = false; };
|
return () => { live = false; };
|
||||||
}, []);
|
}, []);
|
||||||
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
|
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
|
||||||
@@ -44,10 +55,19 @@ export function LoginPage() {
|
|||||||
const [showPw, setShowPw] = useState(false);
|
const [showPw, setShowPw] = useState(false);
|
||||||
const [trustDevice, setTrustDevice] = useState(false);
|
const [trustDevice, setTrustDevice] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(() => takeSignInNotice());
|
||||||
|
|
||||||
const submit = async (e: FormEvent) => {
|
const submit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (signIn === "oauth") {
|
||||||
|
// Off to the mail server's page, which asks for the password there.
|
||||||
|
if (!direct && !username.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
if (trustDevice && !direct) localStorage.setItem("ihasmail:lastUser", username.trim());
|
||||||
|
const params = new URLSearchParams({ ...(direct ? {} : { username: username.trim() }), ...(trustDevice ? { remember: "1" } : {}) });
|
||||||
|
window.location.assign(withBase(`/api/auth/oauth/start?${params}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!username || !password) return;
|
if (!username || !password) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -72,10 +92,12 @@ export function LoginPage() {
|
|||||||
<div className="login-page">
|
<div className="login-page">
|
||||||
<form className="login-card" onSubmit={submit}>
|
<form className="login-card" onSubmit={submit}>
|
||||||
<div className="logo">
|
<div className="logo">
|
||||||
<img src={withBase("/img/logo.png")} alt="" width={120} height={143} />
|
<img src={withBase(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" width={120} height={143} />
|
||||||
{/* A product name, not a word: not translated, and not guessed at
|
{/* A product name, not a word: not translated, and not guessed at
|
||||||
from the page it is on. */}
|
from the page it is on. INBUXA's is its wordmark. */}
|
||||||
<h1 className="notranslate" translate="no">{appName}</h1>
|
<h1 className="notranslate" translate="no">
|
||||||
|
{appName === DEFAULT_APP_NAME ? <InbuxaWordmark height={34} /> : appName}
|
||||||
|
</h1>
|
||||||
<p className="tagline">{t("Fast, friendly webmail. Your mailbox, your way.")}</p>
|
<p className="tagline">{t("Fast, friendly webmail. Your mailbox, your way.")}</p>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -83,10 +105,19 @@ export function LoginPage() {
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!direct && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="u">{t("Email or username")}</label>
|
<label htmlFor="u">{t("Email or username")}</label>
|
||||||
<input id="u" className="input" type="text" autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus={!username} required />
|
<input id="u" className="input" type="text" autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus={!username} required />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{signIn === "oauth" ? (
|
||||||
|
<p className="hint" style={{ marginBottom: 12 }}>
|
||||||
|
{direct
|
||||||
|
? t("You'll sign in on your mail server's own page.")
|
||||||
|
: t("You'll enter your password on your mail server's sign-in page.")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="p">{t("Password")}</label>
|
<label htmlFor="p">{t("Password")}</label>
|
||||||
<div className="pw-wrap">
|
<div className="pw-wrap">
|
||||||
@@ -96,6 +127,7 @@ export function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<label className="check" style={{ marginBottom: 4 }}>
|
<label className="check" style={{ marginBottom: 4 }}>
|
||||||
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
||||||
<span>{t("This is my own device")}</span>
|
<span>{t("This is my own device")}</span>
|
||||||
@@ -105,7 +137,7 @@ export function LoginPage() {
|
|||||||
? "Stay signed in, and keep settings and recent addresses on this computer."
|
? "Stay signed in, and keep settings and recent addresses on this computer."
|
||||||
: "Signed out after 5 minutes of inactivity, and nothing is kept on this computer. Leave this unticked on a shared or public one."}
|
: "Signed out after 5 minutes of inactivity, and nothing is kept on this computer. Leave this unticked on a shared or public one."}
|
||||||
</p>
|
</p>
|
||||||
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy}>
|
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy || signIn === null}>
|
||||||
{busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />}
|
{busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />}
|
||||||
{busy ? "Signing in…" : "Sign in"}
|
{busy ? "Signing in…" : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
@@ -120,9 +152,10 @@ export function LoginPage() {
|
|||||||
One <p> with a break rather than two: .foot carries a 20px
|
One <p> with a break rather than two: .foot carries a 20px
|
||||||
margin-top, which a second paragraph would repeat as a gap.
|
margin-top, which a second paragraph would repeat as a gap.
|
||||||
*/}
|
*/}
|
||||||
<span className="notranslate" translate="no">ihasmail v{APP_VERSION}</span>
|
{/* ihasmail-inbuxa: this build is INBUXA's webmail, and its site is INBUXA's. */}
|
||||||
|
<span className="notranslate" translate="no">INBUXA webmail v{APP_VERSION}</span>
|
||||||
<br />
|
<br />
|
||||||
<a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{t("ihasmail.org")}</a>
|
<a href="https://inbuxa.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">inbuxa.org</a>
|
||||||
{" · "}
|
{" · "}
|
||||||
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">{t("AGPL-3.0 source")}</a>
|
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">{t("AGPL-3.0 source")}</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -130,3 +163,41 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Why the sign-in page is showing, when something sent it here: an error the
|
||||||
|
* mail server's page came back with (`?signin_error=`), or a notice left by a
|
||||||
|
* change that signed this session out. Read once, then removed, so a reload
|
||||||
|
* doesn't repeat it.
|
||||||
|
*/
|
||||||
|
const SIGNIN_ERROR_LABELS: Record<string, string> = {
|
||||||
|
state_mismatch: "This sign-in didn't start in this browser. Try again.",
|
||||||
|
expired: "The sign-in took too long. Try again.",
|
||||||
|
cancelled: "Sign-in was cancelled.",
|
||||||
|
exchange_failed: "The mail server didn't accept the sign-in. Try again.",
|
||||||
|
wrong_account: "That account is on a different mail server than the address you entered. Sign in with that address.",
|
||||||
|
unsupported_server: "This mail server isn't supported.",
|
||||||
|
unavailable: "Couldn't reach the mail server. Try again in a moment.",
|
||||||
|
rate_limited: "Too many attempts. Please wait a few minutes and try again.",
|
||||||
|
password_changed: "Your password was changed. Sign in with the new one.",
|
||||||
|
signed_out: "Your sign-in ended. Sign in again.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SIGNIN_NOTICE_KEY = "ihasmail:signinNotice";
|
||||||
|
|
||||||
|
function takeSignInNotice(): string | null {
|
||||||
|
let code: string | null = null;
|
||||||
|
try {
|
||||||
|
code = sessionStorage.getItem(SIGNIN_NOTICE_KEY);
|
||||||
|
sessionStorage.removeItem(SIGNIN_NOTICE_KEY);
|
||||||
|
} catch { /* storage may be unavailable */ }
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const fromUrl = url.searchParams.get("signin_error");
|
||||||
|
if (fromUrl) {
|
||||||
|
code = fromUrl;
|
||||||
|
url.searchParams.delete("signin_error");
|
||||||
|
window.history.replaceState(null, "", url.toString());
|
||||||
|
}
|
||||||
|
if (!code) return null;
|
||||||
|
return t(SIGNIN_ERROR_LABELS[code] ?? "Could not sign in.");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { shareSummary, type SharedContent } from "@/lib/shareTarget";
|
import { shareSummary, type SharedContent } from "@/lib/shareTarget";
|
||||||
|
import { useAppName } from "@/lib/brand";
|
||||||
import { confirmDialog } from "@/ui/dialog";
|
import { confirmDialog } from "@/ui/dialog";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export async function offerShare(share: SharedContent, open: (share: SharedConte
|
|||||||
|
|
||||||
/** What arrived, so the reader can tell whether it is theirs. */
|
/** What arrived, so the reader can tell whether it is theirs. */
|
||||||
function ShareSummary({ share }: { share: SharedContent }) {
|
function ShareSummary({ share }: { share: SharedContent }) {
|
||||||
|
const appName = useAppName();
|
||||||
const { title, preview, files } = shareSummary(share);
|
const { title, preview, files } = shareSummary(share);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -39,7 +41,7 @@ function ShareSummary({ share }: { share: SharedContent }) {
|
|||||||
{files.length > 5 && <li>…</li>}
|
{files.length > 5 && <li>…</li>}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<p>{t("Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.")}</p>
|
<p>{t("Something was shared with {app}. Nothing is sent until you choose Send. If you didn't just share this, discard it.", { app: appName })}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { ArrowDownToLine, ArrowUpFromLine, ExternalLink, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, Users } from "lucide-react";
|
import { ArrowDownToLine, ArrowUpFromLine, ExternalLink, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, ShieldCheck, Users } from "lucide-react";
|
||||||
|
import { legacyProtocolsOff } from "@/jmap/client";
|
||||||
|
import { useAppName } from "@/lib/brand";
|
||||||
import { adminSections, dashboardCards, type DashboardCard } from "@/lib/admin/adminAccess";
|
import { adminSections, dashboardCards, type DashboardCard } from "@/lib/admin/adminAccess";
|
||||||
import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summarizeMetrics, type MessageStats } from "@/lib/admin/adminDashboard";
|
import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summarizeMetrics, type MessageStats } from "@/lib/admin/adminDashboard";
|
||||||
import { formatDayMonthTime, resolvedLocale } from "@/lib/datetime";
|
import { formatDayMonthTime, resolvedLocale } from "@/lib/datetime";
|
||||||
@@ -35,6 +37,8 @@ async function settle<T>(work: Promise<T>): Promise<Loaded<T>> {
|
|||||||
export function AdminDashboard() {
|
export function AdminDashboard() {
|
||||||
const perms = usePermissions();
|
const perms = usePermissions();
|
||||||
const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null);
|
const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null);
|
||||||
|
const legacyOff = useSession((s) => legacyProtocolsOff(s.session, s.accountId));
|
||||||
|
const app = useAppName();
|
||||||
const cards = dashboardCards(perms);
|
const cards = dashboardCards(perms);
|
||||||
const sections = adminSections(perms);
|
const sections = adminSections(perms);
|
||||||
const [reload, setReload] = useState(0);
|
const [reload, setReload] = useState(0);
|
||||||
@@ -102,6 +106,13 @@ export function AdminDashboard() {
|
|||||||
<RefreshCw size={18} />
|
<RefreshCw size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{legacyOff && (
|
||||||
|
// INBUXA legacy-protocols LP-18: while it's off for the organization
|
||||||
|
<p className="admin-notice">
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
<span>{t("Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.", { app })}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{shown.length ? (
|
{shown.length ? (
|
||||||
<div className="admin-cards-wrap">
|
<div className="admin-cards-wrap">
|
||||||
<div className="admin-cards">
|
<div className="admin-cards">
|
||||||
@@ -115,12 +126,12 @@ export function AdminDashboard() {
|
|||||||
more numbers will be: this is a glance, and operating the server is
|
more numbers will be: this is a glance, and operating the server is
|
||||||
Stalwart's own administration. The link is the operator's to give. */}
|
Stalwart's own administration. The link is the operator's to give. */}
|
||||||
<p className="hint admin-dashboard-note">
|
<p className="hint admin-dashboard-note">
|
||||||
{t("Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.")}
|
{t("Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.")}
|
||||||
{adminUrl && (
|
{adminUrl && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<a href={adminUrl} target="_blank" rel="noopener noreferrer">
|
<a href={adminUrl} target="_blank" rel="noopener noreferrer">
|
||||||
{t("Open Stalwart admin")} <ExternalLink size={13} aria-hidden="true" />
|
{t("Open INBUXA Admin")} <ExternalLink size={13} aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function RoleSheet({ role, roles, defaults, entries, permissionsError, on
|
|||||||
{!creating && !locked && !can(perms, "Role", "Update") && <p className="admin-notice">{t("Your role lets you view roles but not change them.")}</p>}
|
{!creating && !locked && !can(perms, "Role", "Update") && <p className="admin-notice">{t("Your role lets you view roles but not change them.")}</p>}
|
||||||
{kinds.length > 0 && (
|
{kinds.length > 0 && (
|
||||||
<p className="admin-notice warn">
|
<p className="admin-notice warn">
|
||||||
<span>{t("Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.", { kinds: kinds.join(", ") })}</span>
|
<span>{t("The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.", { kinds: kinds.join(", ") })}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ export function RoleSheet({ role, roles, defaults, entries, permissionsError, on
|
|||||||
{!creating && can(perms, "Role", "Destroy") && (
|
{!creating && can(perms, "Role", "Destroy") && (
|
||||||
<DeleteRole
|
<DeleteRole
|
||||||
role={role}
|
role={role}
|
||||||
blocked={kinds.length ? t("Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.") : locked ? t("This role carries permissions yours doesn't.") : null}
|
blocked={kinds.length ? t("The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.") : locked ? t("This role carries permissions yours doesn't.") : null}
|
||||||
onDeleted={onDeleted}
|
onDeleted={onDeleted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function RolesAdmin({ selectedId }: { selectedId?: string }) {
|
|||||||
let canceled = false;
|
let canceled = false;
|
||||||
Promise.all([loadPermissionList(), loadPermissionCatalog()]).then(
|
Promise.all([loadPermissionList(), loadPermissionCatalog()]).then(
|
||||||
([list, catalog]) => !canceled && setEntries(describePermissions(list, catalog, t("General"))),
|
([list, catalog]) => !canceled && setEntries(describePermissions(list, catalog, t("General"))),
|
||||||
(err) => !canceled && setPermissionsError(t("Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})", { reason: describeDirectoryError(err, "role") })),
|
(err) => !canceled && setPermissionsError(t("The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})", { reason: describeDirectoryError(err, "role") })),
|
||||||
);
|
);
|
||||||
return () => {
|
return () => {
|
||||||
canceled = true;
|
canceled = true;
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAppName } from "@/lib/brand";
|
||||||
|
import { can } from "@/lib/admin/adminAccess";
|
||||||
|
import {
|
||||||
|
CONFIRM_PHRASE,
|
||||||
|
fetchTenantLegacy,
|
||||||
|
impactEntries,
|
||||||
|
phraseMatches,
|
||||||
|
setTenantLegacy,
|
||||||
|
type TenantLegacy,
|
||||||
|
} from "@/lib/admin/adminLegacyProtocols";
|
||||||
|
import { formatRelative } from "@/lib/format";
|
||||||
|
import { plural, t, tNode } from "@/lib/i18n";
|
||||||
|
import { toast } from "@/ui/toast";
|
||||||
|
import { usePermissions } from "./usePermissions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tenant's legacy mail protocols switch, in its sheet (INBUXA
|
||||||
|
* legacy-protocols LP-9 to LP-17, at tenant scope).
|
||||||
|
*
|
||||||
|
* Nobody should turn it on by accident or without understanding it: turning
|
||||||
|
* it off shows who would notice (LP-15) and what it means (LP-16) before the
|
||||||
|
* typed phrase is asked for (LP-17). Turning it back on is one click -- undoing
|
||||||
|
* a restriction must never be the hard part. A tenant's switch closes no port,
|
||||||
|
* so the statement names none.
|
||||||
|
*
|
||||||
|
* Only INBUXA has it; on any other server the section isn't there.
|
||||||
|
*/
|
||||||
|
export function TenantLegacyProtocols({ tenantId, tenantName }: { tenantId: string; tenantName: string }) {
|
||||||
|
const perms = usePermissions();
|
||||||
|
const app = useAppName();
|
||||||
|
const canChange = can(perms, "Domain", "Update");
|
||||||
|
const [state, setState] = useState<TenantLegacy | null>(null);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [typed, setTyped] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let canceled = false;
|
||||||
|
void fetchTenantLegacy(tenantId).then((s) => !canceled && setState(s));
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [tenantId, revision]);
|
||||||
|
|
||||||
|
if (!state) return null;
|
||||||
|
|
||||||
|
const turn = async (off: boolean) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await setTenantLegacy(tenantId, off);
|
||||||
|
setConfirming(false);
|
||||||
|
setTyped("");
|
||||||
|
setRevision((r) => r + 1);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const entries = state.recent ? impactEntries(state.recent) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>{t("Legacy mail apps")}</h3>
|
||||||
|
<p>
|
||||||
|
{state.off
|
||||||
|
? t("Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.", { tenant: tenantName, app })
|
||||||
|
: t("On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.", { tenant: tenantName })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{canChange && state.off && (
|
||||||
|
<button className="btn" disabled={busy} onClick={() => void turn(false)}>
|
||||||
|
{t("Turn legacy protocols back on")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canChange && !state.off && !confirming && (
|
||||||
|
<button className="btn btn-danger" onClick={() => setConfirming(true)}>
|
||||||
|
{t("Turn off legacy protocols…")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!state.off && confirming && (
|
||||||
|
<div className="admin-legacy-confirm">
|
||||||
|
{entries &&
|
||||||
|
(entries.length === 0 ? (
|
||||||
|
<p className="hint">{t("No account used a legacy mail app in the last 30 days.")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="admin-legacy-box">
|
||||||
|
<p>
|
||||||
|
<b>
|
||||||
|
{plural(entries.length, {
|
||||||
|
one: "{n} account used a legacy mail app in the last 30 days.",
|
||||||
|
other: "{n} accounts used a legacy mail app in the last 30 days.",
|
||||||
|
}, { n: entries.length })}
|
||||||
|
</b>{" "}
|
||||||
|
{t("Their mail apps will stop working the moment you turn this on:")}
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<li key={e.name}>
|
||||||
|
<span className="notranslate" translate="no">{e.name}</span>: {e.protocols.join(", ")},{" "}
|
||||||
|
{formatRelative(new Date(e.lastUsedAt).toISOString())}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="admin-legacy-box warn">
|
||||||
|
<p><b>{t("Only {app} and JMAP apps will work.", { app })}</b></p>
|
||||||
|
<p>
|
||||||
|
{t("Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {tenant}.", { tenant: tenantName })}
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>{t("Phone and desktop mail apps will stop receiving and sending mail. That's iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.")}</li>
|
||||||
|
<li>{t("Filters managed from a mail app (ManageSieve) will stop working. Filters set in {app} keep working.", { app })}</li>
|
||||||
|
<li>{t("Incoming mail is not affected. Calendars and contacts are not affected.")}</li>
|
||||||
|
<li>{t("People keep full access through {app}, which can be installed as an app on phones and computers.", { app })}</li>
|
||||||
|
</ul>
|
||||||
|
<p>{t("Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and {app} (JMAP) are not affected and cannot be turned off here.", { app })}</p>
|
||||||
|
<p>{t("You can turn legacy protocols back on at any time.")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="admin-legacy-confirm">
|
||||||
|
{tNode("To confirm, type {phrase}", { phrase: <code>{CONFIRM_PHRASE}</code> })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admin-legacy-confirm"
|
||||||
|
className="input"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={typed}
|
||||||
|
onChange={(e) => setTyped(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="row gap-8">
|
||||||
|
<button className="btn btn-danger" disabled={busy || !phraseMatches(typed)} onClick={() => void turn(true)}>
|
||||||
|
{t("Turn off legacy protocols")}
|
||||||
|
</button>
|
||||||
|
<button className="btn" disabled={busy} onClick={() => { setConfirming(false); setTyped(""); }}>
|
||||||
|
{t("Cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { Dialog } from "@/ui/dialog";
|
|||||||
import { Spinner } from "@/ui/misc";
|
import { Spinner } from "@/ui/misc";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { usePermissions } from "./usePermissions";
|
import { usePermissions } from "./usePermissions";
|
||||||
|
import { TenantLegacyProtocols } from "./TenantLegacyProtocols";
|
||||||
|
|
||||||
const GIB = 1024 ** 3;
|
const GIB = 1024 ** 3;
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ export function TenantSheet({ tenant, roles, onClose, onChanged, onCreated, onDe
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="admin-tenant-logo">{t("Logo")}</label>
|
<label htmlFor="admin-tenant-logo">{t("Logo")}</label>
|
||||||
<input id="admin-tenant-logo" className="input" value={logo} disabled={!editable} placeholder="https://…" spellCheck={false} onChange={(e) => setLogo(e.target.value)} />
|
<input id="admin-tenant-logo" className="input" value={logo} disabled={!editable} placeholder="https://…" spellCheck={false} onChange={(e) => setLogo(e.target.value)} />
|
||||||
<span className="hint">{t("An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.")}</span>
|
<span className="hint">{t("An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!creating && (
|
{!creating && (
|
||||||
@@ -203,6 +204,8 @@ export function TenantSheet({ tenant, roles, onClose, onChanged, onCreated, onDe
|
|||||||
|
|
||||||
<h3>{t("Domains")}</h3>
|
<h3>{t("Domains")}</h3>
|
||||||
<TenantDomains tenant={tenant} canChange={can(perms, "Domain", "Update")} onChanged={() => { setRevision((n) => n + 1); onChanged(); }} />
|
<TenantDomains tenant={tenant} canChange={can(perms, "Domain", "Update")} onChanged={() => { setRevision((n) => n + 1); onChanged(); }} />
|
||||||
|
|
||||||
|
<TenantLegacyProtocols tenantId={tenant.id} tenantName={tenant.name} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -215,7 +218,7 @@ export function TenantSheet({ tenant, roles, onClose, onChanged, onCreated, onDe
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="hint">{t("Stalwart refuses to create more than a limit allows. An empty field is no limit.")}</p>
|
<p className="hint">{t("The mail server refuses to create more than a limit allows. An empty field is no limit.")}</p>
|
||||||
|
|
||||||
<h3>{t("Role")}</h3>
|
<h3>{t("Role")}</h3>
|
||||||
<select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable} onChange={(e) => setRole(e.target.value)}>
|
<select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable} onChange={(e) => setRole(e.target.value)}>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { drawableLogo, getTenants, queryTenants, type DirectoryTenant } from "@/
|
|||||||
import { formatSize } from "@/lib/format";
|
import { formatSize } from "@/lib/format";
|
||||||
import { proxiedImageUrl } from "@/lib/text/html";
|
import { proxiedImageUrl } from "@/lib/text/html";
|
||||||
import { plural, t } from "@/lib/i18n";
|
import { plural, t } from "@/lib/i18n";
|
||||||
import { useSession } from "@/store/session";
|
|
||||||
import { Empty, Spinner } from "@/ui/misc";
|
import { Empty, Spinner } from "@/ui/misc";
|
||||||
import { usePermissions } from "./usePermissions";
|
import { usePermissions } from "./usePermissions";
|
||||||
import { TenantSheet } from "./TenantSheet";
|
import { TenantSheet } from "./TenantSheet";
|
||||||
@@ -18,39 +17,15 @@ const PAGE_SIZE = 50;
|
|||||||
* Tenants: separate organizations on one server, each with its own people,
|
* Tenants: separate organizations on one server, each with its own people,
|
||||||
* domains and limits.
|
* domains and limits.
|
||||||
*
|
*
|
||||||
* The section is offered to whoever may read tenants. On a server that does not
|
* The section is offered to whoever may read tenants. The INBUXA mail server
|
||||||
* report Enterprise -- or reports no edition -- the page is only a notice that
|
* has one edition with tenants in it, so there is no edition check and no
|
||||||
* tenants are an Enterprise feature: tenants there hold nobody to anything
|
* notice here, unlike public ihasmail.
|
||||||
* beyond an ordinary user's permissions, so there is nothing worth creating or
|
|
||||||
* listing. On Enterprise the notice is left out, unless the installation asks
|
|
||||||
* for it (SHOW_ENTERPRISE_NOTICES), as the public demo does so as not to
|
|
||||||
* suggest tenants come without the license.
|
|
||||||
*/
|
*/
|
||||||
export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
|
export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
|
||||||
const edition = useSession((s) => s.session?.ihasmail?.server?.edition ?? null);
|
return <EnterpriseTenants selectedId={selectedId} />;
|
||||||
const notices = useSession((s) => s.session?.ihasmail?.server?.enterpriseNotices === true);
|
|
||||||
if (edition !== "enterprise") {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="admin-head">
|
|
||||||
<div className="grow">
|
|
||||||
<h1>{t("Tenants")}</h1>
|
|
||||||
<p className="lead">{t("Separate organizations on one server, each with its own people, domains and limits.")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<EnterpriseNotice warn />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <EnterpriseTenants selectedId={selectedId} notice={notices} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Said on every Tenants page, Enterprise or not. */
|
function EnterpriseTenants({ selectedId }: { selectedId?: string }) {
|
||||||
function EnterpriseNotice({ warn }: { warn: boolean }) {
|
|
||||||
return <p className={`admin-notice${warn ? " warn" : ""}`}>{t("Tenants are a Stalwart Enterprise feature.")}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EnterpriseTenants({ selectedId, notice }: { selectedId?: string; notice: boolean }) {
|
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const perms = usePermissions();
|
const perms = usePermissions();
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
@@ -127,7 +102,6 @@ function EnterpriseTenants({ selectedId, notice }: { selectedId?: string; notice
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notice && <EnterpriseNotice warn={false} />}
|
|
||||||
|
|
||||||
<div className="admin-toolbar">
|
<div className="admin-toolbar">
|
||||||
<label className="admin-search">
|
<label className="admin-search">
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ describe("the Administration dashboard", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the pointer to Stalwart's own administration", () => {
|
describe("the pointer to INBUXA Admin", () => {
|
||||||
let host: HTMLDivElement;
|
let host: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -136,7 +136,7 @@ describe("the pointer to Stalwart's own administration", () => {
|
|||||||
it("names it, and links it where the operator has said where it is", async () => {
|
it("names it, and links it where the operator has said where it is", async () => {
|
||||||
await renderWith("https://admin.example.com");
|
await renderWith("https://admin.example.com");
|
||||||
const note = host.querySelector(".admin-dashboard-note")!;
|
const note = host.querySelector(".admin-dashboard-note")!;
|
||||||
expect(note.textContent).toContain("Stalwart's own administration");
|
expect(note.textContent).toContain("INBUXA Admin");
|
||||||
const link = note.querySelector("a")!;
|
const link = note.querySelector("a")!;
|
||||||
expect(link.getAttribute("href")).toBe("https://admin.example.com");
|
expect(link.getAttribute("href")).toBe("https://admin.example.com");
|
||||||
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
|
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
|
||||||
@@ -144,7 +144,7 @@ describe("the pointer to Stalwart's own administration", () => {
|
|||||||
|
|
||||||
it("names it without a link where nobody has", async () => {
|
it("names it without a link where nobody has", async () => {
|
||||||
await renderWith(null);
|
await renderWith(null);
|
||||||
expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("Stalwart's own administration");
|
expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("INBUXA Admin");
|
||||||
expect(host.querySelector(".admin-dashboard-note a")).toBeNull();
|
expect(host.querySelector(".admin-dashboard-note a")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe("the role sheet", () => {
|
|||||||
it("warns about a default role and will not delete it", async () => {
|
it("warns about a default role and will not delete it", async () => {
|
||||||
signIn(VIEWER);
|
signIn(VIEWER);
|
||||||
await render(roles.get("user")!, { user: ["user"], group: [], tenant: [], admin: [] });
|
await render(roles.get("user")!, { user: ["user"], group: [], tenant: [], admin: [] });
|
||||||
expect(host.textContent).toContain("Stalwart gives this role by default to users");
|
expect(host.textContent).toContain("The mail server gives this role by default to users");
|
||||||
expect(button(host, "Delete role…")?.disabled).toBe(true);
|
expect(button(host, "Delete role…")?.disabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useSession } from "@/store/session";
|
||||||
|
import type { JmapSession } from "@/jmap/types";
|
||||||
|
import type { TenantLegacy } from "@/lib/admin/adminLegacyProtocols";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
state: null as TenantLegacy | null,
|
||||||
|
set: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/admin/adminLegacyProtocols", async (original) => ({
|
||||||
|
...(await original<typeof import("@/lib/admin/adminLegacyProtocols")>()),
|
||||||
|
fetchTenantLegacy: vi.fn(async () => api.state),
|
||||||
|
setTenantLegacy: api.set,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { TenantLegacyProtocols } = await import("../TenantLegacyProtocols");
|
||||||
|
|
||||||
|
const signIn = (permissions: string[]) =>
|
||||||
|
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
|
||||||
|
const button = (host: HTMLElement, label: string) => [...host.querySelectorAll("button")].find((b) => b.textContent?.trim() === label);
|
||||||
|
const type = async (el: HTMLInputElement, value: string) => {
|
||||||
|
await act(async () => {
|
||||||
|
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(el, value);
|
||||||
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INBUXA legacy-protocols at tenant scope: nobody turns it off by accident
|
||||||
|
* (the statement and a typed phrase first), and turning it back on is one
|
||||||
|
* click.
|
||||||
|
*/
|
||||||
|
describe("a tenant's legacy mail protocols switch", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
const render = async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<TenantLegacyProtocols tenantId="t1" tenantName="Acme Corp" />);
|
||||||
|
});
|
||||||
|
await act(async () => {});
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
api.set.mockClear();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isn't there on a server without the switch", async () => {
|
||||||
|
api.state = null;
|
||||||
|
signIn(["sysDomainGet", "sysDomainUpdate"]);
|
||||||
|
await render();
|
||||||
|
expect(host.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows who would notice and what it means, then asks for the exact phrase", async () => {
|
||||||
|
api.state = { off: false, recent: [{ accountId: "a", name: "[email protected]", protocol: "imap", lastUsedAt: Date.now() - 2 * 86400_000 }] };
|
||||||
|
signIn(["sysDomainGet", "sysDomainUpdate"]);
|
||||||
|
await render();
|
||||||
|
await act(async () => button(host, "Turn off legacy protocols…")!.click());
|
||||||
|
|
||||||
|
expect(host.textContent).toContain("1 account used a legacy mail app in the last 30 days.");
|
||||||
|
expect(host.textContent).toContain("[email protected]: IMAP");
|
||||||
|
expect(host.textContent).toContain("turned off for everyone in Acme Corp");
|
||||||
|
// A tenant's switch closes no port, so the statement names none.
|
||||||
|
expect(host.textContent).not.toContain("firewall");
|
||||||
|
|
||||||
|
const confirm = button(host, "Turn off legacy protocols")!;
|
||||||
|
const box = host.querySelector<HTMLInputElement>("#admin-legacy-confirm")!;
|
||||||
|
await type(box, "Turn off legacy mail");
|
||||||
|
expect(confirm.disabled).toBe(true);
|
||||||
|
await type(box, "turn off legacy mail");
|
||||||
|
expect(confirm.disabled).toBe(false);
|
||||||
|
await act(async () => confirm.click());
|
||||||
|
expect(api.set).toHaveBeenCalledWith("t1", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns it back on with one click", async () => {
|
||||||
|
api.state = { off: true, recent: [] };
|
||||||
|
signIn(["sysDomainGet", "sysDomainUpdate"]);
|
||||||
|
await render();
|
||||||
|
await act(async () => button(host, "Turn legacy protocols back on")!.click());
|
||||||
|
expect(api.set).toHaveBeenCalledWith("t1", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the state but no switch to someone who can't change domains", async () => {
|
||||||
|
api.state = { off: true, recent: null };
|
||||||
|
signIn(["sysDomainGet"]);
|
||||||
|
await render();
|
||||||
|
expect(host.textContent).toContain("Off for Acme Corp");
|
||||||
|
expect(host.querySelector("button")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,7 @@ const PERMS = ["sysTenantGet", "sysTenantQuery", "sysTenantCreate"];
|
|||||||
const signIn = (edition: string | null, enterpriseNotices = false) =>
|
const signIn = (edition: string | null, enterpriseNotices = false) =>
|
||||||
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions: PERMS, server: { edition, enterpriseNotices } } } as unknown as JmapSession });
|
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions: PERMS, server: { edition, enterpriseNotices } } } as unknown as JmapSession });
|
||||||
|
|
||||||
/** Tenants are managed on Enterprise only; anywhere else the page is the notice and nothing more. */
|
/** INBUXA: tenants are managed on every server, whatever edition it reports. */
|
||||||
describe("the Tenants page", () => {
|
describe("the Tenants page", () => {
|
||||||
let host: HTMLDivElement;
|
let host: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
@@ -43,25 +43,17 @@ describe("the Tenants page", () => {
|
|||||||
host.remove();
|
host.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const edition of ["community", "oss", null]) {
|
for (const edition of ["community", "oss", null, "enterprise"]) {
|
||||||
it(`shows only the notice on ${edition ?? "a server that reports no edition"}`, async () => {
|
it(`lists and offers tenants on ${edition ?? "a server that reports no edition"}, with no Enterprise notice`, async () => {
|
||||||
signIn(edition);
|
signIn(edition);
|
||||||
await render();
|
await render();
|
||||||
expect(host.querySelector(".admin-notice.warn")?.textContent).toContain("Tenants are a Stalwart Enterprise feature");
|
expect(host.querySelector(".admin-notice")).toBeNull();
|
||||||
expect(host.textContent).not.toContain("New tenant");
|
expect(host.textContent).toContain("New tenant");
|
||||||
expect(host.querySelector('input[type="search"]')).toBeNull();
|
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
|
||||||
expect(host.querySelector(".admin-table")).toBeNull();
|
expect(api.queryTenants).toHaveBeenCalled();
|
||||||
expect(api.queryTenants).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it("lists and offers tenants on Enterprise, and does not say they are Enterprise", async () => {
|
|
||||||
signIn("enterprise");
|
|
||||||
await render();
|
|
||||||
expect(host.querySelector(".admin-notice")).toBeNull();
|
|
||||||
expect(host.textContent).toContain("New tenant");
|
|
||||||
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the Tenants page where the installation asks for Enterprise notices", () => {
|
describe("the Tenants page where the installation asks for Enterprise notices", () => {
|
||||||
@@ -77,15 +69,14 @@ describe("the Tenants page where the installation asks for Enterprise notices",
|
|||||||
host.remove();
|
host.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says tenants are Enterprise above the list, as the demo does", async () => {
|
it("shows no Enterprise notice, even where the installation asks for one", async () => {
|
||||||
signIn("enterprise", true);
|
signIn("enterprise", true);
|
||||||
const { hook } = memoryLocation({ path: "/admin/tenants" });
|
const { hook } = memoryLocation({ path: "/admin/tenants" });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(<Router hook={hook}><TenantsAdmin /></Router>);
|
root.render(<Router hook={hook}><TenantsAdmin /></Router>);
|
||||||
});
|
});
|
||||||
await act(async () => {});
|
await act(async () => {});
|
||||||
expect(host.querySelector(".admin-notice")?.textContent).toBe("Tenants are a Stalwart Enterprise feature.");
|
expect(host.querySelector(".admin-notice")).toBeNull();
|
||||||
expect(host.querySelector(".admin-notice.warn")).toBeNull();
|
|
||||||
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
|
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type { Anchor } from "@/ui/popover";
|
|||||||
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu";
|
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { askEditScope, droppedMessage, runScoped } from "./scope";
|
import { askEditScope, droppedMessage, runScoped } from "./scope";
|
||||||
import { canDragEvent, dayDelta, moveByDaysPatch, movePatch, pixelsToMinutes, resizePatch, snap, type DragPatch } from "@/lib/calendar/eventDrag";
|
import { canDragEvent, columnsMoved, dayDelta, moveAcrossPatch, moveByDaysPatch, pixelsToMinutes, resizePatch, snap, type DragPatch } from "@/lib/calendar/eventDrag";
|
||||||
import { t as translate } from "@/lib/i18n";
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
type View = "month" | "week" | "day" | "agenda";
|
type View = "month" | "week" | "day" | "agenda";
|
||||||
@@ -237,16 +237,47 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
|||||||
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
|
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
|
||||||
const dow = weeks[0]!.map((d) => formatWeekday(d));
|
const dow = weeks[0]!.map((d) => formatWeekday(d));
|
||||||
const maxPer = 4;
|
const maxPer = 4;
|
||||||
|
const chipDrag = useChipDrag(".month-cell", onDragCommit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="month-grid">
|
||||||
|
<div className="dow-row">{dow.map((d) => <div key={d}>{d}</div>)}</div>
|
||||||
|
{weeks.map((days, wi) => (
|
||||||
|
<div key={wi} className="week-row">
|
||||||
|
{days.map((d) => {
|
||||||
|
const dayEnd = addDays(d, 1);
|
||||||
|
const evs = instances.filter((i) => i.start < dayEnd && i.end > d);
|
||||||
|
const shown = evs.slice(0, maxPer);
|
||||||
|
return (
|
||||||
|
<div key={d.toISOString()} data-date={toLocalDateOnly(d)} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
|
||||||
|
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()}</span>
|
||||||
|
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} onDragStart={canDragEvent(i.event, i.calendar) ? (e) => chipDrag.begin(i, d, e) : undefined} dragging={chipDrag.draggingKey === i.key} suppressClick={() => chipDrag.draggedRef.current} />)}
|
||||||
|
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{translate("+{n} more", { n: evs.length - maxPer })}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Dragging a chip to another day: the month grid, and the all-day row above
|
||||||
|
* the week grid. A cell there is a day and nothing finer, so the only question
|
||||||
|
* a drag asks is "which day". The cell under the pointer is found by asking
|
||||||
|
* the document rather than by tracking enter and leave on every cell: one
|
||||||
|
* question at the end beats bookkeeping throughout.
|
||||||
|
*
|
||||||
|
* The move is counted from the day the chip was picked up on, not from the
|
||||||
|
* event's first day, so a three-day event grabbed on its last day and dropped
|
||||||
|
* one cell to the right moves one day, not three.
|
||||||
|
*/
|
||||||
|
function useChipDrag(cellSelector: string, onDragCommit: (i: EventInstance, patch: DragPatch) => void) {
|
||||||
const [draggingKey, setDraggingKey] = useState<string | null>(null);
|
const [draggingKey, setDraggingKey] = useState<string | null>(null);
|
||||||
const draggedRef = useRef(false);
|
const draggedRef = useRef(false);
|
||||||
|
|
||||||
/*
|
const begin = (inst: EventInstance, grabbedOn: Date, e: React.PointerEvent) => {
|
||||||
* A month cell is a day and nothing finer, so the only question a drag here
|
|
||||||
* asks is "which day". The cell under the pointer is found by asking the
|
|
||||||
* document rather than by tracking enter and leave on forty-two cells: one
|
|
||||||
* question at the end beats bookkeeping throughout.
|
|
||||||
*/
|
|
||||||
const beginChipDrag = (inst: EventInstance, e: React.PointerEvent) => {
|
|
||||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||||
if (!canDragEvent(inst.event, inst.calendar)) return;
|
if (!canDragEvent(inst.event, inst.calendar)) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -255,7 +286,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
|||||||
el.setPointerCapture(e.pointerId);
|
el.setPointerCapture(e.pointerId);
|
||||||
let landedOn: string | null = null;
|
let landedOn: string | null = null;
|
||||||
const onPointerMove = (ev: PointerEvent) => {
|
const onPointerMove = (ev: PointerEvent) => {
|
||||||
const cell = document.elementFromPoint(ev.clientX, ev.clientY)?.closest<HTMLElement>(".month-cell");
|
const cell = document.elementFromPoint(ev.clientX, ev.clientY)?.closest<HTMLElement>(cellSelector);
|
||||||
const date = cell?.dataset.date ?? null;
|
const date = cell?.dataset.date ?? null;
|
||||||
if (date) landedOn = date;
|
if (date) landedOn = date;
|
||||||
if (!draggedRef.current) draggedRef.current = true;
|
if (!draggedRef.current) draggedRef.current = true;
|
||||||
@@ -277,8 +308,8 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
|||||||
const target = parts && parts.length === 3 ? new Date(parts[0]!, parts[1]! - 1, parts[2]!) : null;
|
const target = parts && parts.length === 3 ? new Date(parts[0]!, parts[1]! - 1, parts[2]!) : null;
|
||||||
// How far the hand moved it, in local days -- see moveByDaysPatch for
|
// How far the hand moved it, in local days -- see moveByDaysPatch for
|
||||||
// why the target date itself is the wrong thing to write.
|
// why the target date itself is the wrong thing to write.
|
||||||
if (target && !isSameDay(target, inst.start)) {
|
if (target && !isSameDay(target, grabbedOn)) {
|
||||||
onDragCommit(inst, moveByDaysPatch(inst.event.start, dayDelta(inst.start, target)));
|
onDragCommit(inst, moveByDaysPatch(inst.event.start, dayDelta(grabbedOn, target)));
|
||||||
}
|
}
|
||||||
window.setTimeout(() => (draggedRef.current = false), 0);
|
window.setTimeout(() => (draggedRef.current = false), 0);
|
||||||
};
|
};
|
||||||
@@ -287,27 +318,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
|||||||
el.addEventListener("pointercancel", finish);
|
el.addEventListener("pointercancel", finish);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return { begin, draggingKey, draggedRef };
|
||||||
<div className="month-grid">
|
|
||||||
<div className="dow-row">{dow.map((d) => <div key={d}>{d}</div>)}</div>
|
|
||||||
{weeks.map((days, wi) => (
|
|
||||||
<div key={wi} className="week-row">
|
|
||||||
{days.map((d) => {
|
|
||||||
const dayEnd = addDays(d, 1);
|
|
||||||
const evs = instances.filter((i) => i.start < dayEnd && i.end > d);
|
|
||||||
const shown = evs.slice(0, maxPer);
|
|
||||||
return (
|
|
||||||
<div key={d.toISOString()} data-date={toLocalDateOnly(d)} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
|
|
||||||
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()}</span>
|
|
||||||
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} onDragStart={canDragEvent(i.event, i.calendar) ? (e) => beginChipDrag(i, e) : undefined} dragging={draggingKey === i.key} suppressClick={() => draggedRef.current} />)}
|
|
||||||
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{translate("+{n} more", { n: evs.length - maxPer })}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusClass(i: EventInstance): string {
|
function statusClass(i: EventInstance): string {
|
||||||
@@ -358,24 +369,36 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
|||||||
* new time, so the preview is one number and the commit is the same
|
* new time, so the preview is one number and the commit is the same
|
||||||
* arithmetic the tests cover.
|
* arithmetic the tests cover.
|
||||||
*/
|
*/
|
||||||
const [moving, setMoving] = useState<{ key: string; deltaMin: number; mode: "move" | "resize" } | null>(null);
|
const [moving, setMoving] = useState<{ key: string; deltaMin: number; deltaDays: number; shiftPx: number; mode: "move" | "resize" } | null>(null);
|
||||||
/* A drag ends with a pointerup, and a pointerup on the same element is also
|
/* A drag ends with a pointerup, and a pointerup on the same element is also
|
||||||
a click. Without this, letting go of a moved event opens its popover. */
|
a click. Without this, letting go of a moved event opens its popover. */
|
||||||
const draggedRef = useRef(false);
|
const draggedRef = useRef(false);
|
||||||
|
const allDayDrag = useChipDrag(".ad-cell", onDragCommit);
|
||||||
|
|
||||||
const beginDrag = (inst: EventInstance, mode: "move" | "resize", e: React.PointerEvent) => {
|
/*
|
||||||
|
* A move goes sideways as well as up and down: across the columns to
|
||||||
|
* another day, keeping whatever hour it was dragged to. The block stays in
|
||||||
|
* its own column while it moves and is drawn shifted by whole columns, so
|
||||||
|
* the preview is two numbers and nothing is re-laid-out until it lands.
|
||||||
|
* A resize only ever changes the end, so it stays vertical.
|
||||||
|
*/
|
||||||
|
const beginDrag = (inst: EventInstance, mode: "move" | "resize", column: number, e: React.PointerEvent) => {
|
||||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||||
if (!canDragEvent(inst.event, inst.calendar)) return;
|
if (!canDragEvent(inst.event, inst.calendar)) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const el = e.currentTarget as HTMLElement;
|
const el = e.currentTarget as HTMLElement;
|
||||||
|
const startX = e.clientX;
|
||||||
const startY = e.clientY;
|
const startY = e.clientY;
|
||||||
|
const colWidth = mode === "move" ? el.closest<HTMLElement>(".day-col")?.getBoundingClientRect().width ?? 0 : 0;
|
||||||
let delta = 0;
|
let delta = 0;
|
||||||
|
let deltaDays = 0;
|
||||||
el.setPointerCapture(e.pointerId);
|
el.setPointerCapture(e.pointerId);
|
||||||
const onPointerMove = (ev: PointerEvent) => {
|
const onPointerMove = (ev: PointerEvent) => {
|
||||||
delta = snap(pixelsToMinutes(ev.clientY - startY, HOUR_H));
|
delta = snap(pixelsToMinutes(ev.clientY - startY, HOUR_H));
|
||||||
if (delta !== 0) draggedRef.current = true;
|
deltaDays = columnsMoved(ev.clientX - startX, colWidth, column, days.length);
|
||||||
setMoving({ key: inst.key, deltaMin: delta, mode });
|
if (delta !== 0 || deltaDays !== 0) draggedRef.current = true;
|
||||||
|
setMoving({ key: inst.key, deltaMin: delta, deltaDays, shiftPx: deltaDays * colWidth, mode });
|
||||||
};
|
};
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
el.removeEventListener("pointermove", onPointerMove);
|
el.removeEventListener("pointermove", onPointerMove);
|
||||||
@@ -387,9 +410,9 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
|||||||
/* already released, which is fine */
|
/* already released, which is fine */
|
||||||
}
|
}
|
||||||
setMoving(null);
|
setMoving(null);
|
||||||
if (delta !== 0) {
|
if (delta !== 0 || deltaDays !== 0) {
|
||||||
const seconds = (inst.end.getTime() - inst.start.getTime()) / 1000;
|
const seconds = (inst.end.getTime() - inst.start.getTime()) / 1000;
|
||||||
onDragCommit(inst, mode === "move" ? movePatch(inst.event.start, delta) : resizePatch(seconds, delta));
|
onDragCommit(inst, mode === "move" ? moveAcrossPatch(inst.event.start, deltaDays, delta) : resizePatch(seconds, delta));
|
||||||
}
|
}
|
||||||
// Cleared after the click that follows this pointerup has been swallowed.
|
// Cleared after the click that follows this pointerup has been swallowed.
|
||||||
window.setTimeout(() => (draggedRef.current = false), 0);
|
window.setTimeout(() => (draggedRef.current = false), 0);
|
||||||
@@ -431,8 +454,8 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
|||||||
<div className="week-allday">
|
<div className="week-allday">
|
||||||
<div className="ad-label">{translate("all-day")}</div>
|
<div className="ad-label">{translate("all-day")}</div>
|
||||||
{days.map((d) => (
|
{days.map((d) => (
|
||||||
<div key={d.toISOString()} className="ad-cell" onClick={() => onCreate(d, addDays(d, 1), true)} onContextMenu={(e) => onSlotContext(d, addDays(d, 1), true, e)}>
|
<div key={d.toISOString()} data-date={toLocalDateOnly(d)} className="ad-cell" onClick={() => onCreate(d, addDays(d, 1), true)} onContextMenu={(e) => onSlotContext(d, addDays(d, 1), true, e)}>
|
||||||
{allDay(d).map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
|
{allDay(d).map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} onDragStart={canDragEvent(i.event, i.calendar) ? (e) => allDayDrag.begin(i, d, e) : undefined} dragging={allDayDrag.draggingKey === i.key} suppressClick={() => allDayDrag.draggedRef.current} />)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -441,7 +464,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
|||||||
<div className="time-col">
|
<div className="time-col">
|
||||||
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{formatHourLabel(h)}</span>)}
|
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{formatHourLabel(h)}</span>)}
|
||||||
</div>
|
</div>
|
||||||
{days.map((d) => {
|
{days.map((d, column) => {
|
||||||
const evs = layoutOverlaps(timed(d), d);
|
const evs = layoutOverlaps(timed(d), d);
|
||||||
const today = isToday(d);
|
const today = isToday(d);
|
||||||
const nowTop = ((now.getHours() * 60 + now.getMinutes()) / 60) * HOUR_H;
|
const nowTop = ((now.getHours() * 60 + now.getMinutes()) / 60) * HOUR_H;
|
||||||
@@ -490,9 +513,10 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
|||||||
height: Math.max(height + (moving?.key === inst.key && moving.mode === "resize" ? (moving.deltaMin / 60) * HOUR_H : 0), 18),
|
height: Math.max(height + (moving?.key === inst.key && moving.mode === "resize" ? (moving.deltaMin / 60) * HOUR_H : 0), 18),
|
||||||
left: `${left}%`,
|
left: `${left}%`,
|
||||||
width: `calc(${width}% - 3px)`,
|
width: `calc(${width}% - 3px)`,
|
||||||
|
transform: moving?.key === inst.key && moving.shiftPx ? `translateX(${moving.shiftPx}px)` : undefined,
|
||||||
background: color,
|
background: color,
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => beginDrag(inst, "move", e)}
|
onPointerDown={(e) => beginDrag(inst, "move", column, e)}
|
||||||
onClick={(e) => { e.stopPropagation(); if (draggedRef.current) return; onEvent(inst, e.currentTarget); }}
|
onClick={(e) => { e.stopPropagation(); if (draggedRef.current) return; onEvent(inst, e.currentTarget); }}
|
||||||
onContextMenu={(e) => onEventContext(inst, e)}
|
onContextMenu={(e) => onEventContext(inst, e)}
|
||||||
title={inst.event.title ?? ""}
|
title={inst.event.title ?? ""}
|
||||||
@@ -501,7 +525,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
|||||||
/* Its own element rather than an edge zone on the block,
|
/* Its own element rather than an edge zone on the block,
|
||||||
so a thumb has something to aim at and the move drag
|
so a thumb has something to aim at and the move drag
|
||||||
does not have to guess which one was meant. */
|
does not have to guess which one was meant. */
|
||||||
<div className="ev-resize" onPointerDown={(e) => beginDrag(inst, "resize", e)} aria-hidden="true" />
|
<div className="ev-resize" onPointerDown={(e) => beginDrag(inst, "resize", column, e)} aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
<div className="ev-title">{inst.event.title || "(untitled)"}</div>
|
<div className="ev-title">{inst.event.title || "(untitled)"}</div>
|
||||||
{height > 30 && <div className="ev-time">{formatTime(inst.start)} – {formatTime(inst.end)}</div>}
|
{height > 30 && <div className="ev-time">{formatTime(inst.start)} – {formatTime(inst.end)}</div>}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useAppName } from "@/lib/brand";
|
||||||
import { Clock } from "lucide-react";
|
import { Clock } from "lucide-react";
|
||||||
import { Dialog } from "@/ui/dialog";
|
import { Dialog } from "@/ui/dialog";
|
||||||
import { DateTimeField } from "@/ui/datefield";
|
import { DateTimeField } from "@/ui/datefield";
|
||||||
@@ -33,6 +34,7 @@ export function ScheduleDialog({ open, maxMs, initial, onClose, onPick }: {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPick: (at: Date) => void;
|
onPick: (at: Date) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const appName = useAppName();
|
||||||
const [value, setValue] = useState(() => toInputDateTime(initial ? new Date(initial) : roundToNext(new Date(Date.now() + 3_600_000), 15)));
|
const [value, setValue] = useState(() => toInputDateTime(initial ? new Date(initial) : roundToNext(new Date(Date.now() + 3_600_000), 15)));
|
||||||
const at = fromInputDateTime(value);
|
const at = fromInputDateTime(value);
|
||||||
const error = scheduleError(at, new Date(), maxMs);
|
const error = scheduleError(at, new Date(), maxMs);
|
||||||
@@ -58,7 +60,7 @@ export function ScheduleDialog({ open, maxMs, initial, onClose, onPick }: {
|
|||||||
<p className="hint" style={{ color: "var(--danger)" }}>{error}</p>
|
<p className="hint" style={{ color: "var(--danger)" }}>{error}</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
{`${t("The message waits on the server, so it goes out whether or not ihasmail is open.")}${maxMs > 0 ? ` ${t("This server holds a message for up to {span}.", { span: describeSpan(maxMs) })}` : ""}`}
|
{`${t("The message waits on the server, so it goes out whether or not {app} is open.", { app: appName })}${maxMs > 0 ? ` ${t("This server holds a message for up to {span}.", { span: describeSpan(maxMs) })}` : ""}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { lazy, Suspense, useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { AlertOctagon, Archive, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react";
|
import { AlertOctagon, Archive, ArrowDown, ArrowUp, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder";
|
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder";
|
||||||
import { labelTree, visibleLabels } from "@/lib/mailbox/labelTree";
|
import { labelTree, visibleLabels } from "@/lib/mailbox/labelTree";
|
||||||
@@ -14,6 +14,7 @@ import { toast } from "@/ui/toast";
|
|||||||
import { MailboxPicker } from "./MailboxPicker";
|
import { MailboxPicker } from "./MailboxPicker";
|
||||||
import { loadRaw, saveJson } from "@/lib/storage";
|
import { loadRaw, saveJson } from "@/lib/storage";
|
||||||
import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/mailbox/folderMove";
|
import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/mailbox/folderMove";
|
||||||
|
import { canPlaceFolder, compareFolders, neighbour, placeFolder, type Placement } from "@/lib/mailbox/folderOrder";
|
||||||
import { haptic, useTouchRow } from "@/lib/input/touch";
|
import { haptic, useTouchRow } from "@/lib/input/touch";
|
||||||
import { plural, t } from "@/lib/i18n";
|
import { plural, t } from "@/lib/i18n";
|
||||||
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
|
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
|
||||||
@@ -78,8 +79,32 @@ export function MailboxTree() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tree: A–Z at every level (Inbox pinned to the top of the root), subfolders nested and
|
/** Whether the folder in flight may go just above or below this folder. */
|
||||||
// collapsed by default. Expansion state is remembered per folder.
|
const canPlace = (targetId: Id, placement: Placement): boolean => Boolean(draggingId) && canPlaceFolder(mailboxes, draggingId!, targetId, placement);
|
||||||
|
|
||||||
|
/** Put a folder just above or below another: a drag between rows, or Move up / Move down. */
|
||||||
|
const placeFolderAt = async (id: Id, targetId: Id, placement: Placement) => {
|
||||||
|
setDraggingId(null);
|
||||||
|
const updates = placeFolder(mailboxes, id, targetId, placement);
|
||||||
|
if (!updates) return;
|
||||||
|
try {
|
||||||
|
await useMail.getState().arrangeMailboxes(updates);
|
||||||
|
const parentId = updates[id]?.parentId;
|
||||||
|
if (parentId) {
|
||||||
|
const next = { ...expanded, [parentId]: true };
|
||||||
|
setExpanded(next);
|
||||||
|
saveJson("mbx-expanded", next);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t("Could not move “{name}”: {reason}", { name: mailboxDisplayName(mailboxes[id]!), reason: (err as Error).message }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const shown = (m: Mailbox) => showHidden || m.isSubscribed || m.role === "inbox";
|
||||||
|
|
||||||
|
// Tree: in `compareFolders` order at every level (Inbox, then any order the
|
||||||
|
// user has dragged into place, then the special folders, then A–Z),
|
||||||
|
// subfolders nested and collapsed by default. Expansion state is remembered
|
||||||
|
// per folder.
|
||||||
const [expanded, setExpanded] = useState<Record<Id, boolean>>(() => loadRaw("mbx-expanded", {}));
|
const [expanded, setExpanded] = useState<Record<Id, boolean>>(() => loadRaw("mbx-expanded", {}));
|
||||||
const toggle = (id: Id) => {
|
const toggle = (id: Id) => {
|
||||||
const next = { ...expanded, [id]: !expanded[id] };
|
const next = { ...expanded, [id]: !expanded[id] };
|
||||||
@@ -87,17 +112,13 @@ export function MailboxTree() {
|
|||||||
saveJson("mbx-expanded", next);
|
saveJson("mbx-expanded", next);
|
||||||
};
|
};
|
||||||
const { rows, childrenOf, subtreeUnread } = useMemo(() => {
|
const { rows, childrenOf, subtreeUnread } = useMemo(() => {
|
||||||
const all = Object.values(mailboxes).filter((m) => showHidden || m.isSubscribed || m.role === "inbox");
|
const all = Object.values(mailboxes).filter(shown);
|
||||||
const byParent = new Map<Id | null, Mailbox[]>();
|
const byParent = new Map<Id | null, Mailbox[]>();
|
||||||
for (const m of all) {
|
for (const m of all) {
|
||||||
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
|
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
|
||||||
byParent.set(p, [...(byParent.get(p) ?? []), m]);
|
byParent.set(p, [...(byParent.get(p) ?? []), m]);
|
||||||
}
|
}
|
||||||
const cmp = (a: Mailbox, b: Mailbox) => {
|
for (const list of byParent.values()) list.sort(compareFolders);
|
||||||
if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1;
|
|
||||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
|
|
||||||
};
|
|
||||||
for (const list of byParent.values()) list.sort(cmp);
|
|
||||||
const out: Array<{ m: Mailbox; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number }> = [];
|
const out: Array<{ m: Mailbox; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number }> = [];
|
||||||
const unreadBelow = (id: Id): number => (byParent.get(id) ?? []).reduce((n, c) => n + c.unreadEmails + unreadBelow(c.id), 0);
|
const unreadBelow = (id: Id): number => (byParent.get(id) ?? []).reduce((n, c) => n + c.unreadEmails + unreadBelow(c.id), 0);
|
||||||
const walk = (parent: Id | null, depth: number) => {
|
const walk = (parent: Id | null, depth: number) => {
|
||||||
@@ -207,6 +228,8 @@ export function MailboxTree() {
|
|||||||
onFolderDragStart={() => {}}
|
onFolderDragStart={() => {}}
|
||||||
onFolderDragEnd={() => {}}
|
onFolderDragEnd={() => {}}
|
||||||
onFolderDrop={() => {}}
|
onFolderDrop={() => {}}
|
||||||
|
canPlace={() => false}
|
||||||
|
onFolderPlace={() => {}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -229,6 +252,8 @@ export function MailboxTree() {
|
|||||||
onFolderDragStart={() => setDraggingId(m.id)}
|
onFolderDragStart={() => setDraggingId(m.id)}
|
||||||
onFolderDragEnd={() => { setDraggingId(null); setRootDrop(false); }}
|
onFolderDragEnd={() => { setDraggingId(null); setRootDrop(false); }}
|
||||||
onFolderDrop={(id) => void moveFolder(id, m.id)}
|
onFolderDrop={(id) => void moveFolder(id, m.id)}
|
||||||
|
canPlace={(placement) => canPlace(m.id, placement) && !(placement === "after" && open && hasChildren)}
|
||||||
|
onFolderPlace={(id, placement) => void placeFolderAt(id, m.id, placement)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Labels are a flat list that belongs to the mailbox, not to whichever
|
{/* Labels are a flat list that belongs to the mailbox, not to whichever
|
||||||
@@ -260,7 +285,11 @@ export function MailboxTree() {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<Popover anchor={menu.anchor} onClose={menu.close} width={300}>
|
<Popover anchor={menu.anchor} onClose={menu.close} width={300}>
|
||||||
{menuTarget && <MailboxMenu mailbox={menuTarget} onClose={menu.close} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} onMove={() => { menu.close(); setMoveTarget(menuTarget); }} />}
|
{menuTarget && <MailboxMenu mailbox={menuTarget} onClose={menu.close} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} onMove={() => { menu.close(); setMoveTarget(menuTarget); }} onStep={(direction) => {
|
||||||
|
menu.close();
|
||||||
|
const to = neighbour(mailboxes, menuTarget.id, direction, shown);
|
||||||
|
if (to) void placeFolderAt(menuTarget.id, to.targetId, to.placement);
|
||||||
|
}} canStep={(direction) => Boolean(neighbour(mailboxes, menuTarget.id, direction, shown))} />}
|
||||||
</Popover>
|
</Popover>
|
||||||
{moveTarget && (
|
{moveTarget && (
|
||||||
<MailboxPicker
|
<MailboxPicker
|
||||||
@@ -280,8 +309,9 @@ export function MailboxTree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, onDrillIn, currentId, onMenu, dragging, acceptsFolder, onFolderDragStart, onFolderDragEnd, onFolderDrop }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; onDrillIn?: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void; dragging: boolean; acceptsFolder: boolean; onFolderDragStart: () => void; onFolderDragEnd: () => void; onFolderDrop: (id: Id) => void }) {
|
function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, onDrillIn, currentId, onMenu, dragging, acceptsFolder, onFolderDragStart, onFolderDragEnd, onFolderDrop, canPlace, onFolderPlace }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; onDrillIn?: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void; dragging: boolean; acceptsFolder: boolean; onFolderDragStart: () => void; onFolderDragEnd: () => void; onFolderDrop: (id: Id) => void; canPlace: (placement: Placement) => boolean; onFolderPlace: (id: Id, placement: Placement) => void }) {
|
||||||
const [dropping, setDropping] = useState(false);
|
/** Where a drop here would land: in this folder, or just above or below it. */
|
||||||
|
const [drop, setDrop] = useState<"into" | Placement | null>(null);
|
||||||
/** Expanding in place and drilling in are the same relationship; only one shows. */
|
/** Expanding in place and drilling in are the same relationship; only one shows. */
|
||||||
const twisty = hasChildren && !onDrillIn;
|
const twisty = hasChildren && !onDrillIn;
|
||||||
// Scheduled counts like Drafts: everything in it is already read, so the
|
// Scheduled counts like Drafts: everything in it is already read, so the
|
||||||
@@ -297,19 +327,37 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
// Subscribed, not read once: picking a color has to repaint the row.
|
// Subscribed, not read once: picking a color has to repaint the row.
|
||||||
const tint = useSettings((s) => folderColor(s.settings.folderColors, m.id));
|
const tint = useSettings((s) => folderColor(s.settings.folderColors, m.id));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A folder dropped on the top or bottom quarter of a row goes above or below
|
||||||
|
* it; anywhere else, into it. Where "into" isn't allowed -- a special folder,
|
||||||
|
* which can be reordered but never nested -- the whole row reorders, by
|
||||||
|
* whichever half the pointer is in.
|
||||||
|
*/
|
||||||
|
const folderZone = (e: DragEvent): "into" | Placement | null => {
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
const y = e.clientY - r.top;
|
||||||
|
const edge = r.height / 4;
|
||||||
|
const zone = y < edge ? "before" : y > r.height - edge ? "after" : "into";
|
||||||
|
if (zone !== "into" && canPlace(zone)) return zone;
|
||||||
|
if (acceptsFolder) return "into";
|
||||||
|
const half = y < r.height / 2 ? "before" : "after";
|
||||||
|
return canPlace(half) ? half : null;
|
||||||
|
};
|
||||||
const onDragOver = (e: DragEvent) => {
|
const onDragOver = (e: DragEvent) => {
|
||||||
const folder = e.dataTransfer.types.includes(FOLDER_MIME);
|
const zone = e.dataTransfer.types.includes(FOLDER_MIME) ? folderZone(e) : e.dataTransfer.types.includes("application/x-ihasmail-emails") ? "into" : null;
|
||||||
if (folder ? !acceptsFolder : !e.dataTransfer.types.includes("application/x-ihasmail-emails")) return;
|
if (!zone) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "move";
|
e.dataTransfer.dropEffect = "move";
|
||||||
if (!dropping) setDropping(true);
|
if (drop !== zone) setDrop(zone);
|
||||||
};
|
};
|
||||||
const onDrop = (e: DragEvent) => {
|
const onDrop = (e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDropping(false);
|
setDrop(null);
|
||||||
const folderId = e.dataTransfer.getData(FOLDER_MIME);
|
const folderId = e.dataTransfer.getData(FOLDER_MIME);
|
||||||
if (folderId) {
|
if (folderId) {
|
||||||
if (acceptsFolder) onFolderDrop(folderId);
|
const zone = folderZone(e);
|
||||||
|
if (zone === "into") onFolderDrop(folderId);
|
||||||
|
else if (zone) onFolderPlace(folderId, zone);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const raw = e.dataTransfer.getData("application/x-ihasmail-emails");
|
const raw = e.dataTransfer.getData("application/x-ihasmail-emails");
|
||||||
@@ -348,16 +396,17 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/mail/${m.id}`}
|
href={`/mail/${m.id}`}
|
||||||
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""} ${dragging ? "dragging" : ""}`}
|
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${drop === "into" ? "drop-target" : drop ? `drop-${drop}` : ""} ${dragging ? "dragging" : ""}`}
|
||||||
title={label}
|
title={label}
|
||||||
{...press}
|
{...press}
|
||||||
// Dragging a folder is a mouse gesture; on a touchscreen the browser
|
// Dragging a folder is a mouse gesture; on a touchscreen the browser
|
||||||
// starts it from the same long press that now opens the menu.
|
// starts it from the same long press that now opens the menu. Special
|
||||||
draggable={movable(m) && !isTouch}
|
// folders drag too, to be reordered; only Inbox, always first, stays put.
|
||||||
|
draggable={m.role !== "inbox" && !isTouch}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onFolderDragEnd}
|
onDragEnd={onFolderDragEnd}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDragLeave={() => setDropping(false)}
|
onDragLeave={() => setDrop(null)}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -424,7 +473,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void; onMove: () => void }) {
|
function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove, onStep, canStep }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void; onMove: () => void; onStep: (direction: "up" | "down") => void; canStep: (direction: "up" | "down") => boolean }) {
|
||||||
const shared = Object.keys(m.shareWith ?? {}).length > 0;
|
const shared = Object.keys(m.shareWith ?? {}).length > 0;
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const colors = useSettings((s) => s.settings.folderColors);
|
const colors = useSettings((s) => s.settings.folderColors);
|
||||||
@@ -490,6 +539,9 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove }: {
|
|||||||
<MenuItem icon={<FolderPlus size={16} />} label={t("New subfolder")} onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
|
<MenuItem icon={<FolderPlus size={16} />} label={t("New subfolder")} onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
|
||||||
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
|
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
|
||||||
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={onMove} disabled={!movable(m) || !m.myRights.mayRename} />
|
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={onMove} disabled={!movable(m) || !m.myRights.mayRename} />
|
||||||
|
{/* The way to reorder without a drag: from the keyboard, and on touch. */}
|
||||||
|
<MenuItem icon={<ArrowUp size={16} />} label={t("Move up")} onClick={() => onStep("up")} disabled={!canStep("up")} />
|
||||||
|
<MenuItem icon={<ArrowDown size={16} />} label={t("Move down")} onClick={() => onStep("down")} disabled={!canStep("down")} />
|
||||||
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
|
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
|
||||||
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
|
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
|
||||||
stores the share, and it never reaches the other account -- its own
|
stores the share, and it never reaches the other account -- its own
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { currentAppName, useAppName } from "@/lib/brand";
|
||||||
import { BadgeCheck, ShieldAlert, ShieldQuestion, ShieldX } from "lucide-react";
|
import { BadgeCheck, ShieldAlert, ShieldQuestion, ShieldX } from "lucide-react";
|
||||||
import { formatFingerprint } from "@/lib/smime/x509";
|
import { formatFingerprint } from "@/lib/smime/x509";
|
||||||
import type { SignatureState } from "@/lib/smime/useSignature";
|
import type { SignatureState } from "@/lib/smime/useSignature";
|
||||||
@@ -22,6 +23,7 @@ import { formatFullDate } from "@/lib/format";
|
|||||||
* verified against itself.
|
* verified against itself.
|
||||||
*/
|
*/
|
||||||
export function SignatureBanner({ state }: { state: SignatureState }) {
|
export function SignatureBanner({ state }: { state: SignatureState }) {
|
||||||
|
const appName = useAppName();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
if (state.status !== "done") return null;
|
if (state.status !== "done") return null;
|
||||||
const { crypto, trust, previous, warnings } = state.report;
|
const { crypto, trust, previous, warnings } = state.report;
|
||||||
@@ -31,7 +33,7 @@ export function SignatureBanner({ state }: { state: SignatureState }) {
|
|||||||
return (
|
return (
|
||||||
<Banner tone="quiet" icon={<ShieldQuestion size={16} />}>
|
<Banner tone="quiet" icon={<ShieldQuestion size={16} />}>
|
||||||
<span className="grow">
|
<span className="grow">
|
||||||
{t("This message is signed, and ihasmail could not check the signature.")} {explain(crypto.reason)}
|
{t("This message is signed, and {app} could not check the signature.", { app: appName })} {explain(crypto.reason)}
|
||||||
{crypto.detail && <span className="hint"> {crypto.detail}</span>}
|
{crypto.detail && <span className="hint"> {crypto.detail}</span>}
|
||||||
</span>
|
</span>
|
||||||
</Banner>
|
</Banner>
|
||||||
@@ -77,7 +79,7 @@ export function SignatureBanner({ state }: { state: SignatureState }) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{tNode("Signed by {name}, seen here for the first time.", { name: <strong className="notranslate" translate="no">{name}</strong> })}{" "}
|
{tNode("Signed by {name}, seen here for the first time.", { name: <strong className="notranslate" translate="no">{name}</strong> })}{" "}
|
||||||
{t("ihasmail will tell you if a later message from this address is signed by anybody else.")}
|
{t("{app} will tell you if a later message from this address is signed by anybody else.", { app: appName })}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{warnings.includes("certificate-expired") && <> {t("The certificate has expired.")}</>}
|
{warnings.includes("certificate-expired") && <> {t("The certificate has expired.")}</>}
|
||||||
@@ -134,11 +136,12 @@ export function SignatureBanner({ state }: { state: SignatureState }) {
|
|||||||
|
|
||||||
/** The sayable version of why a check did not happen, or did not hold. */
|
/** The sayable version of why a check did not happen, or did not hold. */
|
||||||
function explain(reason: Reason): string {
|
function explain(reason: Reason): string {
|
||||||
|
const appName = currentAppName();
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case "openpgp":
|
case "openpgp":
|
||||||
return t("It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.");
|
return t("It is signed with OpenPGP, and {app} has no way to fetch the sender's public key.", { app: appName });
|
||||||
case "rsa-pss":
|
case "rsa-pss":
|
||||||
return t("It uses a signature algorithm ihasmail cannot check yet.");
|
return t("It uses a signature algorithm {app} cannot check yet.", { app: appName });
|
||||||
case "no-certificate":
|
case "no-certificate":
|
||||||
return t("The signature carries no certificate that can be read.");
|
return t("The signature carries no certificate that can be read.");
|
||||||
case "not-signed-properly":
|
case "not-signed-properly":
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { MailboxTree } from "../MailboxTree";
|
||||||
|
import { useMail } from "@/store/mail";
|
||||||
|
import { useSettings } from "@/store/settings";
|
||||||
|
import type { Mailbox, MailboxRole } from "@/jmap/types";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reordering folders in the sidebar (#402), driven through the real tree.
|
||||||
|
*
|
||||||
|
* The placement arithmetic has its own tests in lib/mailbox; these are about
|
||||||
|
* the row deciding where a drop lands from where the pointer is, which only
|
||||||
|
* the component knows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
|
const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
|
||||||
|
const box = (id: string, name: string, parentId: string | null, role: MailboxRole = null): Mailbox => ({
|
||||||
|
id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, myRights: rights, isSubscribed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAILBOXES = {
|
||||||
|
zeta: box("zeta", "Zeta", null),
|
||||||
|
trash: box("trash", "Deleted Items", null, "trash"),
|
||||||
|
sent: box("sent", "Sent", null, "sent"),
|
||||||
|
inbox: box("inbox", "Inbox", null, "inbox"),
|
||||||
|
alpha: box("alpha", "Alpha", null),
|
||||||
|
drafts: box("drafts", "Drafts", null, "drafts"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** jsdom has no DataTransfer; this is the part of one the tree touches. */
|
||||||
|
function transfer() {
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
return {
|
||||||
|
get types() { return Object.keys(data); },
|
||||||
|
setData: (k: string, v: string) => { data[k] = v; },
|
||||||
|
getData: (k: string) => data[k] ?? "",
|
||||||
|
effectAllowed: "", dropEffect: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fire(el: Element, type: string, dataTransfer: ReturnType<typeof transfer>, clientY = 0) {
|
||||||
|
const e = new Event(type, { bubbles: true, cancelable: true });
|
||||||
|
Object.assign(e, { dataTransfer, clientY });
|
||||||
|
act(() => { el.dispatchEvent(e); });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("reordering folders in the tree", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
const arrange = vi.fn(async (_updates: Record<string, Partial<Mailbox>>) => {});
|
||||||
|
const updateMailbox = vi.fn(async (_id: string, _patch: Partial<Mailbox>) => {});
|
||||||
|
const rows = () => Array.from(document.querySelectorAll(".nav-item.folder-row")).map((r) => r.querySelector(".nav-label")?.textContent);
|
||||||
|
const rowFor = (name: string) => Array.from(document.querySelectorAll<HTMLElement>(".nav-item.folder-row")).find((r) => r.querySelector(".nav-label")?.textContent === name)!;
|
||||||
|
|
||||||
|
/** Drag `from` over `to` at a fraction of its height, drop, and say what the row showed. */
|
||||||
|
function drag(from: string, to: string, frac: number) {
|
||||||
|
const dt = transfer();
|
||||||
|
const target = rowFor(to);
|
||||||
|
// Every row is 36px tall, from 100px down the page.
|
||||||
|
target.getBoundingClientRect = () => ({ top: 100, height: 36, bottom: 136, left: 0, right: 200, width: 200, x: 0, y: 100, toJSON() {} });
|
||||||
|
fire(rowFor(from), "dragstart", dt);
|
||||||
|
fire(target, "dragover", dt, 100 + 36 * frac);
|
||||||
|
const shown = /drop-(before|after|target)/.exec(target.className)?.[1] ?? null;
|
||||||
|
fire(target, "drop", dt, 100 + 36 * frac);
|
||||||
|
return shown;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
arrange.mockClear();
|
||||||
|
updateMailbox.mockClear();
|
||||||
|
window.history.replaceState({}, "", "/mail/inbox");
|
||||||
|
useMail.setState({ mailboxes: MAILBOXES, mailboxesLoaded: true, arrangeMailboxes: arrange, updateMailbox });
|
||||||
|
useSettings.setState((s) => ({ settings: { ...s.settings, showHiddenFolders: false, labelsSidebar: false } }));
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
act(() => root.render(<MailboxTree />));
|
||||||
|
});
|
||||||
|
afterEach(() => { act(() => root.unmount()); host.remove(); });
|
||||||
|
|
||||||
|
it("lists special folders under Inbox before the rest, until something is dragged", () => {
|
||||||
|
expect(rows()).toEqual(["Inbox", "Drafts", "Sent", "Deleted Items", "Alpha", "Zeta"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a folder above the row when it's dropped on the row's top edge", () => {
|
||||||
|
expect(drag("Zeta", "Drafts", 0.1)).toBe("before");
|
||||||
|
expect(arrange).toHaveBeenCalledWith({
|
||||||
|
zeta: { sortOrder: 20 }, drafts: { sortOrder: 30 }, sent: { sortOrder: 40 }, trash: { sortOrder: 50 }, alpha: { sortOrder: 60 }, inbox: { sortOrder: 10 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests a folder dropped on the middle of an ordinary folder, as before", () => {
|
||||||
|
expect(drag("Zeta", "Alpha", 0.5)).toBe("target");
|
||||||
|
expect(updateMailbox).toHaveBeenCalledWith("zeta", { parentId: "alpha" });
|
||||||
|
expect(arrange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders a special folder by the nearer half, since it can't be nested", () => {
|
||||||
|
expect(drag("Deleted Items", "Drafts", 0.4)).toBe("before");
|
||||||
|
expect(Object.keys(arrange.mock.calls[0]![0])).toContain("trash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops nothing above Inbox", () => {
|
||||||
|
expect(drag("Sent", "Inbox", 0.1)).toBeNull();
|
||||||
|
expect(arrange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,39 +1,48 @@
|
|||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
|
import { useAppName } from "@/lib/brand";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
import { DEFAULT_SOURCE_URL } from "@/lib/source";
|
||||||
import { withBase } from "@/lib/basePath";
|
import { withBase } from "@/lib/basePath";
|
||||||
import { t, tNode } from "@/lib/i18n";
|
import { t, tNode } from "@/lib/i18n";
|
||||||
|
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
||||||
|
|
||||||
export function AboutSettings() {
|
export function AboutSettings() {
|
||||||
|
const appName = useAppName();
|
||||||
const session = useSession((s) => s.session);
|
const session = useSession((s) => s.session);
|
||||||
const caps = Object.keys(session?.capabilities ?? {});
|
const caps = Object.keys(session?.capabilities ?? {});
|
||||||
// A deployment running modified code should offer its own source, not ours.
|
// A deployment running modified code should offer its own source, not ours.
|
||||||
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
|
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{t("About ihasmail")}</h1>
|
{/* ihasmail-inbuxa: INBUXA's webmail, built on ihasmail. The version and
|
||||||
<p className="lead">{tNode("A fast, friendly, open-source webmail for {server}, built on JMAP.", { server: <a href="https://stalw.art" target="_blank" rel="noreferrer">{t("Stalwart Mail Server")}</a> })}</p>
|
source are this build's, the AGPL's offer; ihasmail keeps its credit.
|
||||||
|
The name comes from APP_NAME now, so a renamed deployment is named
|
||||||
|
here too. */}
|
||||||
|
<h1>{t("About {app}", { app: appName })}</h1>
|
||||||
|
<p className="lead">{tNode("A fast, friendly, open-source webmail for {server}, built on JMAP.", { server: <span className="notranslate" translate="no">{appName}</span> })}</p>
|
||||||
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
||||||
<img src={withBase("/img/logo.png")} alt={t("ihasmail")} width={96} />
|
<img src={withBase("/img/inbuxa-mark.png")} alt="" width={80} />
|
||||||
<div>
|
<div>
|
||||||
|
<InbuxaWordmark height={26} />
|
||||||
{/* A product name and a version string: neither is a word to translate. */}
|
{/* A product name and a version string: neither is a word to translate. */}
|
||||||
<div style={{ fontWeight: 700, fontSize: "1.2em" }} className="notranslate" translate="no">ihasmail v{APP_VERSION}</div>
|
<div style={{ fontWeight: 700 }} className="notranslate" translate="no">{appName} webmail v{APP_VERSION}</div>
|
||||||
<div className="hint">{tNode("AGPL-3.0-or-later · {source}", { source: <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a> })}</div>
|
<div className="hint">{tNode("Built on {project}", { project: <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">ihasmail</a> })}</div>
|
||||||
|
<div className="hint">{tNode("AGPL-3.0-or-later · {source}", { source: <a href={sourceUrl} target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">{sourceUrl.replace(/^https?:\/\//, "")}</a> })}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2>{t("Server")}</h2>
|
<h2>{t("Server")}</h2>
|
||||||
<table className="sessions-table">
|
<table className="sessions-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>{t("Signed in as")}</td><td>{session?.username}</td></tr>
|
<tr><td>{t("Signed in as")}</td><td>{session?.username}</td></tr>
|
||||||
<tr><td>{t("Stalwart")}</td><td>{describeServer(session?.ihasmail?.server)}</td></tr>
|
<tr><td>{t("Mail server")}</td><td className="notranslate" translate="no">INBUXA</td></tr>
|
||||||
<tr><td>{t("Accounts")}</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
|
<tr><td>{t("Accounts")}</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
|
||||||
<tr><td>{t("Max upload")}</td><td>{t("{size} MB", { size: Math.round(client.maxSizeUpload / 1048576) })}</td></tr>
|
<tr><td>{t("Max upload")}</td><td>{t("{size} MB", { size: Math.round(client.maxSizeUpload / 1048576) })}</td></tr>
|
||||||
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr>
|
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p className="hint" style={{ marginTop: 6 }}>{t("Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.")}</p>
|
<p className="hint" style={{ marginTop: 6 }}>{t("This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.")}</p>
|
||||||
<p className="hint">{tNode("ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.", { example: <strong className="notranslate" translate="no">v2026.8.30+pr129</strong>, sha: <code>+g1fa6578</code> })}</p>
|
<p className="hint">{tNode("{app}'s own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.", { example: <strong className="notranslate" translate="no">v2026.8.30+pr129</strong>, sha: <code>+g1fa6578</code> }, { app: appName })}</p>
|
||||||
<h2>{t("Server capabilities")}</h2>
|
<h2>{t("Server capabilities")}</h2>
|
||||||
<div className="row wrap gap-4">
|
<div className="row wrap gap-4">
|
||||||
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
|
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
|
||||||
@@ -41,14 +50,3 @@ export function AboutSettings() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stalwart deliberately withholds its version from clients (it reports a fixed
|
|
||||||
* "1.0.0" wherever it publishes one at all), so the edition is all there is to
|
|
||||||
* show. The generation used to be reported here too, back when ihasmail spoke
|
|
||||||
* to both 0.15 and 0.16; it requires 0.16 now, so signing in at all is the
|
|
||||||
* answer to that question.
|
|
||||||
*/
|
|
||||||
function describeServer(server: { edition?: string | null } | undefined): string {
|
|
||||||
return server?.edition ? `0.16 or newer (${server.edition})` : "0.16 or newer";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
|
import { useAppName } from "@/lib/brand";
|
||||||
import { PALETTES, effectiveMode, type Mode, type PaletteId } from "@/lib/palette";
|
import { PALETTES, effectiveMode, type Mode, type PaletteId } from "@/lib/palette";
|
||||||
import { Switch, useIsTouch } from "@/ui/misc";
|
import { Switch, useIsTouch } from "@/ui/misc";
|
||||||
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/input/swipe";
|
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/input/swipe";
|
||||||
@@ -75,6 +76,7 @@ const ACCENTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function AppearanceSettings() {
|
export function AppearanceSettings() {
|
||||||
|
const appName = useAppName();
|
||||||
const s = useSettings((st) => st.settings);
|
const s = useSettings((st) => st.settings);
|
||||||
const update = useSettings((st) => st.update);
|
const update = useSettings((st) => st.update);
|
||||||
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
|
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
|
||||||
@@ -84,7 +86,7 @@ export function AppearanceSettings() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{translate("Appearance")}</h1>
|
<h1>{translate("Appearance")}</h1>
|
||||||
<p className="lead">{translate("Make ihasmail yours.")}</p>
|
<p className="lead">{translate("Make {app} yours.", { app: appName })}</p>
|
||||||
<h2>{translate("Theme")}</h2>
|
<h2>{translate("Theme")}</h2>
|
||||||
<div className="mode-switch" role="group" aria-label={translate("Light or dark")}>
|
<div className="mode-switch" role="group" aria-label={translate("Light or dark")}>
|
||||||
{MODES.map((m) => (
|
{MODES.map((m) => (
|
||||||
@@ -178,7 +180,7 @@ export function AppearanceSettings() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
{translate("Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.")}
|
{translate("Only languages {app} has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.", { app: appName })}
|
||||||
</p>
|
</p>
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
|
import { useAppName } from "@/lib/brand";
|
||||||
import { Switch } from "@/ui/misc";
|
import { Switch } from "@/ui/misc";
|
||||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
@@ -183,6 +184,7 @@ export function GeneralSettings() {
|
|||||||
<Switch locked={isEnforced("includeQuote")} checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
|
<Switch locked={isEnforced("includeQuote")} checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
|
||||||
<Switch locked={isEnforced("signatureAboveQuote")} checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
|
<Switch locked={isEnforced("signatureAboveQuote")} checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
|
||||||
<Switch locked={isEnforced("spellcheck")} checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
|
<Switch locked={isEnforced("spellcheck")} checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
|
||||||
|
<Switch locked={isEnforced("composeMaximized")} checked={s.composeMaximized} onChange={(v) => update({ composeMaximized: v })} label={t("Open the composer full screen")} />
|
||||||
|
|
||||||
<h2>{t("Locale")}</h2>
|
<h2>{t("Locale")}</h2>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
@@ -254,6 +256,7 @@ export function GeneralSettings() {
|
|||||||
* it can and points at the browser's own settings for the rest.
|
* it can and points at the browser's own settings for the rest.
|
||||||
*/
|
*/
|
||||||
function MailHandlerSettings() {
|
function MailHandlerSettings() {
|
||||||
|
const appName = useAppName();
|
||||||
const support = mailtoHandlerSupport();
|
const support = mailtoHandlerSupport();
|
||||||
const [requested, setRequested] = useState(mailtoHandlerRequested);
|
const [requested, setRequested] = useState(mailtoHandlerRequested);
|
||||||
|
|
||||||
@@ -261,7 +264,7 @@ function MailHandlerSettings() {
|
|||||||
try {
|
try {
|
||||||
registerMailtoHandler();
|
registerMailtoHandler();
|
||||||
setRequested(true);
|
setRequested(true);
|
||||||
toast.success(t("Your browser will ask whether to open mail links in ihasmail"));
|
toast.success(t("Your browser will ask whether to open mail links in {app}", { app: appName }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(t("Your browser refused the request: {error}", { error: (err as Error).message }));
|
toast.error(t("Your browser refused the request: {error}", { error: (err as Error).message }));
|
||||||
}
|
}
|
||||||
@@ -274,7 +277,7 @@ function MailHandlerSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (support === "unsupported") {
|
if (support === "unsupported") {
|
||||||
return <p className="hint">{tNode("This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.", { scheme: <code>mailto:</code> })}</p>;
|
return <p className="hint">{tNode("This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make {app} the default from your operating system if you install it as an app.", { scheme: <code>mailto:</code> }, { app: appName })}</p>;
|
||||||
}
|
}
|
||||||
if (support === "insecure") {
|
if (support === "insecure") {
|
||||||
return <p className="hint">{tNode("Registering for {scheme} links requires a secure (HTTPS) connection.", { scheme: <code>mailto:</code> })}</p>;
|
return <p className="hint">{tNode("Registering for {scheme} links requires a secure (HTTPS) connection.", { scheme: <code>mailto:</code> })}</p>;
|
||||||
@@ -283,7 +286,7 @@ function MailHandlerSettings() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
{tNode("Open {scheme} links — in web pages, documents and other apps — in ihasmail 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 › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).", { scheme: <code>mailto:</code> })}
|
{tNode("Open {scheme} links — in web pages, documents and other apps — 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 › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).", { scheme: <code>mailto:</code> }, { app: appName })}
|
||||||
</p>
|
</p>
|
||||||
<div className="row wrap">
|
<div className="row wrap">
|
||||||
<button className="btn btn-primary" onClick={ask}>{requested ? "Ask again" : "Make ihasmail the default mail app"}</button>
|
<button className="btn btn-primary" onClick={ask}>{requested ? "Ask again" : "Make ihasmail the default mail app"}</button>
|
||||||
@@ -293,7 +296,7 @@ function MailHandlerSettings() {
|
|||||||
{!isInstalledApp() && (
|
{!isInstalledApp() && (
|
||||||
<p className="hint mt-8">
|
<p className="hint mt-8">
|
||||||
|
|
||||||
{t("For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.")}
|
{t("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.", { app: appName })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useAppName } from "@/lib/brand";
|
||||||
import { Plus, Trash2, Star, Eye, EyeOff } from "lucide-react";
|
import { Plus, Trash2, Star, Eye, EyeOff } from "lucide-react";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
@@ -75,6 +76,7 @@ export function IdentitiesSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; onClose: () => void }) {
|
function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; onClose: () => void }) {
|
||||||
|
const appName = useAppName();
|
||||||
const [name, setName] = useState(identity.name ?? "");
|
const [name, setName] = useState(identity.name ?? "");
|
||||||
const [email, setEmail] = useState(identity.email ?? "");
|
const [email, setEmail] = useState(identity.email ?? "");
|
||||||
const [replyTo, setReplyTo] = useState(formatAddressList(identity.replyTo));
|
const [replyTo, setReplyTo] = useState(formatAddressList(identity.replyTo));
|
||||||
@@ -129,7 +131,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
|
|||||||
<span className="hint">{t("Images are stored in your Files (folder “ihasmail”) and embedded when you send.")}</span>
|
<span className="hint">{t("Images are stored in your Files (folder “ihasmail”) and embedded when you send.")}</span>
|
||||||
<span className="hint nowrap" style={tooLong ? { color: "var(--warn)", fontWeight: 600 } : undefined}>{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}</span>
|
<span className="hint nowrap" style={tooLong ? { color: "var(--warn)", fontWeight: 600 } : undefined}>{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
{tooLong && <div className="warn-box mt-8">{t("This signature is larger than the server's {limit}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.", { limit: SIGNATURE_LIMIT })}</div>}
|
{tooLong && <div className="warn-box mt-8">{t("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 — other mail clients will see the plain-text version.", { limit: SIGNATURE_LIMIT, app: appName })}</div>}
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useAppName } from "@/lib/brand";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useSettings, type LabelVisibility } from "@/store/settings";
|
import { useSettings, type LabelVisibility } from "@/store/settings";
|
||||||
import { labelTree, descendantKeywords } from "@/lib/mailbox/labelTree";
|
import { labelTree, descendantKeywords } from "@/lib/mailbox/labelTree";
|
||||||
@@ -8,6 +9,7 @@ import { promptDialog } from "@/ui/dialog";
|
|||||||
import { t, tNode } from "@/lib/i18n";
|
import { t, tNode } from "@/lib/i18n";
|
||||||
|
|
||||||
export function LabelsSettings() {
|
export function LabelsSettings() {
|
||||||
|
const appName = useAppName();
|
||||||
const labels = useSettings((s) => s.settings.labels);
|
const labels = useSettings((s) => s.settings.labels);
|
||||||
const update = useSettings((s) => s.update);
|
const update = useSettings((s) => s.update);
|
||||||
const [editing, setEditing] = useState<string | null>(null);
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
@@ -25,7 +27,7 @@ export function LabelsSettings() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{t("Labels")}</h1>
|
<h1>{t("Labels")}</h1>
|
||||||
<p className="lead">{t("Labels are IMAP keywords stored on your messages, so every other client sees them. Names, colors and nesting are ihasmail\u2019s own and follow your account. Nesting is display only \u2014 it rewrites nothing in the mailbox.")}</p>
|
<p className="lead">{t("Labels are IMAP keywords stored on your messages, so every other client sees them. Names, colors and nesting are {app}\u2019s own and follow your account. Nesting is display only \u2014 it rewrites nothing in the mailbox.", { app: appName })}</p>
|
||||||
{labels.map((l) => (
|
{labels.map((l) => (
|
||||||
<div key={l.keyword} className="card">
|
<div key={l.keyword} className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user